Skip to content

fix(skills): mask Skill.mcp_tools credentials at rest#3774

Merged
simonrosenberg merged 3 commits into
mainfrom
mask-skill-mcp-tools-secret
Jun 17, 2026
Merged

fix(skills): mask Skill.mcp_tools credentials at rest#3774
simonrosenberg merged 3 commits into
mainfrom
mask-skill-mcp-tools-secret

Conversation

@simonrosenberg

@simonrosenberg simonrosenberg commented Jun 17, 2026

Copy link
Copy Markdown
Member

HUMAN:

mask Skill.mcp_tools credentials at rest


AGENT:

Why

Skill.mcp_tools is an unmodeled dict with no masking serializer, so any MCP server credential a skill carries in env/headers is dumped in plaintext wherever a Skill is serialized — even under expose_secrets=False. This breaks the secret-free-at-rest invariant and is a real, pre-existing latent leak on two at-rest disk paths today (independent of AgentProfiles):

  1. Conversation statebase_state.json (SDK) / meta.json (agent-server), via agent.agent_context.skills[].
  2. PersistedSettings → settings file, via <variant>.agent_context.skills[].

OpenHandsAgentSettings.mcp_config already masks via serialize_mcp_config, but agent_context.skills[].mcp_tools bypassed it.

This is the root-cause fix for the gap surfaced in the #3757 review (which masked it only at the OpenHandsAgentProfile boundary). Masking on Skill itself covers every serialization site — AgentContext, conversation state, settings persistence, profiles, and any future container — and lets the per-boundary serializer on branch agent-profile-model be retired.

Summary

  • Add a @field_serializer("mcp_tools") on Skill that routes the value through the same serialize_mcp_config used for settings mcp_config (single masking source of truth): redacted by default, plaintext under expose_secrets, encrypted under a cipher.
  • Import serialize_mcp_config function-locally to avoid the settings.model → agent_context → skills module-load cycle (the same lazy-import pattern already used elsewhere in skill.py).
  • Masking is serialization-only — the live self.mcp_tools attribute keeps real values, so runtime MCP usage (which reads agent.mcp_config, a separate field) is unaffected.

Issue Number

Follow-up to #3757 review.

How to Test

Ran in the worktree (uv run):

$ uv run pytest tests/sdk/skills/test_skill_serialization.py -q
12 passed

$ uv run pytest tests/sdk/skills/ tests/sdk/conversation/test_mcp_secrets_serialization_leak.py tests/sdk/agent/test_agent_serialization.py -q
255 passed, 2 skipped

Cross-cutting propagation check (proves the root fix reaches the AgentContext path the boundary fix did not):

skill = Skill(name="s", content="c", mcp_tools={"mcpServers":{"svc":{"url":"https://x",
    "headers":{"Authorization":"Bearer ghp_LEAKCHECK_999"}, "env":{"K":"ghp_LEAKCHECK_999"}}}})
ctx = AgentContext(skills=[skill])
json.dumps(ctx.model_dump(mode="json"))                                  # secret absent  -> redacted
json.dumps(ctx.model_dump(mode="json", context={"expose_secrets":"plaintext"}))  # secret present -> recoverable

Output: default dump → secret absent; expose_secrets → secret present.

ruff check, ruff format --check, and pyright on the changed files: clean (0 errors).

New regression tests in tests/sdk/skills/test_skill_serialization.py: redacted-by-default, exposed-under-expose_secrets, encrypted-under-cipher, real-values-accessible-in-memory, and None round-trip.

Type

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

Notes

  • The masked mcp_tools re-serializes through MCPConfig.model_validate(...).model_dump(exclude_defaults=True) (same normalization as mcp_config); structure round-trips with <redacted> placeholders.
  • Once this lands, the OpenHandsAgentProfile._serialize_secret_free boundary mask on branch agent-profile-model ([AgentProfile][sdk] AgentProfile kind-discriminated union #3757) becomes redundant and can be removed.

Agent Server images for this PR

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:5adcad3-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:5adcad3-golang-amd64
ghcr.io/openhands/agent-server:5adcad31a23ad54cf7e9df9d4af838ea95ba59de-golang-amd64
ghcr.io/openhands/agent-server:mask-skill-mcp-tools-secret-golang-amd64
ghcr.io/openhands/agent-server:5adcad3-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:5adcad3-golang-arm64
ghcr.io/openhands/agent-server:5adcad31a23ad54cf7e9df9d4af838ea95ba59de-golang-arm64
ghcr.io/openhands/agent-server:mask-skill-mcp-tools-secret-golang-arm64
ghcr.io/openhands/agent-server:5adcad3-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:5adcad3-java-amd64
ghcr.io/openhands/agent-server:5adcad31a23ad54cf7e9df9d4af838ea95ba59de-java-amd64
ghcr.io/openhands/agent-server:mask-skill-mcp-tools-secret-java-amd64
ghcr.io/openhands/agent-server:5adcad3-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:5adcad3-java-arm64
ghcr.io/openhands/agent-server:5adcad31a23ad54cf7e9df9d4af838ea95ba59de-java-arm64
ghcr.io/openhands/agent-server:mask-skill-mcp-tools-secret-java-arm64
ghcr.io/openhands/agent-server:5adcad3-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:5adcad3-python-amd64
ghcr.io/openhands/agent-server:5adcad31a23ad54cf7e9df9d4af838ea95ba59de-python-amd64
ghcr.io/openhands/agent-server:mask-skill-mcp-tools-secret-python-amd64
ghcr.io/openhands/agent-server:5adcad3-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:5adcad3-python-arm64
ghcr.io/openhands/agent-server:5adcad31a23ad54cf7e9df9d4af838ea95ba59de-python-arm64
ghcr.io/openhands/agent-server:mask-skill-mcp-tools-secret-python-arm64
ghcr.io/openhands/agent-server:5adcad3-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:5adcad3-golang
ghcr.io/openhands/agent-server:5adcad31a23ad54cf7e9df9d4af838ea95ba59de-golang
ghcr.io/openhands/agent-server:mask-skill-mcp-tools-secret-golang
ghcr.io/openhands/agent-server:5adcad3-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:5adcad3-java
ghcr.io/openhands/agent-server:5adcad31a23ad54cf7e9df9d4af838ea95ba59de-java
ghcr.io/openhands/agent-server:mask-skill-mcp-tools-secret-java
ghcr.io/openhands/agent-server:5adcad3-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:5adcad3-python
ghcr.io/openhands/agent-server:5adcad31a23ad54cf7e9df9d4af838ea95ba59de-python
ghcr.io/openhands/agent-server:mask-skill-mcp-tools-secret-python
ghcr.io/openhands/agent-server:5adcad3-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 5adcad3-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., 5adcad3-python-amd64) are also available if needed

Skill.mcp_tools is an unmodeled dict with no masking serializer, so any MCP
server credential a skill carries in env/headers dumped in plaintext wherever a
Skill is serialized — including two at-rest disk paths today: conversation
state (base_state.json / agent-server meta.json) and PersistedSettings, both via
agent_context.skills[]. OpenHandsAgentSettings.mcp_config already masks via
serialize_mcp_config, but skills bypassed it.

Add a @field_serializer on Skill.mcp_tools that routes through the same
serialize_mcp_config (single masking source of truth): redacted by default,
plaintext under expose_secrets, encrypted under a cipher. serialize_mcp_config
is imported function-locally to avoid the settings.model -> agent_context ->
skills import cycle.

Fixing at the Skill level covers every serialization site (AgentContext,
conversation state, settings persistence, profiles, future containers) rather
than one boundary at a time. Supersedes the OpenHandsAgentProfile-boundary mask
on branch agent-profile-model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/skills
   skill.py4722894%108–109, 275–276, 491, 612–615, 802–803, 875–876, 935–936, 1034–1035, 1121, 1149, 1172, 1179–1180, 1230–1231, 1237–1238, 1244–1245
TOTAL31213854972% 

@simonrosenberg simonrosenberg marked this pull request as ready for review June 17, 2026 14:06
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

all-hands-bot commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation.

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

Code Review: PR #3774 — mask Skill.mcp_tools credentials at rest

🟢 Good taste — Elegant, minimal security fix with no issues found.


Summary

This PR addresses a security gap where Skill.mcp_tools would serialize MCP server credentials (API keys, bearer tokens) in plaintext during serialization. The fix routes mcp_tools through the same serialize_mcp_config used by settings.mcp_config, ensuring consistent secret masking behavior across the codebase.


Taste Assessment

The implementation demonstrates good engineering judgment:

  1. Pattern reuse: Leverages the existing serialize_mcp_config function rather than duplicating masking logic — this ensures parity with mcp_config behavior and reduces maintenance burden.

  2. Lazy import: The from openhands.sdk.settings.model import serialize_mcp_config inside the serializer avoids the settings.model -> agent_context -> skills import cycle. This is the right solution for this constraint.

  3. Comprehensive test coverage: Five new tests cover all scenarios:

    • Default redaction (secret absent from serialized output)
    • Opt-in plaintext exposure (expose_secrets: plaintext)
    • Encryption with cipher (expose_secrets: encrypted)
    • In-memory values remain accessible (runtime usage unaffected)
    • None roundtrips unchanged
  4. Minimal surface area: Only 27 lines of production code added, with focused purpose.


No Issues Found

No critical issues, improvement opportunities, or testing gaps identified.


Risk and Safety Evaluation

  • Overall PR ⚠️ Risk Assessment: 🟢 LOW

This change adds security protection rather than removing it. The in-memory mcp_tools attribute retains real values, ensuring runtime MCP functionality is unaffected. The change only affects serialization output, and the tests confirm that deserialization from serialized data still works correctly.


Verdict

Worth merging: Clean, well-tested security fix that follows existing patterns.


Key Insight

The lazy import pattern here is worth documenting as a precedent — it's the idiomatic solution when a serializer needs access to another module that would create a circular dependency.


This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation.

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

✅ QA Report: PASS

Skill.mcp_tools credential serialization was exercised before/after; the PR masks default at-rest dumps while preserving opt-in plaintext/encrypted serialization and live in-memory access.

Does this PR achieve its stated goal?

Yes. The stated goal is to mask Skill.mcp_tools credentials at rest. On origin/main, a real SDK script created a Skill with MCP env/headers credentials and Skill.model_dump, Skill.model_dump_json, and AgentContext.model_dump all reported contains_secret=True; on the PR commit, the same script reported contains_secret=False with <redacted> placeholders for default Skill/AgentContext dumps. Opt-in plaintext still exposed the value, encrypted serialization produced a non-redacted encrypted value, and skill.mcp_tools retained the live value for runtime use.

Phase Result
Environment Setup make build completed successfully and installed the uv-managed workspace.
CI Status ⏳ At QA time: 20 checks successful, 2 skipped, 10 in progress; no CI was rerun.
Functional Verification ✅ Before/after SDK serialization behavior matched the PR goal.
Functional Verification

Test 1: Skill and AgentContext MCP credentials are masked at rest

Step 1 — Reproduce / establish baseline without the fix:

Ran:

git checkout --quiet origin/main
uv run python /tmp/qa_skill_mcp_serialization.py

Observed excerpt:

in_memory_secret_preserved=True
skill.default: contains_secret=True; length=558
skill.default.env.API_KEY= <fake QA token printed in plaintext>
skill.default.header.Authorization= Bearer <fake QA token printed in plaintext>
skill.model_dump_json.contains_secret=True
agent_context.default: contains_secret=True; length=835
skill.expose_plaintext: contains_secret=True; length=558
skill.expose_encrypted: contains_secret=True; length=558
clean.mcp_tools_is_none= True

This confirms the baseline bug: default Skill serialization, JSON serialization, AgentContext serialization, and encrypted-mode serialization all still carried the MCP credential in plaintext.

Step 2 — Apply the PR's changes:

Checked out the PR commit:

git checkout --quiet 5adcad31a23ad54cf7e9df9d4af838ea95ba59de

Step 3 — Re-run with the fix in place:

Ran the same SDK script:

uv run python /tmp/qa_skill_mcp_serialization.py

Observed excerpt:

in_memory_secret_preserved=True
skill.default: contains_secret=False; length=481
skill.default: redacted_placeholders=2
skill.default.env.API_KEY= <redacted>
skill.default.header.Authorization= <redacted>
skill.model_dump_json.contains_secret=False
agent_context.default: contains_secret=False; length=758
agent_context.default: redacted_placeholders=2
skill.expose_plaintext: contains_secret=True; length=558
skill.expose_encrypted: contains_secret=False; length=765
skill.expose_encrypted.env.API_KEY= gAAAAABq...
encrypted_secret_differs_from_redacted= True
clean.mcp_tools_is_none= True

This shows the PR fixes the leak for default at-rest serialization paths while preserving the documented escape hatches: plaintext exposure still works only when requested, encrypted exposure no longer stores plaintext, in-memory MCP values remain available to runtime code, and a Skill without mcp_tools still round-trips as None.

Issues Found

None.

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

@simonrosenberg simonrosenberg enabled auto-merge (squash) June 17, 2026 14:30
@simonrosenberg simonrosenberg merged commit 5792f57 into main Jun 17, 2026
29 of 32 checks passed
@simonrosenberg simonrosenberg deleted the mask-skill-mcp-tools-secret branch June 17, 2026 14:55
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.

2 participants