Skip to content

Commit fd80128

Browse files
juanmicheliniopenhands-agentallhands-botenyst
authored
Fix: Security analyzer ignores LLM security_risk when no analyzer is configured (OpenHands#2130)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com> Co-authored-by: enyst <engel.nyst@gmail.com>
1 parent 217b218 commit fd80128

7 files changed

Lines changed: 102 additions & 17 deletions

File tree

‎openhands-agent-server/openhands/agent_server/docker/Dockerfile‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ RUN test -x /agent-server/dist/openhands-agent-server
5757
FROM ${BASE_IMAGE} AS base-image-minimal
5858
ARG USERNAME UID GID PORT
5959

60+
61+
ARG OPENHANDS_BUILD_GIT_SHA=unknown
62+
ARG OPENHANDS_BUILD_GIT_REF=unknown
63+
ENV OPENHANDS_BUILD_GIT_SHA=${OPENHANDS_BUILD_GIT_SHA}
64+
ENV OPENHANDS_BUILD_GIT_REF=${OPENHANDS_BUILD_GIT_REF}
65+
6066
# Install base packages and create user
6167
RUN set -eux; \
6268
# Install base packages (works for both Debian-based images)

‎openhands-agent-server/openhands/agent_server/docker/build.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,10 @@ def build(opts: BuildOptions) -> list[str]:
564564
opts.target,
565565
"--build-arg",
566566
f"BASE_IMAGE={opts.base_image}",
567+
"--build-arg",
568+
f"OPENHANDS_BUILD_GIT_SHA={opts.git_sha}",
569+
"--build-arg",
570+
f"OPENHANDS_BUILD_GIT_REF={opts.git_ref}",
567571
]
568572
if push:
569573
args += ["--platform", ",".join(opts.platforms), "--push"]

‎openhands-agent-server/openhands/agent_server/server_details_router.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import os
2+
import sys
13
import time
24
from importlib.metadata import version
35

46
from fastapi import APIRouter, Response
5-
from pydantic import BaseModel
7+
from pydantic import BaseModel, Field
68

79

810
server_details_router = APIRouter(prefix="", tags=["Server Details"])
@@ -11,11 +13,37 @@
1113
_initialization_complete = False
1214

1315

16+
def _package_version(dist_name: str) -> str:
17+
try:
18+
return version(dist_name)
19+
except Exception:
20+
return "unknown"
21+
22+
1423
class ServerInfo(BaseModel):
1524
uptime: float
1625
idle_time: float
1726
title: str = "OpenHands Agent Server"
18-
version: str = version("openhands-agent-server")
27+
28+
version: str = Field(
29+
default_factory=lambda: _package_version("openhands-agent-server")
30+
)
31+
sdk_version: str = Field(default_factory=lambda: _package_version("openhands-sdk"))
32+
tools_version: str = Field(
33+
default_factory=lambda: _package_version("openhands-tools")
34+
)
35+
workspace_version: str = Field(
36+
default_factory=lambda: _package_version("openhands-workspace")
37+
)
38+
39+
build_git_sha: str = Field(
40+
default_factory=lambda: os.environ.get("OPENHANDS_BUILD_GIT_SHA", "unknown")
41+
)
42+
build_git_ref: str = Field(
43+
default_factory=lambda: os.environ.get("OPENHANDS_BUILD_GIT_REF", "unknown")
44+
)
45+
python_version: str = Field(default_factory=lambda: sys.version)
46+
1947
docs: str = "/docs"
2048
redoc: str = "/redoc"
2149

‎openhands-sdk/openhands/sdk/agent/agent.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,13 @@ def _extract_security_risk(
487487
f"Failed to provide security_risk field in tool '{tool_name}'"
488488
)
489489

490-
# When using weaker models without security analyzer
490+
# When no security analyzer is configured, ignore any security_risk field
491+
# from LLM and return UNKNOWN. This ensures that security_risk is only
492+
# evaluated when a security analyzer is explicitly set.
493+
if security_analyzer is None:
494+
return risk.SecurityRisk.UNKNOWN
495+
496+
# When using non-LLM security analyzer without security risk field
491497
# safely ignore missing security risk fields
492498
if not requires_sr and raw is None:
493499
return risk.SecurityRisk.UNKNOWN

‎openhands-sdk/openhands/sdk/workspace/remote/base.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,21 @@ def _execute(self, generator: Generator[dict[str, Any], httpx.Response, Any]):
7474
except StopIteration as e:
7575
return e.value
7676

77+
def get_server_info(self) -> dict[str, Any]:
78+
"""Return server metadata from the agent-server.
79+
80+
This is useful for debugging version mismatches between the local SDK and
81+
the remote agent-server image.
82+
83+
Returns:
84+
A JSON-serializable dict returned by GET /server_info.
85+
"""
86+
response = self.client.get("/server_info")
87+
response.raise_for_status()
88+
data = response.json()
89+
assert isinstance(data, dict)
90+
return data
91+
7792
def execute_command(
7893
self,
7994
command: str,

‎tests/sdk/agent/test_extract_security_risk.py‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,24 +62,29 @@ def agent_without_analyzer(mock_llm):
6262
("agent_with_llm_analyzer", "MEDIUM", SecurityRisk.MEDIUM, False),
6363
("agent_with_llm_analyzer", "HIGH", SecurityRisk.HIGH, False),
6464
("agent_with_llm_analyzer", "UNKNOWN", SecurityRisk.UNKNOWN, False),
65-
# Case 2: analyzer is not set, security risk is passed, extracted properly
65+
# Case 2: Non-LLM analyzer set, security risk is passed, extracted properly
6666
("agent_with_non_llm_analyzer", "LOW", SecurityRisk.LOW, False),
6767
("agent_with_non_llm_analyzer", "MEDIUM", SecurityRisk.MEDIUM, False),
6868
("agent_with_non_llm_analyzer", "HIGH", SecurityRisk.HIGH, False),
6969
("agent_with_non_llm_analyzer", "UNKNOWN", SecurityRisk.UNKNOWN, False),
70-
("agent_without_analyzer", "LOW", SecurityRisk.LOW, False),
71-
("agent_without_analyzer", "MEDIUM", SecurityRisk.MEDIUM, False),
72-
("agent_without_analyzer", "HIGH", SecurityRisk.HIGH, False),
70+
# Case 3: No analyzer set, security risk is passed, should be ignored
71+
# (return UNKNOWN)
72+
("agent_without_analyzer", "LOW", SecurityRisk.UNKNOWN, False),
73+
("agent_without_analyzer", "MEDIUM", SecurityRisk.UNKNOWN, False),
74+
("agent_without_analyzer", "HIGH", SecurityRisk.UNKNOWN, False),
7375
("agent_without_analyzer", "UNKNOWN", SecurityRisk.UNKNOWN, False),
74-
# Case 3: LLM analyzer set, security risk not passed, ValueError raised
76+
# Case 4: LLM analyzer set, security risk not passed, ValueError raised
7577
("agent_with_llm_analyzer", None, None, True),
76-
# Case 4: analyzer is not set, security risk is not passed, UNKNOWN returned
78+
# Case 5: analyzer is not set, security risk is not passed, UNKNOWN returned
7779
("agent_with_non_llm_analyzer", None, SecurityRisk.UNKNOWN, False),
7880
("agent_without_analyzer", None, SecurityRisk.UNKNOWN, False),
79-
# Case 5: invalid security risk value passed, ValueError raised
81+
# Case 6: invalid security risk value passed
82+
# - With LLM analyzer: ValueError raised for validation
83+
# - With non-LLM analyzer: ValueError raised for invalid enum
84+
# - Without analyzer: ignored, returns UNKNOWN (no validation attempted)
8085
("agent_with_llm_analyzer", "INVALID", None, True),
8186
("agent_with_non_llm_analyzer", "INVALID", None, True),
82-
("agent_without_analyzer", "INVALID", None, True),
87+
("agent_without_analyzer", "INVALID", SecurityRisk.UNKNOWN, False),
8388
],
8489
)
8590
def test_extract_security_risk(
@@ -122,14 +127,14 @@ def test_extract_security_risk_arguments_mutation():
122127
)
123128
)
124129

125-
# Test with security_risk present
130+
# Test with security_risk present but no analyzer (should be ignored)
126131
arguments = {"param1": "value1", "security_risk": "LOW", "param2": "value2"}
127132
original_args = arguments.copy()
128133

129134
result = agent._extract_security_risk(arguments, "test_tool", False, None)
130135

131-
# Verify result
132-
assert result == SecurityRisk.LOW
136+
# Verify result is UNKNOWN when no analyzer is set (security_risk is ignored)
137+
assert result == SecurityRisk.UNKNOWN
133138

134139
# Verify security_risk was popped
135140
assert "security_risk" not in arguments

‎tests/sdk/agent/test_security_policy_integration.py‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,24 +279,37 @@ def _tool_response(name: str, args_json: str) -> ModelResponse:
279279

280280

281281
def test_security_risk_param_ignored_when_no_analyzer():
282-
"""Security risk param is ignored when no analyzer is configured."""
282+
"""Security risk param is ignored when no analyzer is configured.
283+
284+
This test reproduces the issue from #1957 where the LLM includes
285+
security_risk in tool calls even when llm_security_analyzer=False
286+
and no security analyzer is configured.
287+
288+
Expected behavior: security_risk should be UNKNOWN when no analyzer is set.
289+
"""
290+
from openhands.sdk.security.risk import SecurityRisk
283291

284292
llm = LLM(
285293
usage_id="test-llm",
286294
model="test-model",
287295
api_key=SecretStr("test-key"),
288296
base_url="http://test",
289297
)
290-
agent = Agent(llm=llm, tools=[])
298+
# Set llm_security_analyzer=False in system_prompt_kwargs
299+
agent = Agent(
300+
llm=llm, tools=[], system_prompt_kwargs={"llm_security_analyzer": False}
301+
)
291302

292303
events = []
293304
convo = Conversation(agent=agent, callbacks=[events.append])
294305

306+
# Mock LLM response that includes security_risk=HIGH even though
307+
# llm_security_analyzer=False (the LLM might do this if it's well-trained)
295308
with patch(
296309
"openhands.sdk.llm.llm.litellm_completion",
297310
return_value=_tool_response(
298311
"think",
299-
'{"thought": "This is a test thought", "security_risk": "LOW"}',
312+
'{"thought": "This is a test thought", "security_risk": "HIGH"}',
300313
),
301314
):
302315
convo.send_message(
@@ -306,3 +319,11 @@ def test_security_risk_param_ignored_when_no_analyzer():
306319

307320
# No agent errors
308321
assert not any(isinstance(e, AgentErrorEvent) for e in events)
322+
323+
# Find the ActionEvent
324+
action_events = [e for e in events if isinstance(e, ActionEvent)]
325+
assert len(action_events) == 1
326+
327+
# Verify that the security_risk is UNKNOWN (ignored) when no analyzer is set
328+
# Even though the LLM provided "HIGH", it should be ignored
329+
assert action_events[0].security_risk == SecurityRisk.UNKNOWN

0 commit comments

Comments
 (0)