Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
da4ca8a
Refactor: Always include security_risk field in tool schemas
openhands-agent Nov 6, 2025
0e5b697
Update agent.py
malhotra5 Nov 6, 2025
e087266
send back error events
malhotra5 Nov 6, 2025
7b5a9dd
simplify risk field handling
malhotra5 Nov 6, 2025
c5e6329
fix comment
malhotra5 Nov 6, 2025
90eeb48
add comments
malhotra5 Nov 6, 2025
9c4a85a
Add SecurityAnalyzerConfigurationEvent for tracking analyzer configur…
openhands-agent Nov 6, 2025
42a2bfc
move event emit
malhotra5 Nov 6, 2025
e1e612c
prevent dupe configuration events
malhotra5 Nov 6, 2025
951a405
rm fluff tests
malhotra5 Nov 6, 2025
35ce7db
write tests for event equality and serialization
malhotra5 Nov 6, 2025
625e509
Merge branch 'main' into refactor/always-include-security-risk-fields
malhotra5 Nov 6, 2025
1106bdf
fix merge conflicts
malhotra5 Nov 6, 2025
51ad9dc
Add comprehensive unit tests for Agent._extract_security_risk method
openhands-agent Nov 6, 2025
e3ab2a2
Add comprehensive unit tests for SecurityAnalyzerConfigurationEvent b…
openhands-agent Nov 6, 2025
40d02df
Fix failing unit tests due to SecurityAnalyzerConfigurationEvent
openhands-agent Nov 6, 2025
9ac3455
Fix test_conversation_persistence_lifecycle for SecurityAnalyzerConfi…
openhands-agent Nov 6, 2025
f5c0d4d
Fix SecurityAnalyzerConfigurationEvent equality and test expectations
openhands-agent Nov 6, 2025
e188cf3
fix tests
malhotra5 Nov 7, 2025
480e53d
Merge branch 'main' into refactor/always-include-security-risk-fields
malhotra5 Nov 7, 2025
160a6a2
always default to adding risk prediction
malhotra5 Nov 7, 2025
8405598
Merge branch 'refactor/always-include-security-risk-fields' of https:…
malhotra5 Nov 7, 2025
01646be
Revert "always default to adding risk prediction"
malhotra5 Nov 7, 2025
248648f
Update tool.py
malhotra5 Nov 7, 2025
2402da2
simplify
malhotra5 Nov 7, 2025
4b3e817
handle readonly case
malhotra5 Nov 7, 2025
4d58824
Fix failing unit tests for security risk extraction
openhands-agent Nov 7, 2025
e222455
Delete test_security_risk_schema_consistency.py
malhotra5 Nov 7, 2025
66439c7
Merge branch 'main' into refactor/always-include-security-risk-fields
malhotra5 Nov 7, 2025
2077d67
rename param
malhotra5 Nov 7, 2025
7d33f82
rename param
malhotra5 Nov 7, 2025
a0b1869
Fix line length issues in test comments
openhands-agent Nov 7, 2025
17ae5fe
Move security_analyzer field from Agent class to ConversationState
openhands-agent Nov 7, 2025
503e577
override system prompt
malhotra5 Nov 7, 2025
360ee72
record transition
malhotra5 Nov 7, 2025
fdfedb5
Merge branch 'main' into refactor/always-include-security-risk-fields
malhotra5 Nov 10, 2025
4690734
add deprecation warning
malhotra5 Nov 10, 2025
e42ad06
clear analyze after moving to state
malhotra5 Nov 10, 2025
83bfee7
set security analyzer
malhotra5 Nov 10, 2025
32447c9
simplify tests and make fix
malhotra5 Nov 10, 2025
0ddccd1
rm comment
malhotra5 Nov 10, 2025
e4b2aea
Update base.py
malhotra5 Nov 10, 2025
2da10e7
add back reconciliation
malhotra5 Nov 10, 2025
9a5cc2c
clean up tests
malhotra5 Nov 10, 2025
ba70f91
add backwards comp test
malhotra5 Nov 10, 2025
307e650
Merge branch 'main' into refactor/always-include-security-risk-fields
malhotra5 Nov 10, 2025
abc199a
Fix SecurityAnalyzerRecord validation by adding explicit default
openhands-agent Nov 10, 2025
7db47ad
Update test_agent_reconciliation.py
malhotra5 Nov 10, 2025
f00c4ac
Fix missing get_system_message method in Agent class
openhands-agent Nov 10, 2025
f62ff13
rm system prompt diff
malhotra5 Nov 10, 2025
a3303e3
rm comment
malhotra5 Nov 10, 2025
d8c66e5
Update test_security_policy_integration.py
malhotra5 Nov 10, 2025
72b3255
Update test_security_policy_integration.py
malhotra5 Nov 10, 2025
4ffa36e
Remove manual autosave from security analyzer updates and fix test ex…
openhands-agent Nov 10, 2025
f260741
Remove noqa: E501 markings and fix line length issues
openhands-agent Nov 10, 2025
7ac1017
simplify tests
malhotra5 Nov 10, 2025
51d30cb
simplify tests
malhotra5 Nov 10, 2025
eb8626c
Merge branch 'main' into refactor/always-include-security-risk-fields
malhotra5 Nov 10, 2025
816bb10
rm unused method
malhotra5 Nov 11, 2025
b1662d0
add remote endpoint tests
malhotra5 Nov 11, 2025
533f7b0
Merge branch 'main' into refactor/always-include-security-risk-fields
malhotra5 Nov 11, 2025
f314857
rm old event
malhotra5 Nov 11, 2025
090ae58
Update types.py
malhotra5 Nov 11, 2025
eca4672
simplify tests
malhotra5 Nov 11, 2025
f973187
rm configuration history
malhotra5 Nov 11, 2025
b5774dd
document decision via comment
malhotra5 Nov 11, 2025
d60da37
use model validator for security prompt default
malhotra5 Nov 11, 2025
41101cc
Merge branch 'main' into refactor/always-include-security-risk-fields
xingyaoww Nov 11, 2025
c7d5438
Add integration test for security_risk field with live agent server
openhands-agent Nov 11, 2025
7b7a325
Merge branch 'main' into refactor/always-include-security-risk-fields
malhotra5 Nov 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
GenerateTitleResponse,
SendMessageRequest,
SetConfirmationPolicyRequest,
SetSecurityAnalyzerRequest,
StartConversationRequest,
Success,
UpdateConversationRequest,
Expand Down Expand Up @@ -237,6 +238,23 @@ async def set_conversation_confirmation_policy(
return Success()


@conversation_router.post(
"/{conversation_id}/security_analyzer",
responses={404: {"description": "Item not found"}},
)
async def set_conversation_security_analyzer(
conversation_id: UUID,
request: SetSecurityAnalyzerRequest,
conversation_service: ConversationService = Depends(get_conversation_service),
) -> Success:
"""Set the security analyzer for a conversation."""
event_service = await conversation_service.get_event_service(conversation_id)
if event_service is None:
raise HTTPException(status.HTTP_404_NOT_FOUND)
await event_service.set_security_analyzer(request.security_analyzer)
return Success()


@conversation_router.patch(
"/{conversation_id}", responses={404: {"description": "Item not found"}}
)
Expand Down
12 changes: 12 additions & 0 deletions openhands-agent-server/openhands/agent_server/event_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ConversationState,
)
from openhands.sdk.event.conversation_state import ConversationStateUpdateEvent
from openhands.sdk.security.analyzer import SecurityAnalyzerBase
from openhands.sdk.security.confirmation_policy import ConfirmationPolicyBase
from openhands.sdk.utils.async_utils import AsyncCallbackWrapper
from openhands.sdk.utils.cipher import Cipher
Expand Down Expand Up @@ -303,6 +304,17 @@ async def set_confirmation_policy(self, policy: ConfirmationPolicyBase):
None, self._conversation.set_confirmation_policy, policy
)

async def set_security_analyzer(
self, security_analyzer: SecurityAnalyzerBase | None
):
"""Set the security analyzer for the conversation."""
if not self._conversation:
raise ValueError("inactive_service")
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None, self._conversation.set_security_analyzer, security_analyzer
)

async def close(self):
await self._pub_sub.close()
if self._conversation:
Expand Down
9 changes: 9 additions & 0 deletions openhands-agent-server/openhands/agent_server/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
ConversationState,
)
from openhands.sdk.llm.utils.metrics import MetricsSnapshot
from openhands.sdk.security.analyzer import SecurityAnalyzerBase
from openhands.sdk.security.confirmation_policy import (
ConfirmationPolicyBase,
NeverConfirm,
Expand Down Expand Up @@ -165,6 +166,14 @@ class SetConfirmationPolicyRequest(BaseModel):
policy: ConfirmationPolicyBase = Field(description="The confirmation policy to set")


class SetSecurityAnalyzerRequest(BaseModel):
"Payload to set security analyzer for a conversation"

security_analyzer: SecurityAnalyzerBase | None = Field(
description="The security analyzer to set"
)


class UpdateConversationRequest(BaseModel):
"""Payload to update conversation metadata."""

Expand Down
107 changes: 69 additions & 38 deletions openhands-sdk/openhands/sdk/agent/agent.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import json

from pydantic import ValidationError
from pydantic import ValidationError, model_validator

import openhands.sdk.security.analyzer as analyzer
import openhands.sdk.security.risk as risk
from openhands.sdk.agent.base import AgentBase
from openhands.sdk.agent.utils import fix_malformed_tool_arguments
Expand Down Expand Up @@ -41,7 +42,6 @@
should_enable_observability,
)
from openhands.sdk.observability.utils import extract_action_name
from openhands.sdk.security.confirmation_policy import NeverConfirm
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from openhands.sdk.tool import (
Action,
Expand Down Expand Up @@ -72,9 +72,20 @@ class Agent(AgentBase):
>>> agent = Agent(llm=llm, tools=tools)
"""

@property
def _add_security_risk_prediction(self) -> bool:
return isinstance(self.security_analyzer, LLMSecurityAnalyzer)
@model_validator(mode="before")
@classmethod
def _add_security_prompt_as_default(cls, data):
"""Ensure llm_security_analyzer=True is always set before initialization."""
if not isinstance(data, dict):
return data

kwargs = data.get("system_prompt_kwargs") or {}
if not isinstance(kwargs, dict):
kwargs = {}

kwargs.setdefault("llm_security_analyzer", True)
data["system_prompt_kwargs"] = kwargs
return data

def init_state(
self,
Expand All @@ -85,18 +96,6 @@ def init_state(
# TODO(openhands): we should add test to test this init_state will actually
# modify state in-place

# Validate security analyzer configuration once during initialization
if self._add_security_risk_prediction and isinstance(
state.confirmation_policy, NeverConfirm
):
# If security analyzer is enabled, we always need a policy that is not
# NeverConfirm, otherwise we are just predicting risks without using them,
# and waste tokens!
logger.warning(
"LLM security analyzer is enabled but confirmation "
"policy is set to NeverConfirm"
)

llm_convertible_messages = [
event for event in state.events if isinstance(event, LLMConvertibleEvent)
]
Expand All @@ -105,10 +104,15 @@ def init_state(
event = SystemPromptEvent(
source="agent",
system_prompt=TextContent(text=self.system_message),
# Always expose a 'security_risk' parameter in tool schemas.
# This ensures the schema remains consistent, even if the
# security analyzer is disabled. Validation of this field
# happens dynamically at runtime depending on the analyzer
# configured. This allows weaker models to omit risk field
# and bypass validation requirements when analyzer is disabled.
# For detailed logic, see `_extract_security_risk` method.
tools=[
t.to_openai_tool(
add_security_risk_prediction=self._add_security_risk_prediction
)
t.to_openai_tool(add_security_risk_prediction=True)
for t in self.tools_map.values()
],
)
Expand Down Expand Up @@ -176,15 +180,15 @@ def step(
tools=list(self.tools_map.values()),
include=None,
store=False,
add_security_risk_prediction=self._add_security_risk_prediction,
add_security_risk_prediction=True,
extra_body=self.llm.litellm_extra_body,
)
else:
llm_response = self.llm.completion(
messages=_messages,
tools=list(self.tools_map.values()),
extra_body=self.llm.litellm_extra_body,
add_security_risk_prediction=self._add_security_risk_prediction,
add_security_risk_prediction=True,
)
except FunctionCallValidationError as e:
logger.warning(f"LLM generated malformed function call: {e}")
Expand Down Expand Up @@ -230,6 +234,7 @@ def step(
tool_call,
llm_response_id=llm_response.id,
on_event=on_event,
security_analyzer=state.security_analyzer,
thought=thought_content
if i == 0
else [], # Only first gets thought
Expand Down Expand Up @@ -300,10 +305,10 @@ def _requires_user_confirmation(

# If a security analyzer is registered, use it to grab the risks of the actions
# involved. If not, we'll set the risks to UNKNOWN.
if self.security_analyzer is not None:
if state.security_analyzer is not None:
risks = [
risk
for _, risk in self.security_analyzer.analyze_pending_actions(
for _, risk in state.security_analyzer.analyze_pending_actions(
action_events
)
]
Expand All @@ -319,11 +324,44 @@ def _requires_user_confirmation(

return False

def _extract_security_risk(
self,
arguments: dict,
tool_name: str,
read_only_tool: bool,
security_analyzer: analyzer.SecurityAnalyzerBase | None = None,
) -> risk.SecurityRisk:
requires_sr = isinstance(security_analyzer, LLMSecurityAnalyzer)
raw = arguments.pop("security_risk", None)

# Default risk value for action event
# Tool is marked as read-only so security risk can be ignored
if read_only_tool:
return risk.SecurityRisk.UNKNOWN

# Raises exception if failed to pass risk field when expected
# Exception will be sent back to agent as error event
# Strong models like GPT-5 can correct itself by retrying
if requires_sr and raw is None:
raise ValueError(
f"Failed to provide security_risk field in tool '{tool_name}'"
)

# When using weaker models without security analyzer
# safely ignore missing security risk fields
if not requires_sr and raw is None:
return risk.SecurityRisk.UNKNOWN

# Raises exception if invalid risk enum passed by LLM
security_risk = risk.SecurityRisk(raw)
return security_risk

def _get_action_event(
self,
tool_call: MessageToolCall,
llm_response_id: str,
on_event: ConversationCallbackType,
security_analyzer: analyzer.SecurityAnalyzerBase | None = None,
thought: list[TextContent] | None = None,
reasoning_content: str | None = None,
thinking_blocks: list[ThinkingBlock | RedactedThinkingBlock] | None = None,
Expand Down Expand Up @@ -369,25 +407,18 @@ def _get_action_event(

# Fix malformed arguments (e.g., JSON strings for list/dict fields)
arguments = fix_malformed_tool_arguments(arguments, tool.action_type)

# if the tool has a security_risk field (when security analyzer is set),
# pop it out as it's not part of the tool's action schema
if (
_predicted_risk := arguments.pop("security_risk", None)
) is not None and self.security_analyzer is not None:
try:
security_risk = risk.SecurityRisk(_predicted_risk)
except ValueError:
logger.warning(
f"Invalid security_risk value from LLM: {_predicted_risk}"
)

security_risk = self._extract_security_risk(
arguments,
tool.name,
tool.annotations.readOnlyHint if tool.annotations else False,
security_analyzer,
)
assert "security_risk" not in arguments, (
"Unexpected 'security_risk' key found in tool arguments"
)

action: Action = tool.action_from_arguments(arguments)
except (json.JSONDecodeError, ValidationError) as e:
except (json.JSONDecodeError, ValidationError, ValueError) as e:
err = (
f"Error validating args {tool_call.arguments} for tool "
f"'{tool.name}': {e}"
Expand Down
49 changes: 38 additions & 11 deletions openhands-sdk/openhands/sdk/agent/base.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import os
import re
import sys
import warnings
from abc import ABC, abstractmethod
from collections.abc import Generator, Iterable
from typing import TYPE_CHECKING, Any

from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator

import openhands.sdk.security.analyzer as analyzer
from openhands.sdk.context.agent_context import AgentContext
from openhands.sdk.context.condenser import CondenserBase, LLMSummarizingCondenser
from openhands.sdk.context.prompts.prompt import render_template
from openhands.sdk.llm import LLM
from openhands.sdk.logger import get_logger
from openhands.sdk.mcp import create_mcp_tools
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from openhands.sdk.security import analyzer
from openhands.sdk.tool import BUILT_IN_TOOLS, Tool, ToolDefinition, resolve_tool
from openhands.sdk.utils.models import DiscriminatedUnionMixin
from openhands.sdk.utils.pydantic_diff import pretty_pydantic_diff
Expand All @@ -27,6 +27,13 @@
logger = get_logger(__name__)


AGENT_SECURITY_ANALYZER_DEPRECATION_WARNING = (
"Agent.security_analyzer is deprecated and will be removed "
"in a future release.\n\n use `conversation = Conversation();"
"conversation.set_security_analyzer(...)` instead."
)


class AgentBase(DiscriminatedUnionMixin, ABC):
"""Abstract base class for OpenHands agents.

Expand Down Expand Up @@ -122,11 +129,13 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
description="Optional kwargs to pass to the system prompt Jinja2 template.",
examples=[{"cli_mode": True}],
)

security_analyzer: analyzer.SecurityAnalyzerBase | None = Field(
default=None,
description="Optional security analyzer to evaluate action risks.",
examples=[{"kind": "LLMSecurityAnalyzer"}],
)

condenser: CondenserBase | None = Field(
default=None,
description="Optional condenser to use for condensing conversation history.",
Expand All @@ -147,6 +156,22 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
# Runtime materialized tools; private and non-serializable
_tools: dict[str, ToolDefinition] = PrivateAttr(default_factory=dict)

@model_validator(mode="before")
@classmethod
def _coerce_inputs(cls, data):
if not isinstance(data, dict):
return data
d = dict(data)

if "security_analyzer" in d and d["security_analyzer"]:
warnings.warn(
AGENT_SECURITY_ANALYZER_DEPRECATION_WARNING,
DeprecationWarning,
stacklevel=3,
)
Comment on lines +166 to +171
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@OpenHands is there any good python library that allow us to manage deprecation warning in a more systematic way?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm on it! xingyaoww can track my progress at all-hands.dev

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

I answered your question about Python libraries for managing deprecation warnings systematically. Here are the recommendations:

Top 3 Recommended Libraries:

  1. deprecation (Best for your use case)

    • Lightweight and purpose-built for deprecation management
    • Supports version tracking (deprecated_in, removed_in, current_version)
    • Integrates seamlessly with Python's warnings module
    • Ideal for SDKs and libraries
    • Installation: pip install deprecation
  2. Deprecated (deprecated.py)

    • Feature-rich with excellent Sphinx documentation integration
    • Simple decorator syntax with version and reason tracking
    • Very popular (4M+ downloads/month)
    • Installation: pip install Deprecated
  3. debtcollector (From OpenStack)

    • Comprehensive solution for tracking both deprecations and technical debt
    • Advanced features like move notifications and removal tracking
    • Battle-tested in large projects
    • Installation: pip install debtcollector

Why deprecation is recommended for your codebase:

Looking at your current usage (multiple warnings.warn calls in agent/base.py, llm/llm.py, and conversation/conversation_stats.py), the deprecation library would provide:

  • Version tracking for deprecation timelines
  • Minimal overhead and dependencies
  • Easy migration from your current warnings usage
  • Clear, structured messaging
  • SDK-friendly implementation

All three libraries provide decorators that make deprecation management more systematic and maintainable than the built-in warnings module alone.

No code changes were made as this was an informational question.

View full conversation


return d

@property
def prompt_dir(self) -> str:
"""Returns the directory where this class's module file is located."""
Expand All @@ -164,13 +189,7 @@ def name(self) -> str:
@property
def system_message(self) -> str:
"""Compute system message on-demand to maintain statelessness."""
# Prepare template kwargs, including cli_mode if available
template_kwargs = dict(self.system_prompt_kwargs)
if self.security_analyzer:
template_kwargs["llm_security_analyzer"] = bool(
isinstance(self.security_analyzer, LLMSecurityAnalyzer)
)

system_message = render_template(
prompt_dir=self.prompt_dir,
template_name=self.system_prompt_filename,
Expand Down Expand Up @@ -198,6 +217,16 @@ def init_state(

def _initialize(self, state: "ConversationState"):
"""Create an AgentBase instance from an AgentSpec."""

# 1) Migrate deprecated analyzer → state (if present)
if self.security_analyzer and not state.security_analyzer:
state.security_analyzer = self.security_analyzer
# 2) Clear on the immutable model (allowed via object.__setattr__)
try:
object.__setattr__(self, "security_analyzer", None)
except Exception:
logger.warning("Could not clear deprecated Agent.security_analyzer")

if self._tools:
logger.warning("Agent already initialized; skipping re-initialization.")
return
Expand Down Expand Up @@ -297,8 +326,6 @@ def resolve_diff_from_deserialized(self, persisted: "AgentBase") -> "AgentBase":
updates["condenser"] = new_condenser

# Allow security_analyzer to differ - use the runtime (self) version
# This allows users to add/remove security analyzers mid-conversation
# (e.g., when switching to weaker LLMs that can't handle security_risk field)
updates["security_analyzer"] = self.security_analyzer

# Create maps by tool name for easy lookup
Expand Down
Loading
Loading