Skip to content

fix(sdk): mask model output in the durable MessageEvent - #4783

Merged
VascoSch92 merged 2 commits into
mainfrom
vasco/security-4678-mask-agent-message-output
Aug 31, 2026
Merged

VascoSch92 merged 2 commits into
mainfrom
vasco/security-4678-mask-agent-message-output

Conversation

@VascoSch92

@VascoSch92 VascoSch92 commented Aug 31, 2026 •

Copy link
Copy Markdown
Member

HUMAN:

Fix leakage of secrets.


AGENT:

Why

SecretRegistry.mask_secrets_in_output had, outside acp_agent.py, exactly two non-test call sites — openhands-tools/openhands/tools/terminal/impl.py:371 (bash output) and openhands-sdk/openhands/sdk/mcp/tool.py:187 (MCP tool output). Both are tool output. Nothing masked the model's own response text on the standard Agent path.

That copy is the durable one. A secret the model echoes back was persisted verbatim into the MessageEvent that is stored, replayed to the LLM on the next turn, rendered, exported, and POSTed to webhooks.

This is the off-ACP half of the problem. On ACP the situation was already the better one: _handle_update masks each chunk and _finalize_successful_turn re-masks the joined text at the persistence boundary, so the durable ACP record was already safe (covered today by TestACPStepMasksPersistedTurn::test_finish_action_masks_secret_split_across_chunks). Off ACP there was no masking at either end.

Summary

  • Mask registered secret values in the agent's own MessageEvent at _emit_message_event (openhands-sdk/openhands/sdk/agent/response_dispatch.py) — the single point where both _handle_content_response and _handle_no_content_response build that event, shared by the sync (step) and async (astep) paths.
  • Mask reasoning_content alongside content: it is the only text a reasoning-only response carries, so masking content alone would be a no-op for exactly one of the two callers.
  • Masking is unconditional: it reads the registry's tracked values, which are not the same thing as its secret_sources list (see Notes).

Issue Number

Fixes #4678

How to Test

New regression tests in tests/sdk/agent/test_response_dispatch.py, driven through the existing _run_single_step harness (a real agent.step() with a canned LLMResponse, not a direct call to the masking helper):

uv run pytest tests/sdk/agent/test_response_dispatch.py -q
# 26 passed

To confirm they actually catch the bug, revert only the source file and re-run — all three masking tests fail against the previous behaviour:

git stash push openhands-sdk/openhands/sdk/agent/response_dispatch.py
uv run pytest tests/sdk/agent/test_response_dispatch.py -q -k mask
FAILED test_content_response_masks_registered_secret
  - AssertionError: assert 'supersecret' not in 'the value i...ersecret now'
FAILED test_content_response_masks_secret_joined_from_stream_chunks
  - AssertionError: assert 'the value is supersecret now' == 'the value is...t-...
FAILED test_reasoning_only_response_masks_registered_secret
  - AssertionError: assert 'the value is supersecret now' == 'the value is...t-...
3 failed, 22 deselected

Wider suites, to show nothing else moved:

uv run pytest tests/sdk tests/cross -q -p no:randomly
# 2 failed, 6381 passed, 10 skipped, 10 xfailed

The two failures are tests/cross/test_remote_conversation_live_server.py::test_openai_chat_completions_gateway_over_real_server and ::test_settings_and_secrets_api_with_live_server. Both are pre-existing on origin/main — verified by stashing this branch's changes and re-running the same file on a clean tree, which produces the identical two failures.

pre-commit run --files <both changed files> passes (ruff format, ruff lint, pycodestyle, pyright, import rules, tool registration).

Video/Screenshots

Not applicable — no user-facing surface. The observable behaviour is the assertion output above.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

Acceptance criteria. Both are met. "A secret split across two chunks is masked in the durable message on both the Agent and ACP paths" — the ACP half already held and is covered by the existing test named above; the Agent half is test_content_response_masks_secret_joined_from_stream_chunks. "Model response text containing a registered secret is masked on the standard Agent path" — test_content_response_masks_registered_secret.

Why no stream snapshot. The issue sketches a snapshot compiled at stream open. That is not needed for these acceptance criteria and is deliberately left out of this PR: deltas are accumulated before the Message is constructed, so the durable copy is built from the joined text and the join-boundary mask catches a secret that straddles two chunks. Masking the deltas themselves belongs with the streaming work in #4671.

Self-review catch (fixed in 37341bd). The first push guarded the mask with if not registry.secret_sources: return message. That was wrong. Masking reads _exported_values, a separate field from secret_sources, and EventService.activate_credential_binding pops a name out of secret_sources via model_copy while the resolved value stays tracked for masking. In that window the guard skipped masking a live credential that mask_secrets_in_output would still have masked — a leak in exactly the credential-binding flow. Reproduced directly against the registry:

before: sources=['TOKEN'] exported=['TOKEN']  mask -> "the value is <secret-hidden> now"
after : sources=[]        exported=['TOKEN']  mask -> "the value is <secret-hidden> now"
                                              guard would skip masking: True

The guard is gone; masking is unconditional, which is also cheaper to reason about (an empty _exported_values makes it a no-op). Regression test: test_content_response_masks_value_dropped_from_secret_sources, verified to fail with the guard restored.

Deliberately not masked. thinking_blocks and responses_reasoning_item are signed provider payloads; rewriting their text invalidates the signature replayed on the next request.

Known gap, not in scope here. ActionEvent.thought on the tool-call path (agent.py:1318) is still unmasked — _handle_tool_calls does not route through _emit_message_event. Happy to fold it in if you would rather it landed together; I kept this PR to the one chokepoint the acceptance criteria name.

Lock cost. agent.step() runs under the state lock, and mask_secrets_in_output resolves uncached sources, which may do blocking I/O. Exposure is unchanged in practice: it is one call per agent step, it already has failed-lookup backoff, values are normally resolved and cached well before the model replies, and with no secrets registered it degrades to an empty loop. terminal/impl.py already calls it from inside a step.

Not verified locally. .github/scripts/check_sdk_api_breakage.py could not run here (griffe fails to fetch the baseline from PyPI in this environment). The change adds no public API — one private static method, no changed signatures.


🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:37341bd-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-37341bd-python \
  ghcr.io/openhands/agent-server:37341bd-python

All tags pushed for this build

ghcr.io/openhands/agent-server:37341bd-golang-amd64
ghcr.io/openhands/agent-server:37341bdf1003a42128577ccf8ae8e0087c3b9a3c-golang-amd64
ghcr.io/openhands/agent-server:vasco-security-4678-mask-agent-message-output-golang-amd64
ghcr.io/openhands/agent-server:37341bd-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:37341bd-golang-arm64
ghcr.io/openhands/agent-server:37341bdf1003a42128577ccf8ae8e0087c3b9a3c-golang-arm64
ghcr.io/openhands/agent-server:vasco-security-4678-mask-agent-message-output-golang-arm64
ghcr.io/openhands/agent-server:37341bd-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:37341bd-java-amd64
ghcr.io/openhands/agent-server:37341bdf1003a42128577ccf8ae8e0087c3b9a3c-java-amd64
ghcr.io/openhands/agent-server:vasco-security-4678-mask-agent-message-output-java-amd64
ghcr.io/openhands/agent-server:37341bd-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:37341bd-java-arm64
ghcr.io/openhands/agent-server:37341bdf1003a42128577ccf8ae8e0087c3b9a3c-java-arm64
ghcr.io/openhands/agent-server:vasco-security-4678-mask-agent-message-output-java-arm64
ghcr.io/openhands/agent-server:37341bd-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:37341bd-python-amd64
ghcr.io/openhands/agent-server:37341bdf1003a42128577ccf8ae8e0087c3b9a3c-python-amd64
ghcr.io/openhands/agent-server:vasco-security-4678-mask-agent-message-output-python-amd64
ghcr.io/openhands/agent-server:37341bd-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:37341bd-python-arm64
ghcr.io/openhands/agent-server:37341bdf1003a42128577ccf8ae8e0087c3b9a3c-python-arm64
ghcr.io/openhands/agent-server:vasco-security-4678-mask-agent-message-output-python-arm64
ghcr.io/openhands/agent-server:37341bd-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:37341bd-golang
ghcr.io/openhands/agent-server:37341bdf1003a42128577ccf8ae8e0087c3b9a3c-golang
ghcr.io/openhands/agent-server:vasco-security-4678-mask-agent-message-output-golang
ghcr.io/openhands/agent-server:37341bd-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:37341bd-java
ghcr.io/openhands/agent-server:37341bdf1003a42128577ccf8ae8e0087c3b9a3c-java
ghcr.io/openhands/agent-server:vasco-security-4678-mask-agent-message-output-java
ghcr.io/openhands/agent-server:37341bd-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:37341bd-python
ghcr.io/openhands/agent-server:37341bdf1003a42128577ccf8ae8e0087c3b9a3c-python
ghcr.io/openhands/agent-server:vasco-security-4678-mask-agent-message-output-python
ghcr.io/openhands/agent-server:37341bd-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 37341bd-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 37341bd-python-amd64) are also available if needed

Only tool output was masked on the standard Agent path: bash output in the
terminal executor and MCP tool output. The model's own response text was
never masked, so a secret the model echoed back was persisted verbatim into
the MessageEvent — the copy that is stored, replayed to the LLM, rendered,
exported and POSTed to webhooks.

Mask at _emit_message_event, the single point where both
_handle_content_response and _handle_no_content_response build that event,
shared by the sync and async paths. reasoning_content is masked as well
since it is the only text a reasoning-only response carries. Signed
provider payloads (thinking_blocks, responses_reasoning_item) are left
untouched so their signatures stay valid.

An empty registry short-circuits, so the non-secret path copies nothing.

Fixes #4678
@github-actions

Copy link
Copy Markdown
Contributor

Endpoint audit

⚠️ 18 actionable Agent Server contract divergence(s) · report-only

Contract: pinned release artifact

Category Count
Actionable client-only calls 0
Actionable server-only operations 18
Documented non-divergences 13
Agent Server contract operations 129
Audited handwritten client endpoints 119

Actionable client-only calls (0)

none

Actionable server-only operations (18)

  • DELETE /api/canvas-extensions/installed/{}
  • DELETE /api/llm/provider-connections/{}
  • GET /api/canvas-extensions/installed
  • GET /api/canvas-extensions/installed/{}
  • GET /api/canvas-extensions/installed/{}/bundle
  • GET /api/conversations/{}/events
  • GET /api/file/archive
  • GET /api/git/commits
  • GET /api/git/commits/{}/changes
  • GET /api/init
  • GET /api/llm/provider-connections
  • PATCH /api/canvas-extensions/installed/{}
  • PATCH /api/llm/provider-connections/{}
  • POST /api/canvas-extensions/install
  • POST /api/conversations/{}/load_plugin
  • POST /api/file/create_directory
  • POST /api/init
  • POST /api/llm/provider-connections
Documented non-divergences (13)

Client calls intentionally absent from the filtered contract (11)

  • GET /
  • GET /alive
  • GET /health
  • GET /ready
  • GET /server_info

Reason: Operational Agent Server endpoints intentionally excluded from the filtered public release artifact.
Owner: OpenHands runtime maintainers

  • DELETE /api/meta-profiles/{}
  • GET /api/meta-profiles
  • GET /api/meta-profiles/{}
  • POST /api/meta-profiles/{}
  • POST /api/meta-profiles/{}/activate

Reason: Client-ahead API stacked on the pending Agent Server meta-profiles implementation.
Owner: OpenHands SDK maintainers
Tracking: #3744

  • POST /api/profiles/{}/validate

Reason: Client-ahead API stacked on the pending Agent Server pre-flight LLM validation endpoint.
Owner: OpenHands TypeScript client maintainers
Tracking: #4422

Server operations covered by an exposed browser URL (2)

  • GET /api/conversations/{}/workspace
  • GET /api/conversations/{}/workspace/{}

Reason: RemoteWorkspace.startWorkspaceSession exposes these authenticated URLs for browser iframe and file requests; they are not HttpClient method calls.
Owner: OpenHands TypeScript client maintainers

@github-actions

github-actions Bot commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result: ✅ PASSED

Action log

@github-actions

github-actions Bot commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result: ✅ PASSED

Action log

@github-actions

github-actions Bot commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/agent
   response_dispatch.py82791%153, 203, 227, 231, 288–290
TOTAL410881150972% 

Review feedback: trim the docstring to the one non-obvious constraint and
drop the section banner in the tests.

Also removes an empty-registry short-circuit that was wrong. Masking reads
_exported_values, which is a separate field from secret_sources:
EventService.activate_credential_binding pops a name from secret_sources
via model_copy while the resolved value stays tracked, so the guard skipped
masking a live credential that mask_secrets_in_output would still have
masked. Calling it unconditionally is both correct and cheap — an empty
_exported_values makes it a no-op.

Covered by test_content_response_masks_value_dropped_from_secret_sources.
@VascoSch92
VascoSch92 marked this pull request as ready for review August 31, 2026 10:26
@all-hands-bot

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

@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.

Well. If we mask in Observations, they should never get here… well, unless the user did a trick.

Well, I don’t feel strongly about it, I do think too much code always leads to bugs, and repeating it all over… well, actually, it’s kind of good, right? Repeating it a lot means the next agent will be reminded of it every time it gets it into context. 🤔 Let’s do it…

@VascoSch92

Copy link
Copy Markdown
Member Author

Well. If we mask in Observations, they should never get here… well, unless the user did a trick.

Well, I don’t feel strongly about it, I do think too much code always leads to bugs, and repeating it all over… well, actually, it’s kind of good, right? Repeating it a lot means the next agent will be reminded of it every time it gets it into context. 🤔 Let’s do it…

my point is that If i give a token to the LLM I don't want that my token is saved into the trajectories

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.

[Security] Model output is never masked on the standard Agent path

3 participants