11import json
22
3- from pydantic import ValidationError
3+ from pydantic import ValidationError , model_validator
44
5+ import openhands .sdk .security .analyzer as analyzer
56import openhands .sdk .security .risk as risk
67from openhands .sdk .agent .base import AgentBase
78from openhands .sdk .agent .utils import fix_malformed_tool_arguments
4142 should_enable_observability ,
4243)
4344from openhands .sdk .observability .utils import extract_action_name
44- from openhands .sdk .security .confirmation_policy import NeverConfirm
4545from openhands .sdk .security .llm_analyzer import LLMSecurityAnalyzer
4646from openhands .sdk .tool import (
4747 Action ,
@@ -72,9 +72,20 @@ class Agent(AgentBase):
7272 >>> agent = Agent(llm=llm, tools=tools)
7373 """
7474
75- @property
76- def _add_security_risk_prediction (self ) -> bool :
77- return isinstance (self .security_analyzer , LLMSecurityAnalyzer )
75+ @model_validator (mode = "before" )
76+ @classmethod
77+ def _add_security_prompt_as_default (cls , data ):
78+ """Ensure llm_security_analyzer=True is always set before initialization."""
79+ if not isinstance (data , dict ):
80+ return data
81+
82+ kwargs = data .get ("system_prompt_kwargs" ) or {}
83+ if not isinstance (kwargs , dict ):
84+ kwargs = {}
85+
86+ kwargs .setdefault ("llm_security_analyzer" , True )
87+ data ["system_prompt_kwargs" ] = kwargs
88+ return data
7889
7990 def init_state (
8091 self ,
@@ -85,18 +96,6 @@ def init_state(
8596 # TODO(openhands): we should add test to test this init_state will actually
8697 # modify state in-place
8798
88- # Validate security analyzer configuration once during initialization
89- if self ._add_security_risk_prediction and isinstance (
90- state .confirmation_policy , NeverConfirm
91- ):
92- # If security analyzer is enabled, we always need a policy that is not
93- # NeverConfirm, otherwise we are just predicting risks without using them,
94- # and waste tokens!
95- logger .warning (
96- "LLM security analyzer is enabled but confirmation "
97- "policy is set to NeverConfirm"
98- )
99-
10099 llm_convertible_messages = [
101100 event for event in state .events if isinstance (event , LLMConvertibleEvent )
102101 ]
@@ -105,10 +104,15 @@ def init_state(
105104 event = SystemPromptEvent (
106105 source = "agent" ,
107106 system_prompt = TextContent (text = self .system_message ),
107+ # Always expose a 'security_risk' parameter in tool schemas.
108+ # This ensures the schema remains consistent, even if the
109+ # security analyzer is disabled. Validation of this field
110+ # happens dynamically at runtime depending on the analyzer
111+ # configured. This allows weaker models to omit risk field
112+ # and bypass validation requirements when analyzer is disabled.
113+ # For detailed logic, see `_extract_security_risk` method.
108114 tools = [
109- t .to_openai_tool (
110- add_security_risk_prediction = self ._add_security_risk_prediction
111- )
115+ t .to_openai_tool (add_security_risk_prediction = True )
112116 for t in self .tools_map .values ()
113117 ],
114118 )
@@ -176,15 +180,15 @@ def step(
176180 tools = list (self .tools_map .values ()),
177181 include = None ,
178182 store = False ,
179- add_security_risk_prediction = self . _add_security_risk_prediction ,
183+ add_security_risk_prediction = True ,
180184 extra_body = self .llm .litellm_extra_body ,
181185 )
182186 else :
183187 llm_response = self .llm .completion (
184188 messages = _messages ,
185189 tools = list (self .tools_map .values ()),
186190 extra_body = self .llm .litellm_extra_body ,
187- add_security_risk_prediction = self . _add_security_risk_prediction ,
191+ add_security_risk_prediction = True ,
188192 )
189193 except FunctionCallValidationError as e :
190194 logger .warning (f"LLM generated malformed function call: { e } " )
@@ -230,6 +234,7 @@ def step(
230234 tool_call ,
231235 llm_response_id = llm_response .id ,
232236 on_event = on_event ,
237+ security_analyzer = state .security_analyzer ,
233238 thought = thought_content
234239 if i == 0
235240 else [], # Only first gets thought
@@ -300,10 +305,10 @@ def _requires_user_confirmation(
300305
301306 # If a security analyzer is registered, use it to grab the risks of the actions
302307 # involved. If not, we'll set the risks to UNKNOWN.
303- if self .security_analyzer is not None :
308+ if state .security_analyzer is not None :
304309 risks = [
305310 risk
306- for _ , risk in self .security_analyzer .analyze_pending_actions (
311+ for _ , risk in state .security_analyzer .analyze_pending_actions (
307312 action_events
308313 )
309314 ]
@@ -319,11 +324,44 @@ def _requires_user_confirmation(
319324
320325 return False
321326
327+ def _extract_security_risk (
328+ self ,
329+ arguments : dict ,
330+ tool_name : str ,
331+ read_only_tool : bool ,
332+ security_analyzer : analyzer .SecurityAnalyzerBase | None = None ,
333+ ) -> risk .SecurityRisk :
334+ requires_sr = isinstance (security_analyzer , LLMSecurityAnalyzer )
335+ raw = arguments .pop ("security_risk" , None )
336+
337+ # Default risk value for action event
338+ # Tool is marked as read-only so security risk can be ignored
339+ if read_only_tool :
340+ return risk .SecurityRisk .UNKNOWN
341+
342+ # Raises exception if failed to pass risk field when expected
343+ # Exception will be sent back to agent as error event
344+ # Strong models like GPT-5 can correct itself by retrying
345+ if requires_sr and raw is None :
346+ raise ValueError (
347+ f"Failed to provide security_risk field in tool '{ tool_name } '"
348+ )
349+
350+ # When using weaker models without security analyzer
351+ # safely ignore missing security risk fields
352+ if not requires_sr and raw is None :
353+ return risk .SecurityRisk .UNKNOWN
354+
355+ # Raises exception if invalid risk enum passed by LLM
356+ security_risk = risk .SecurityRisk (raw )
357+ return security_risk
358+
322359 def _get_action_event (
323360 self ,
324361 tool_call : MessageToolCall ,
325362 llm_response_id : str ,
326363 on_event : ConversationCallbackType ,
364+ security_analyzer : analyzer .SecurityAnalyzerBase | None = None ,
327365 thought : list [TextContent ] | None = None ,
328366 reasoning_content : str | None = None ,
329367 thinking_blocks : list [ThinkingBlock | RedactedThinkingBlock ] | None = None ,
@@ -369,25 +407,18 @@ def _get_action_event(
369407
370408 # Fix malformed arguments (e.g., JSON strings for list/dict fields)
371409 arguments = fix_malformed_tool_arguments (arguments , tool .action_type )
372-
373- # if the tool has a security_risk field (when security analyzer is set),
374- # pop it out as it's not part of the tool's action schema
375- if (
376- _predicted_risk := arguments .pop ("security_risk" , None )
377- ) is not None and self .security_analyzer is not None :
378- try :
379- security_risk = risk .SecurityRisk (_predicted_risk )
380- except ValueError :
381- logger .warning (
382- f"Invalid security_risk value from LLM: { _predicted_risk } "
383- )
384-
410+ security_risk = self ._extract_security_risk (
411+ arguments ,
412+ tool .name ,
413+ tool .annotations .readOnlyHint if tool .annotations else False ,
414+ security_analyzer ,
415+ )
385416 assert "security_risk" not in arguments , (
386417 "Unexpected 'security_risk' key found in tool arguments"
387418 )
388419
389420 action : Action = tool .action_from_arguments (arguments )
390- except (json .JSONDecodeError , ValidationError ) as e :
421+ except (json .JSONDecodeError , ValidationError , ValueError ) as e :
391422 err = (
392423 f"Error validating args { tool_call .arguments } for tool "
393424 f"'{ tool .name } ': { e } "
0 commit comments