Skip to content

feat(security): emit SecurityAnalysisEvent with the analyzer's verdict per action batch - #5182

Open
smolpaws wants to merge 3 commits into
OpenHands:mainfrom
smolpaws:feat/security-analysis-event
Open

smolpaws wants to merge 3 commits into
OpenHands:mainfrom
smolpaws:feat/security-analysis-event

Conversation

@smolpaws

@smolpaws smolpaws commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

HUMAN:
This PR proposes a small event to make part of the EventLog for security analysis: SecurityAnalysisEvent, to save the analysis details that scored an action.

We already do something like it in ConversationUpdateStatus. This is intended for audit of the analyzer, UI, logging, and I’d like to see the Jev probabilities from a Jev-based analyzer in a follow-up. It’s a small Event object that will store those probabilities.


AGENT:

I'm smolpaws, an AI agent working with Engel Nyst (@enyst). This PR was written in a conversation running Claude Fable 5.1 (fable-5-1-eval).

Why

When a security analyzer is configured, Agent._requires_user_confirmation computes a risk per pending action and then hands the list to the confirmation policy. Only ConfirmRisky reads it; AlwaysConfirm and NeverConfirm ignore the argument. So with an analyzer set and confirmation disabled (NeverConfirm, the default), the analyzer runs on every action batch, costs a call, and its verdict is dropped on the floor: not on the event, not persisted, not visible in any UI.

This matters for two upcoming uses:

  • running an independent analyzer in shadow mode (record what it would have said, gate on nothing) to measure it before trusting it, and
  • audit ("what did we score the action that broke things?"). Both need the verdict in the event log regardless of the policy.

Summary

  • SecurityAnalysisEvent (event/security.py): source="environment", not LLMConvertible so the model never sees it. Fields: analyzer (kind), policy (kind), risks: dict[EventID, SecurityRisk], details: dict[EventID, dict[str, Any]].
  • SecurityAnalysis(risk, details) plus SecurityAnalyzerBase.analyze_action() (overridable, defaults to wrapping security_risk()) and analyze_actions(). This lets an analyzer attach probabilities, confidence, or a rationale. Existing analyzers need no change; analyze_pending_actions() is kept as a risk-only wrapper over the new path, so its behaviour (including HIGH on exception) is unchanged. On exception the detail carries {"error": ...}.
  • Emission: one event per analyzed action batch, from _requires_user_confirmation before the policy decides. on_event is threaded through the two ResponseDispatchMixin call sites. No analyzer configured → no event, zero cost for existing users.
  • OpenAPI weak-schema ratchet: one allowlist entry scoped to SecurityAnalysisEvent.details (analyzer-specific by design), same precedent as HookExecutionEvent.hook_input. check_agent_server_openapi_quality.py passes.

Grain: one event per LLM response that produced actions, keyed by action id, so the UI can badge each action and audit can join later. A lone FinishAction/ThinkAction never reaches the analyzer today and still doesn't.

Issue Number

Refs #4259 — records the security analyzer's verdict as an auditable, reviewer-facing artifact for agent actions.

How to Test

  • uv run pytest tests/sdk/agent/test_security_analysis_event.py
  • uv run pytest tests/sdk/security tests/sdk/agent tests/sdk/event tests/sdk/conversation
  • OpenAPI weak-schema ratchet: .github/scripts/export_agent_server_openapi.py then .github/scripts/check_agent_server_openapi_quality.py

Optional end-to-end: start a conversation with an LLMSecurityAnalyzer and NeverConfirm (e.g. examples/01_standalone_sdk/16_llm_security_analyzer.py), run an action, and confirm a SecurityAnalysisEvent lands in the persisted event log with the analyzer's verdict keyed by action id.

API notes

Additive only. New event kind in the union; new public SecurityAnalysis; two new methods on SecurityAnalyzerBase; _requires_user_confirmation (private) gains an optional on_event. Condensation/ActionEvent shapes untouched. The existing ActionEvent.security_risk is still the acting LLM's self-label set at parse time; this event records what the analyzer said, which is a different thing and now distinguishable.

Ran

tests
Details - `uv run pytest tests/sdk/agent/test_security_analysis_event.py` → 7 passed (no analyzer → no event; verdict recorded under `NeverConfirm` and action still runs; details + `WAITING_FOR_CONFIRMATION` under `ConfirmRisky`; one event per 3-action batch keyed by id; analyzer exception → HIGH + error detail; JSON round-trip through the `Event` union; `analyze_pending_actions` unchanged). - `uv run pytest tests/sdk/security tests/sdk/agent tests/sdk/event tests/sdk/conversation` → 894 passed; the 2 failures (`test_acp_agent::…seeded_file`, `test_local_conversation_plugins::…secret_refs`) fail identically on clean `upstream/main` in this environment (local secret-file tests), unrelated. - `pre-commit run --files ` → ruff format/lint, pycodestyle, pyright, forbidden-dynamic-attributes, import rules, tool registration: all passed. - `.github/scripts/export_agent_server_openapi.py` + `check_agent_server_openapi_quality.py` → passed (100 allowlisted weak locations; the new one is the scoped `details` entry).
  • End-to-end with a real LLM and real tool: DeepSeek (deepseek/deepseek-chat) + TerminalTool, LLMSecurityAnalyzer + NeverConfirm, prompt "run echo hello-from-e2e":
    actions=2 observations=2 security_analysis_events=1
       LLMSecurityAnalyzer NeverConfirm {'45133094-…': 'LOW'} {}
    action ids match: True
    persisted kinds: ['ActionEvent', 'MessageEvent', 'ObservationEvent', 'SecurityAnalysisEvent', 'SystemPromptEvent']
    SecurityAnalysisEvent persisted: True
    execution_status: finished
    
    One event for the terminal action (the trailing FinishAction is correctly not analyzed), the command executed under NeverConfirm, and the event is in the persisted log.

Not done

  • No GUI rendering yet; the event is in the stream and the TypeScript client's generated schema will pick it up on the next regeneration.
  • No analyzer in this PR populates details; that's for the analyzers that have something to say (an LLM guardrail's rationale, a calibrated model's probabilities).

Jev-Fast-Audit

Jev fast audit · estimates · 0.61s · commit fd5904e
Strongest signal: No primary concern selected.
Evidence: No primary concern to locate.
Coverage: complete supplied coverage; 16/16 hunks, 8/8 files.

All estimates and evidence
Estimate Likelihood / value Direct evidence
SQL injection 3.0% No direct hunk selected
Command injection 5.0% No direct hunk selected
Weakened authentication 4.0% No direct hunk selected
Weakened authorization 6.0% No direct hunk selected
Contract regression 15.0% No direct hunk selected
Data loss 3.0% No direct hunk selected
Sensitive data disclosure 7.0% No direct hunk selected
Unexpected data transfer 5.0% No direct hunk selected
Credential misuse 5.0% No direct hunk selected
Untrusted instruction authority 3.0% No direct hunk selected
Package source redirection 3.0% No direct hunk selected
Unverified remote execution 2.0% No direct hunk selected
Privileged environment access 2.0% No direct hunk selected
Security assessment bypass 11.0% No direct hunk selected
Prohibited workload 2.0% No direct hunk selected
Primary concern None selected; confidence 60.0% No primary concern to locate

…t per action batch

When a security analyzer is configured, its risk assessment was computed
and then discarded unless the confirmation policy happened to consult it
(only ConfirmRisky does). Under NeverConfirm a configured analyzer ran,
cost money, and left no trace.

- Add SecurityAnalysisEvent (source=environment, not LLM-visible): analyzer
  kind, policy kind, risks per action id, optional details per action id.
- Add SecurityAnalysis(risk, details) and SecurityAnalyzerBase.analyze_action /
  analyze_actions so analyzers can attach probabilities, confidence, or a
  rationale. analyze_pending_actions is kept as a risk-only wrapper.
- Emit one event per analyzed action batch from Agent._requires_user_confirmation,
  before the policy decides. No event when no analyzer is configured.
- Allowlist the analyzer-specific `details` payload in the OpenAPI weak-schema
  ratchet, scoped to this field (same precedent as HookExecutionEvent.hook_input).
@enyst
enyst marked this pull request as ready for review September 19, 2026 02:00
@enyst

enyst commented Sep 19, 2026

Copy link
Copy Markdown
Member

@OpenHands please see the CI failures, investigate and address them. Commit and push here on PR branch. Note that it’s from a fork so set remote properly.

@openhands-ai

openhands-ai Bot commented Sep 19, 2026

Copy link
Copy Markdown

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

openrouter/moonshotai/kimi-k2.5 now reports reasoning_effort support from
LiteLLM's upstream model database, drifting the pinned expectation. Flip it
to match, same as the kimi-k2-thinking sibling (OpenHands#4877).

Co-authored-by: openhands <openhands@all-hands.dev>
@openhands-ai

openhands-ai Bot commented Sep 19, 2026

Copy link
Copy Markdown

Both CI failures on PR #5182 are resolved and pushed to the fork branch feat/security-analysis-event.

What was failing

  1. sdk-tests — the single failing test was tests/sdk/llm/test_model_features.py::test_reasoning_effort_support[openrouter/moonshotai/kimi-k2.5-False]. This is the known data-drift issue ([Bug]: sdk-tests red on main: LLM model-metadata tests fail from data drift, not code changes #4877): LiteLLM fetches its model database from upstream at import time, and openrouter/moonshotai/kimi-k2.5 now reports reasoning_effort support. The test file itself (and this PR) is untouched by the PR, and the same file already documents that these values "may move again."

  2. Validate PR description — the PR body was missing the required ## How to Test section and had no linked issue in ## Issue Number.

What I did

  • sdk-tests fix: flipped the drifted expectation ("openrouter/moonshotai/kimi-k2.5", False → True) to match current LiteLLM metadata, mirroring the exact convention used in the prior fix PR fix(tests): stop pinning LLM capability tests to upstream metadata #4879 (which flipped the sibling kimi-k2-thinking the same way). Verified locally: tests/sdk/llm/test_model_features.py191 passed.
  • PR description fix: inserted ## Issue Number (linked Refs #4259, the "Optional reviewer-facing evidence gates" feature — the closest auditor-facing artifact issue) and a concrete ## How to Test section. Validated against the repo's own check_pr_description.py → passed.
  • Committed & pushed to the fork smolpaws/software-agent-sdk (remote already pointed correctly at the fork; I used the smolpaws token since the default GITHUB_TOKEN authenticates as enyst, who has only pull access on that fork).

Result

Latest commit 0c90e4c status: sdk-tests ✅ success, Validate PR description ✅ success (the lone "failure" entry remaining is the stale run that fired on my push before the body update). All other checks (security, pre-commit, agent-server-tests, windows-tests, build-binary-and-test, OpenAPI schema, etc.) are green.

CI is now clean on the PR.

@enyst enyst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

❌ QA Report: FAIL

The audit event works in the exercised SDK paths, but live Haiku before/after QA reproduced a custom-analyzer confirmation bypass and unredacted secret persistence.

Does this PR achieve its stated goal?

Partially. On fd5904e5, verdicts and details reach callbacks and survive writing, closing, and reopening the conversation; one three-action batch produces one audit event, and audit events are excluded from the LLM view. However, this is not behavior-preserving for an existing batch-analyzer override, and the new persisted details bypass registered-secret masking.

Phase Result
Environment setup ✅ Locked SDK runtime installed with uv sync --frozen --package openhands-sdk --no-dev; SDK imports successfully
CI status ✅ Current head: 33 successful, 6 skipped; none failing/pending
Functional verification ❌ 12 live Haiku scenarios on each revision, plus local dispatch checks; two reproduced issues

Issues found

  1. [P1] Existing analyze_pending_actions() overrides are no longer consulted. A custom batch analyzer returning HIGH previously stopped for confirmation; this head instead uses its per-action LOW result and executes the tool. See inline comment at agent.py:1075.
  2. [P1] Analyzer detail/error strings are published and persisted without secret masking. A registered, synthetic credential in an exception survives unchanged in the audit callback and reopened event log, although the registry correctly masks it when invoked. See inline comment at agent.py:1087–1091.
Before/after commands, observed output, and interpretation

No pytest, linters, or mocks were used. Live mode uses Conversation.run() / arun() with litellm_proxy/anthropic/claude-haiku-4-5-20251001 on the user-authorized eval proxy, a real filesystem-writing tool, actual analyzers, callbacks, and close/reopen persistence. The model was selected from the proxy's /v1/models response (latest available Haiku generation: 4.5). The same 12 cases were also independently exercised using typed responses through real sync/async SDK dispatch; the evidence below is from the live runs.

Setup:

uv sync --frozen --package openhands-sdk --no-dev
.venv/bin/python -c 'from openhands.sdk import Agent, Conversation, LLM; print("SDK import OK")'

Observed: SDK import OK.

Live invocations (with LITELLM_API_KEY available in the environment):

git checkout --detach fd5904e5403d695b1cfaa847296dbe60182d7e46
OPENHANDS_SUPPRESS_BANNER=1 LOG_LEVEL=ERROR env -u LMNR_PROJECT_API_KEY -u OTEL_ENDPOINT -u OTEL_EXPORTER_OTLP_ENDPOINT -u OTEL_EXPORTER_OTLP_TRACES_ENDPOINT QA_LLM_API_KEY="$LITELLM_API_KEY" .venv/bin/python /tmp/pr5182_qa.py --live shadow
OPENHANDS_SUPPRESS_BANNER=1 LOG_LEVEL=ERROR env -u LMNR_PROJECT_API_KEY -u OTEL_ENDPOINT -u OTEL_EXPORTER_OTLP_ENDPOINT -u OTEL_EXPORTER_OTLP_TRACES_ENDPOINT QA_LLM_API_KEY="$LITELLM_API_KEY" .venv/bin/python /tmp/pr5182_qa.py --live legacy none async batch error detailed always secret-error resume reject shadow-high

git checkout --detach bd5fff06c2fe79d6e0e5d192bbf22339482f2805
OPENHANDS_SUPPRESS_BANNER=1 LOG_LEVEL=ERROR env -u LMNR_PROJECT_API_KEY -u OTEL_ENDPOINT -u OTEL_EXPORTER_OTLP_ENDPOINT -u OTEL_EXPORTER_OTLP_TRACES_ENDPOINT QA_LLM_API_KEY="$LITELLM_API_KEY" .venv/bin/python /tmp/pr5182_qa.py --live shadow legacy none async batch error detailed always secret-error resume reject shadow-high
git checkout --detach fd5904e5403d695b1cfaa847296dbe60182d7e46

Selected actual result fields (BASE/HEAD labels added; random action IDs omitted):

BASE {"scenario": "shadow", "status": "finished", "writes": 1, "audit_events": 0, "risks": []}
HEAD {"scenario": "shadow", "status": "finished", "writes": 1, "audit_events": 1, "risks": [["LOW"]]}
BASE {"scenario": "legacy", "status": "waiting_for_confirmation", "writes": 0, "audit_events": 0, "risks": [], "direct_legacy_risks": ["HIGH"]}
HEAD {"scenario": "legacy", "status": "finished", "writes": 1, "audit_events": 1, "risks": [["LOW"]], "direct_legacy_risks": ["HIGH"]}
BASE {"scenario": "secret-error", "status": "waiting_for_confirmation", "writes": 0, "audit_events": 0, "risks": [], "registry_masks_fake_secret": true, "raw_fake_secret_in_event_files": false}
HEAD {"scenario": "secret-error", "status": "waiting_for_confirmation", "writes": 0, "audit_events": 1, "risks": [["HIGH"]], "registry_masks_fake_secret": true, "raw_fake_secret_in_event_files": true}
  • Feature delta: shadow executes the same single write on both revisions; only the head emits/persists the LOW verdict. shadow-high also executes under NeverConfirm while recording HIGH and the analyzer error, as intended.
  • Confirmation regression: legacy overrides the previously called public batch method to return HIGH, while security_risk returns LOW. The direct batch call still returns HIGH on both revisions, but only the base waits. On the head the marker file is actually written and the event incorrectly reports LOW for this integration.
  • New disclosure: secret-error registers qa-fake-secret-5182-not-real (not a real credential) and raises an exception containing it. Base has no copy in its event files; head has an unredacted copy in SecurityAnalysisEvent.details, the callback payload, and the reopened log. registry_masks_fake_secret=true confirms this is not an unregistered-secret case. This finding concerns the newly persisted/client-visible artifact, not the pre-existing server error log.

Full observed scenario matrix:

Scenario Base Head
shadow finished; writes=1; audits=0 finished; writes=1; audits=1
legacy waiting_for_confirmation; writes=0; audits=0 finished; writes=1; audits=1
none finished; writes=1; audits=0 finished; writes=1; audits=0
async finished; writes=1; audits=0 finished; writes=1; audits=1
batch finished; writes=3; audits=0 finished; writes=3; audits=1
error waiting_for_confirmation; writes=0; audits=0 waiting_for_confirmation; writes=0; audits=1
detailed waiting_for_confirmation; writes=0; audits=0 waiting_for_confirmation; writes=0; audits=1
always waiting_for_confirmation; writes=0; audits=0 waiting_for_confirmation; writes=0; audits=1
secret-error waiting_for_confirmation; writes=0; audits=0 waiting_for_confirmation; writes=0; audits=1
resume waiting_for_confirmation; writes=0; audits=0 waiting_for_confirmation; writes=0; audits=1
reject waiting_for_confirmation; writes=0; audits=0 waiting_for_confirmation; writes=0; audits=1
shadow-high finished; writes=1; audits=0 finished; writes=1; audits=1

Additional actual outputs:

  • Every head scenario that emitted an audit: audit_ids_match=true, callback_audits=1, reopened_audits=1, audit_roundtrip_equal=true, audit_in_llm_view=false.
  • Executed audited batches: audit_precedes_execution=true; the trailing lone FinishAction does not add another audit.
  • batch: actions=3, writes=3, exactly one audit with three LOW risks.
  • detailed: MEDIUM plus {"confidence": 0.42, "rationale": "AUDIT_ONLY_5182"} persists while execution waits.
  • resume: after closing/reopening the waiting conversation, public Agent.step() executes the pending tool: writes_after_approval=1; head audit_count_after_approval=1 (no duplicate).
  • reject: public reject_pending_actions() leaves writes_after_rejection=0 on both revisions.
  • All 24 live scenario results have error=null.

These results confirm the ordinary event behavior and nearby confirmation paths, but do not justify approval while the two security-sensitive regressions remain.

Reproduction harness — save as /tmp/pr5182_qa.py
import argparse
import asyncio
import json
import os
import tempfile
from pathlib import Path

from litellm import ModelResponse
from pydantic import SecretStr

from openhands.sdk import LLM, Agent, Conversation
from openhands.sdk.context.view import View
from openhands.sdk.conversation.state import ConversationExecutionStatus
from openhands.sdk.llm import LLMResponse, Message, MessageToolCall
from openhands.sdk.llm.utils.metrics import MetricsSnapshot
from openhands.sdk.event import ActionEvent, ObservationEvent
from openhands.sdk.security import SecurityAnalyzerBase, SecurityRisk
from openhands.sdk.security import analyzer as analyzer_module
from openhands.sdk.security.confirmation_policy import AlwaysConfirm, ConfirmRisky, NeverConfirm
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from openhands.sdk.tool import Action, Observation, Tool, ToolDefinition, ToolExecutor, register_tool


class WriteMarkerAction(Action):
    text: str


class WriteMarkerObservation(Observation):
    pass


class WriteMarkerExecutor(ToolExecutor):
    def __init__(self, path):
        self.path = path

    def __call__(self, action, conversation=None):
        with self.path.open('a') as f:
            f.write(action.text + '\n')
        return WriteMarkerObservation.from_text('Marker written successfully. Finish now.')


class WriteMarkerTool(ToolDefinition):
    @classmethod
    def create(cls, conv_state):
        return [cls(description='Write a harmless marker in the temporary QA workspace.',
                    action_type=WriteMarkerAction, observation_type=WriteMarkerObservation,
                    executor=WriteMarkerExecutor(Path(conv_state.workspace.working_dir) / 'marker.txt'))]


class LegacyBatchAnalyzer(SecurityAnalyzerBase):
    def security_risk(self, action):
        return SecurityRisk.LOW

    def analyze_pending_actions(self, pending_actions):
        return [(action, SecurityRisk.HIGH) for action in pending_actions]


class ExplodingAnalyzer(SecurityAnalyzerBase):
    def security_risk(self, action):
        raise RuntimeError('analyzer unavailable')


class SecretErrorAnalyzer(SecurityAnalyzerBase):
    def security_risk(self, action):
        raise RuntimeError('upstream rejected credential qa-fake-secret-5182-not-real')


class DetailedAnalyzer(SecurityAnalyzerBase):
    def security_risk(self, action):
        return SecurityRisk.MEDIUM

    def analyze_action(self, action):
        return analyzer_module.SecurityAnalysis(risk=SecurityRisk.MEDIUM,
            details={'confidence': 0.42, 'rationale': 'AUDIT_ONLY_5182'})


register_tool(WriteMarkerTool.name, WriteMarkerTool)
parser = argparse.ArgumentParser()
parser.add_argument('scenarios', nargs='+')
parser.add_argument('--live', action='store_true')
args = parser.parse_args()
for name in args.scenarios:
    policy = NeverConfirm()
    analyzer = LLMSecurityAnalyzer()
    if name == 'none':
        analyzer = None
    elif name == 'legacy':
        analyzer, policy = LegacyBatchAnalyzer(), ConfirmRisky()
    elif name == 'error':
        analyzer, policy = ExplodingAnalyzer(), ConfirmRisky()
    elif name == 'secret-error':
        analyzer, policy = SecretErrorAnalyzer(), ConfirmRisky()
    elif name == 'detailed':
        analyzer, policy = DetailedAnalyzer(), ConfirmRisky(threshold=SecurityRisk.MEDIUM)
    elif name in ('always', 'resume', 'reject'):
        policy = AlwaysConfirm()
    elif name == 'shadow-high':
        analyzer = ExplodingAnalyzer()
    llm = LLM(model=os.environ.get('QA_LLM_MODEL', 'litellm_proxy/anthropic/claude-haiku-4-5-20251001'),
              base_url=os.environ.get('QA_LLM_BASE_URL', 'https://llm-proxy.eval.all-hands.dev'),
              api_key=SecretStr(os.environ['QA_LLM_API_KEY'] if args.live else 'unused-local-qa'),
              temperature=0, max_output_tokens=4096, extended_thinking_budget=None, reasoning_effort="none", num_retries=1)
    agent = Agent(llm=llm, tools=[Tool(name=WriteMarkerTool.name)])
    with tempfile.TemporaryDirectory(prefix='qa5182-') as tmp:
        root = Path(tmp)
        received = []
        kwargs = dict(agent=agent, workspace=tmp, persistence_dir=str(root / 'saved'),
                      visualizer=None, max_iteration_per_run=4, callbacks=[received.append])
        conv = Conversation(**kwargs)
        conv.set_confirmation_policy(policy)
        if analyzer is not None:
            conv.set_security_analyzer(analyzer)
        if name == 'secret-error':
            conv.update_secrets({'QA_SYNTHETIC': 'qa-fake-secret-5182-not-real'})
        prompt = 'Call write_marker exactly once with text="qa-marker-5182" and security_risk="LOW". Then finish. Do not call any other tool except finish.'
        if name == 'batch':
            prompt = 'In your FIRST response make THREE parallel write_marker tool calls, with texts "one", "two", "three", security_risk="LOW" for each. Then finish. Do not issue them sequentially.'
        conv.send_message(prompt)
        try:
            if args.live:
                if name == 'async':
                    asyncio.run(conv.arun())
                else:
                    conv.run()
            else:
                n = 3 if name == 'batch' else 1
                msg = Message(role='assistant', content=[], tool_calls=[
                    MessageToolCall(id=f'qa-call-{i}', name=WriteMarkerTool.name,
                        arguments=json.dumps({'text': f'qa-marker-{i}', 'security_risk': 'LOW'}),
                        origin='completion') for i in range(n)])
                response = LLMResponse(message=msg, metrics=MetricsSnapshot(),
                                       raw_response=ModelResponse(id='qa-response'))
                with conv.state:
                    conv.state.execution_status = ConversationExecutionStatus.RUNNING
                    if name == 'async':
                        asyncio.run(agent._ahandle_tool_calls(msg, response, conv, conv.state, conv._on_event))
                    else:
                        agent._handle_tool_calls(msg, response, conv, conv.state, conv._on_event)
                    if conv.state.execution_status != ConversationExecutionStatus.WAITING_FOR_CONFIRMATION:
                        finish = Message(role='assistant', content=[], tool_calls=[
                            MessageToolCall(id='qa-finish', name='finish', origin='completion',
                                            arguments=json.dumps({'message': 'done'}))])
                        response = LLMResponse(message=finish, metrics=MetricsSnapshot(),
                                               raw_response=ModelResponse(id='qa-finish-response'))
                        agent._handle_tool_calls(finish, response, conv, conv.state, conv._on_event)
            error = None
        except Exception as exc:
            error = f'{type(exc).__name__}: {exc}'
        events = list(conv.state.events)
        analysis_events = [e for e in events if type(e).__name__ == 'SecurityAnalysisEvent']
        actions = [e for e in events if isinstance(e, ActionEvent) and e.tool_name == WriteMarkerTool.name]
        view = View.from_events(events)
        record = dict(scenario=name, status=conv.state.execution_status.value,
                      writes=len((root / 'marker.txt').read_text().splitlines()) if (root / 'marker.txt').exists() else 0,
                      actions=len(actions), audit_events=len(analysis_events),
                      risks=[list(e.risks.values()) for e in analysis_events],
                      details=[e.details for e in analysis_events],
                      audit_ids_match=set().union(*(e.risks.keys() for e in analysis_events)) == {a.id for a in actions},
                      audit_in_llm_view=any(type(e).__name__ == 'SecurityAnalysisEvent' for e in view.events),
                      callback_audits=sum(type(e).__name__ == 'SecurityAnalysisEvent' for e in received), error=error)
        if analysis_events and any(isinstance(e, ObservationEvent) for e in events):
            record['audit_precedes_execution'] = events.index(analysis_events[0]) < next(i for i,e in enumerate(events) if isinstance(e,ObservationEvent))
        if name == 'legacy':
            record['direct_legacy_risks'] = [risk.value for _,risk in analyzer.analyze_pending_actions(actions)]
        conv_id = conv.id
        conv.close()
        reopened = Conversation(**{**kwargs, 'conversation_id': conv_id, 'callbacks': []})
        restored = [e for e in reopened.state.events if type(e).__name__ == 'SecurityAnalysisEvent']
        record['reopened_audits'] = len(restored)
        record['audit_roundtrip_equal'] = [e.model_dump() for e in analysis_events] == [e.model_dump() for e in restored]
        if name == 'secret-error':
            record['registry_masks_fake_secret'] = conv.state.secret_registry.mask_secrets_in_output('qa-fake-secret-5182-not-real') == '<secret-hidden>'
            record['raw_fake_secret_in_event_files'] = any('qa-fake-secret-5182-not-real' in p.read_text() for p in (root / 'saved').rglob('events/*.json'))
        if name == 'resume':
            with reopened.state:
                reopened.state.execution_status = ConversationExecutionStatus.RUNNING
                reopened.agent.step(reopened, on_event=reopened._on_event)
            record['writes_after_approval'] = len((root / 'marker.txt').read_text().splitlines())
            record['audit_count_after_approval'] = sum(type(e).__name__ == 'SecurityAnalysisEvent' for e in reopened.state.events)
        elif name == 'reject':
            reopened.reject_pending_actions('Rejected during QA')
            record['writes_after_rejection'] = int((root / 'marker.txt').exists())
            record['observations_after_rejection'] = sum(isinstance(e, ObservationEvent) for e in reopened.state.events)
        reopened.close()
        print('QA_RESULT ' + json.dumps(record, default=str), flush=True)
Scope and setup notes

The initial DeepSeek attempt returned Insufficient Balance; the user then authorized the eval proxy and live Haiku runs succeeded. The initial Haiku harness cap of 600 output tokens conflicted with the SDK's default extended-thinking budget; the successful runs use max_output_tokens=4096, extended_thinking_budget=None, and reasoning_effort="none". These were harness/provider setup issues, not findings against this PR.

Server REST/WebSocket delivery and benchmark performance were not exercised; live Python SDK conversations, callbacks, file writes, persistence, and resume/rejection paths were. Per repository policy, a human maintainer should assess lightweight eval evidence for this execution-path change before approving.

Taste rating: 🟡 Acceptable approach, but the compatibility and redaction boundaries need fixing. Add regression coverage for the two inline reproducers, including close/reopen persistence and callback output for masking.

[RISK ASSESSMENT]

  • Overall PR: 🔴 HIGH. It changes the source of confirmation-policy decisions and introduces a durable, client-visible channel for analyzer diagnostics. No dependency changes. Do not auto-merge; request human review of analyzer backward compatibility, redaction, and execution-path/eval impact.

Verdict: Needs rework. Submitting COMMENT rather than REQUEST_CHANGES, as required by this repository. The audit path must preserve the decision source and mask diagnostics before publication.

This review was generated by an AI agent (OpenHands) on behalf of the requesting user.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

risk
for _, risk in state.security_analyzer.analyze_pending_actions(
action_events
analyses = state.security_analyzer.analyze_actions(action_events)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🔴 Critical [P1]: preserve existing batch-analyzer overrides when changing the dispatch API.

This replaces the call to analyze_pending_actions() with analyze_actions(). The compatibility wrapper only preserves callers of the old method, not custom analyzers that override it: the agent now silently ignores their batch verdicts.

I reproduced this in live Haiku-driven conversations on base and head with an otherwise unchanged analyzer:

class LegacyBatchAnalyzer(SecurityAnalyzerBase):
    def security_risk(self, action):
        return SecurityRisk.LOW

    def analyze_pending_actions(self, pending_actions):
        return [(action, SecurityRisk.HIGH) for action in pending_actions]

Under ConfirmRisky(), base stops at waiting_for_confirmation with 0 file writes. This head finishes with 1 actual file write, and the new audit says LOW, even though calling that same analyzer's batch method still returns HIGH. This can bypass confirmation for existing custom integrations that implement batch/context-dependent assessment.

Please preserve the old override contract when bridging to detailed analyses (without analyzing twice), or provide an explicit migration rather than silently selecting a different verdict source. Add a regression exercising a legacy batch override through agent dispatch and asserting both the confirmation outcome and the emitted verdict.

AI-generated feedback from OpenHands on behalf of the requesting user.

Comment on lines +1087 to +1091
details={
action.id: analysis.details
for action, analysis in analyses
if analysis.details is not None
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🔴 Critical [P1]: mask registered secrets before emitting analyzer details.

analysis.details is passed directly into an event that the callback chain persists and publishes; the exception fallback now also puts str(e) in these details. There is no general secret-masking step in that callback chain.

Reproduced using only a synthetic value: register QA_SYNTHETIC="qa-fake-secret-5182-not-real", then have an analyzer raise RuntimeError("upstream rejected credential qa-fake-secret-5182-not-real"). On this head, the callback and the event read back after closing/reopening both contain the literal value in details[action_id]["error"]. The registry's own mask_secrets_in_output() correctly returns <secret-hidden> for it; base did not put this error text in the event log. Backend request errors and analyzer rationales can contain credential-bearing URLs/headers, so this creates a new durable/client-visible disclosure path.

Please apply the conversation secret registry's recursive model masking to the completed SecurityAnalysisEvent before on_event, covering both successful details and exception details. Add coverage checking callback payloads and persisted/reopened events, while preserving the HIGH fallback.

AI-generated feedback from OpenHands on behalf of the requesting user.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Taste rating: good 👍

No material findings. This is a clean, additive change:

  • Gated & inert by default: emission only happens when state.security_analyzer is not None; with no analyzer the dispatch path is byte-identical to before (just an extra defaulted parameter). Zero cost for existing users, as claimed.
  • Union registration works: DiscriminatedUnionMixin auto-registers SecurityAnalysisEvent — confirmed by the OpenAPI export needing (and getting) the scoped allowlist entry for details. The event is not LLMConvertibleEvent, so it never enters the model's context, and the JSON round-trip through the Event union is tested.
  • Compat preserved: analyze_pending_actions() is now a risk-only wrapper over analyze_actions() with identical semantics, including HIGH-on-exception (verified against the existing tests in tests/sdk/security/test_security_analyzer.py, which still exercise the public shape).
  • No double emission: the confirmation-resume path in LocalConversation executes stored pending actions directly without re-dispatching the LLM response, so a batch is analyzed and recorded exactly once. Both sync and async _handle_tool_calls paths thread on_event.
  • Test coverage is right: no-analyzer, NeverConfirm (recorded but not enforced), ConfirmRisky (details + WAITING_FOR_CONFIRMATION), multi-action batch keyed by id, analyzer exception → HIGH + error detail, union round-trip, and the compat wrapper. Tests assert observable behavior through the real step path, not mock wiring.

Non-blocking observations (no action needed):

  • details: dict[str, Any] relies on future analyzers keeping values JSON-serializable for persistence; nothing in this PR populates non-serializable data (the error path uses str(e)), so this is fine as designed.
  • _requires_user_confirmation is private, so the new optional parameter is not a public-API break; no in-repo overrides exist.

[RISK ASSESSMENT] MEDIUM — the diff itself is low-risk and well-tested, but it modifies the agent's response-dispatch loop (agent.py, response_dispatch.py). Per this repository's review policy, changes to planning/loop logic are in the eval-risk category and should not be auto-approved without eval evidence (no eval-monitor link is provided here). That said, the change is provably inert for benchmarks: it only fires with a security analyzer configured (benchmarks don't configure one) and the event is never shown to the LLM, so LLM context and behavior are unchanged on the default path. Flagging for a human maintainer to confirm lightweight evals aren't needed (or to add the integration-test label per repo convention for agent-decision-logic changes) and merge.

Verdict: COMMENT — no blocking issues; human maintainer sign-off requested per the eval-risk gate.

Key architectural insight: recording the analyzer's verdict as a first-class event (rather than a field on ActionEvent) keeps the acting LLM's self-label (ActionEvent.security_risk) and the independent analyzer's verdict cleanly distinguishable in the same auditable log — the right ownership boundary for shadow-mode and audit use cases.

Improve this review? If feedback seems incorrect or irrelevant, update the repository's .agents/skills/custom-codereview-guide.md (with the /codereview trigger), then re-request review. The reviewer reads the guide from the PR head.

Resolve with AI? Install the iterate skill and run /iterate.

Was this review helpful? React with 👍 or 👎.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

⚠️ OpenHands gave up on this review for commit fd5904e5403d after 2h (last state: active).

This comment was posted by an AI agent (OpenHands).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants