Skip to content

feat(settings): encrypt MCP env / headers with cipher at rest#3193

Merged
VascoSch92 merged 2 commits into
mainfrom
feat/encrypt-mcp-headers-env
May 11, 2026
Merged

feat(settings): encrypt MCP env / headers with cipher at rest#3193
VascoSch92 merged 2 commits into
mainfrom
feat/encrypt-mcp-headers-env

Conversation

@rbren
Copy link
Copy Markdown
Contributor

@rbren rbren commented May 10, 2026

Stacks on top of #PARENT (fix/mcp-settings-redacted-on-disk).

Per-value encrypts every string in MCP server env / headers when persisting OpenHandsAgentSettings — the same way other secret fields are encrypted via the cipher context. Other parts of mcp_config (server names, urls, commands, args) remain plaintext.

Until now the on-disk persistence path passed only {"cipher": cipher} in the serialization context, and the mcp_config field serializer simply trusted that signal and wrote env / headers verbatim — leaving user MCP credentials in plaintext on disk despite a cipher being configured. This change brings them under the same at-rest protection as SecretStr fields persisted via serialize_secret.

Behavior

_serialize_mcp_config now branches on context:

  • expose_secrets=plaintext / True: raw values (backend trusted path).
  • expose_secrets=encrypted or cipher present: Fernet-encrypt every env / headers value. Raises MissingCipherError when encrypted-mode is requested without a cipher, mirroring serialize_secret.
  • default: redact via sanitize_dict (REST default unchanged).

A new _decrypt_mcp_secret_values field validator decrypts values on load when a cipher is in context. Values that don't start with the Fernet "gAAAAA" prefix are treated as legacy plaintext and passed through unchanged, so settings written by the previous build (which stored env / headers unencrypted) migrate cleanly on the next save.

Tests

  • tests/sdk/test_settings.py:
    • Replaced the previous "passes through with cipher" test with a stronger one asserting Fernet ciphertext on the dump (gAAAA…), no plaintext / <redacted>, and that round-tripping through model_validate(..., context={"cipher": cipher}) recovers the originals.
    • Added a legacy-plaintext-on-disk migration test (values without the Fernet prefix pass through on load).
    • Added an expose_secrets="encrypted" without cipher test asserting MissingCipherError in the cause chain (Pydantic wraps it in PydanticSerializationError).
    • Added an expose_secrets="plaintext" test (overrides cipher even when both are present).
  • tests/agent_server/test_settings_router.py: updated to assert on-disk settings.json contains Fernet ciphertext (not plaintext) and that GET /api/settings with X-Expose-Secrets: plaintext round-trips to the original values.

Local verification:

  • pytest tests/sdk/test_settings.py tests/agent_server/test_settings_router.py → 87 passed.
  • pytest tests/sdk/conversation/test_mcp_secrets_serialization_leak.py tests/sdk/conversation/local/test_state_serialization.py tests/workspace/test_cloud_workspace_sdk_settings.py → 57 passed.
  • ruff check and pyright clean on touched files.

This PR was opened by an AI agent (OpenHands) on behalf of the repo owner.


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:5142d1e-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:5142d1e-golang-amd64
ghcr.io/openhands/agent-server:5142d1e8cb87ac06302f7ec195a7a69420600c5b-golang-amd64
ghcr.io/openhands/agent-server:feat-encrypt-mcp-headers-env-golang-amd64
ghcr.io/openhands/agent-server:5142d1e-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:5142d1e-golang-arm64
ghcr.io/openhands/agent-server:5142d1e8cb87ac06302f7ec195a7a69420600c5b-golang-arm64
ghcr.io/openhands/agent-server:feat-encrypt-mcp-headers-env-golang-arm64
ghcr.io/openhands/agent-server:5142d1e-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:5142d1e-java-amd64
ghcr.io/openhands/agent-server:5142d1e8cb87ac06302f7ec195a7a69420600c5b-java-amd64
ghcr.io/openhands/agent-server:feat-encrypt-mcp-headers-env-java-amd64
ghcr.io/openhands/agent-server:5142d1e-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:5142d1e-java-arm64
ghcr.io/openhands/agent-server:5142d1e8cb87ac06302f7ec195a7a69420600c5b-java-arm64
ghcr.io/openhands/agent-server:feat-encrypt-mcp-headers-env-java-arm64
ghcr.io/openhands/agent-server:5142d1e-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:5142d1e-python-amd64
ghcr.io/openhands/agent-server:5142d1e8cb87ac06302f7ec195a7a69420600c5b-python-amd64
ghcr.io/openhands/agent-server:feat-encrypt-mcp-headers-env-python-amd64
ghcr.io/openhands/agent-server:5142d1e-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:5142d1e-python-arm64
ghcr.io/openhands/agent-server:5142d1e8cb87ac06302f7ec195a7a69420600c5b-python-arm64
ghcr.io/openhands/agent-server:feat-encrypt-mcp-headers-env-python-arm64
ghcr.io/openhands/agent-server:5142d1e-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:5142d1e-golang
ghcr.io/openhands/agent-server:5142d1e8cb87ac06302f7ec195a7a69420600c5b-golang
ghcr.io/openhands/agent-server:feat-encrypt-mcp-headers-env-golang
ghcr.io/openhands/agent-server:5142d1e-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:5142d1e-java
ghcr.io/openhands/agent-server:5142d1e8cb87ac06302f7ec195a7a69420600c5b-java
ghcr.io/openhands/agent-server:feat-encrypt-mcp-headers-env-java
ghcr.io/openhands/agent-server:5142d1e-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:5142d1e-python
ghcr.io/openhands/agent-server:5142d1e8cb87ac06302f7ec195a7a69420600c5b-python
ghcr.io/openhands/agent-server:feat-encrypt-mcp-headers-env-python
ghcr.io/openhands/agent-server:5142d1e-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

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

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 10, 2026

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   _secrets_exposure.py61395%24, 81, 122
openhands-sdk/openhands/sdk/settings
   model.py5625290%83, 106, 111, 332, 334, 346, 348, 403, 413–416, 419, 432, 436, 442, 452, 458, 463, 639, 652, 663, 673, 677, 679, 681, 683, 685, 687, 689, 961, 963, 1235, 1303, 1419, 1455–1458, 1484, 1608, 1653, 1685, 1695, 1697, 1702, 1720, 1733, 1735, 1737, 1739, 1746
openhands-sdk/openhands/sdk/utils
   cipher.py42295%70–71
TOTAL26903620576% 

Copy link
Copy Markdown
Collaborator

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

Choose a reason for hiding this comment

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

Solid security improvement that properly encrypts MCP credentials at rest. Clean implementation with comprehensive test coverage and backward compatibility.

Copy link
Copy Markdown
Collaborator

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

Choose a reason for hiding this comment

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

✅ QA Report: PASS

MCP credentials are properly encrypted at rest using Fernet encryption, with successful round-trip decryption and legacy plaintext migration.

Does this PR achieve its stated goal?

Yes. The PR successfully encrypts MCP env and headers values when persisting OpenHandsAgentSettings to disk. Before this change, MCP credentials were stored in plaintext despite a cipher being configured. After this change, credentials are encrypted with Fernet (ciphertext starting with gAAAA), decrypted on load, and legacy plaintext values migrate cleanly on the next save.

Phase Result
Environment Setup ✅ Dependencies installed, project builds successfully
CI Status ⚠️ Most checks passing (sdk-tests, agent-server-tests, tools-tests, workspace-tests, windows-tests all green); pre-commit has minor formatting issue
Functional Verification ✅ MCP encryption, decryption, and migration all work correctly
Functional Verification

Test 1: MCP credentials encrypted on disk

Step 1 — Create settings with MCP secrets:
Created OpenHandsAgentSettings with MCP config containing:

  • GitHub token: ghp-super-secret-token-abc123
  • Authorization header: Bearer tok-very-secret-xyz789
  • API key: sk-another-secret-def456

Step 2 — Serialize with cipher context (simulating FileSettingsStore.save):

cipher = Cipher(secret_key="agent-server-test-key")
dumped = settings.model_dump(mode="json", context={"cipher": cipher})

Step 3 — Verify on-disk format:
Wrote serialized settings to settings.json and analyzed file content:

File size: 3069 bytes
Fernet token prefix 'gAAAA' count: 3
Plaintext secrets found: {'ghp-super-secret-token': False, 'tok-very-secret': False, 'sk-another-secret': False, '<redacted>': False}

MCP config in on-disk file:
- GITHUB_TOKEN: gAAAAABqANfconIneHDnY8sZwAEIGQ2hYR7hhZoYt-5kYQT830lbRAZzILYY...
- Authorization: gAAAAABqANfcuzaxlBMaMrfoYEs4RIx6i1HEs7TiChRvb2_4EkdRj7qoxKB4...
- X-API-Key: gAAAAABqANfcMrzJ02ba0KX01cyEUo7_zLfctaYkQutMiFef_-RGRiIstMIs...

All values encrypted: True (start with 'gAAAA')
Non-secret structure (command, URL) remains plaintext: True

This confirms secrets are encrypted on disk, not stored as plaintext or <redacted>.

Step 4 — Load from disk and verify decryption:

loaded_settings = OpenHandsAgentSettings.model_validate(
    parsed, context={"cipher": cipher}
)

Decrypted values:

GITHUB_TOKEN: ghp-super-secret-token-abc123 ✓
Authorization: Bearer tok-very-secret-xyz789 ✓
X-API-Key: sk-another-secret-def456 ✓

Round-trip integrity: All secrets match original

This confirms the decryption works correctly and recovers the original plaintext values.


Test 2: Legacy plaintext migration

Step 1 — Simulate legacy settings file (pre-encryption build):
Created payload with plaintext MCP env:

{
  "mcp_config": {
    "mcpServers": {
      "github": {
        "command": "uvx",
        "env": {"GITHUB_TOKEN": "ghp-legacy-plaintext-token"}
      }
    }
  }
}

Step 2 — Load with cipher context:

restored = OpenHandsAgentSettings.model_validate(
    legacy_payload, context={"cipher": cipher}
)

Result: Token loaded successfully as ghp-legacy-plaintext-token (plaintext preserved).

Step 3 — Re-serialize (next save will encrypt):

re_dumped = restored.model_dump(mode="json", context={"cipher": cipher})

Result: Token now encrypted (starts with gAAAA).

This confirms legacy plaintext values are not dropped — they pass through on load and get encrypted on the next save, enabling clean migration.


Test 3: REST API exposure modes

Tested all serialization contexts:

  1. Default (no context): Value is <redacted>
  2. expose_secrets="plaintext": Raw value my-secret-value
  3. Cipher context: Value encrypted (starts with gAAAA) ✓
  4. expose_secrets="plaintext" + cipher: Plaintext wins (not encrypted) ✓
  5. expose_secrets="encrypted" without cipher: Raises PydanticSerializationError wrapping MissingCipherError

All exposure modes work as documented.

Issues Found

None. The PR delivers on its stated goal with correct encryption, decryption, and migration behavior.


CI Note: Pre-commit has a minor formatting issue (multi-line function signature formatting). Functional tests (sdk-tests, agent-server-tests) are all passing.

Base automatically changed from fix/mcp-settings-redacted-on-disk to main May 10, 2026 20:50
@VascoSch92
Copy link
Copy Markdown
Contributor

@OpenHands fix the conflicts and the pre-commit

@openhands-ai
Copy link
Copy Markdown

openhands-ai Bot commented May 11, 2026

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

Per-value encrypt every string in MCP server env and headers
when persisting OpenHandsAgentSettings — the same way other
secret fields are encrypted via the cipher context. Other parts of
mcp_config (server names, urls, commands, args) remain plaintext.

Until now the on-disk persistence path passed only
``{"cipher": cipher}`` in the serialization context, and the
``mcp_config`` field serializer simply trusted that signal and
wrote env / headers verbatim — leaving user MCP credentials in
plaintext on disk despite a cipher being configured. This change
brings them under the same at-rest protection as
``SecretStr`` fields persisted via ``serialize_secret``.

- _serialize_mcp_config now:
  - ``expose_secrets=plaintext|True``: raw values (backend trusted)
  - ``expose_secrets=encrypted`` or ``cipher`` present:
    Fernet-encrypt every env / headers value (raises
    ``MissingCipherError`` when encrypted-mode is requested
    without a cipher, mirroring ``serialize_secret``)
  - default: redact via ``sanitize_dict``
- New ``_decrypt_mcp_secret_values`` field validator decrypts
  values on load when a cipher is in context. Values that don't
  start with the Fernet "gAAAAA" prefix pass through as plaintext
  so settings written by the previous build (which stored env /
  headers unencrypted) migrate cleanly on the next save.

Co-authored-by: openhands <openhands@all-hands.dev>
@VascoSch92 VascoSch92 force-pushed the feat/encrypt-mcp-headers-env branch from 8d4ecd6 to 76677e7 Compare May 11, 2026 14:52
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 11, 2026

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 11, 2026

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@openhands-ai
Copy link
Copy Markdown

openhands-ai Bot commented May 11, 2026

OpenHands encountered an error: Request timeout after 30 seconds to https://cdcpiqdxfigjxigx.prod-runtime.all-hands.dev/api/conversations/f13ca76a-5702-4685-9154-a04538e62d18/ask_agent

See the conversation for more information.

  - Add Cipher.try_decrypt_str so callers don't reach into _get_fernet().
  - Promote FERNET_TOKEN_PREFIX to a public Final[str] in cipher.py and
  drop the duplicate from _secrets_exposure.py.
  - Extract resolve_expose_mode in pydantic_secrets.py and route both
  serialize_secret and _serialize_mcp_config through it so the
  plaintext/encrypted/redact decision lives in one place.
  - Replace cipher.encrypt(...) or "" with cast(str, ...); the empty-string
  fallback would have silently destroyed a secret if ever reached.
  - Deep-copy in _walk_mcp_secret_values so the validator no longer mutates
  the user-provided payload passed to model_validate.
  - Rename the over-long encrypt test and drop its # noqa: E501.
@VascoSch92 VascoSch92 requested a review from all-hands-bot May 11, 2026 15:11
Copy link
Copy Markdown
Collaborator

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

Choose a reason for hiding this comment

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

🟢 Good taste

Clean security fix that properly encrypts MCP credentials at rest. The implementation follows established patterns (serialize_secret, cipher usage), maintains backward compatibility for legacy plaintext values, and has comprehensive test coverage.


[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟢 LOW

Security improvement that fixes plaintext credential storage. Well-tested with backward-compatible migration path for existing installations. Follows existing encryption patterns and includes comprehensive tests covering encryption round-trip, legacy migration, error cases, and integration scenarios.

VERDICT:
Worth merging: Solid security improvement with clean implementation

KEY INSIGHT:
Per-value Fernet encryption brings MCP credentials under the same at-rest protection as other SecretStr fields while transparently migrating legacy plaintext on first load.

Copy link
Copy Markdown
Collaborator

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

Choose a reason for hiding this comment

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

✅ QA Report: PASS

MCP env/headers are successfully encrypted at rest with Fernet cipher, decrypt correctly on load, and migrate legacy plaintext values seamlessly.

Does this PR achieve its stated goal?

Yes. The PR's goal was to encrypt MCP server env and headers values when persisting settings at rest using the configured cipher (the same way other SecretStr fields are encrypted). Functional verification confirms:

  1. Encryption works: When settings with MCP config are persisted with a cipher, env and headers values are encrypted with Fernet (start with gAAAAA prefix), not stored as plaintext or <redacted>.
  2. Decryption works: Loading encrypted settings with the same cipher correctly decrypts the values back to their original plaintext.
  3. Migration works: Legacy settings files with plaintext env/headers (written by pre-encryption builds) load without data loss and are re-encrypted on the next save.
  4. Non-secret structure remains readable: Server names, commands, args, and URLs remain plaintext for debuggability.
  5. Agent-server integration works: The REST API (PATCH /api/settings → on-disk encryption → GET /api/settings with X-Expose-Secrets: plaintext → decryption) round-trips correctly.
Phase Result
Environment Setup uv sync --dev completed successfully
CI Status ⚠️ Pre-commit ✅, Python API checks ✅, 1 unrelated docker build failing, core tests pending
Functional Verification ✅ All encryption/decryption behavior verified end-to-end
Functional Verification

Test 1: SDK-Level Encryption Round-Trip

Step 1 — Create settings with MCP secrets and persist with cipher:

Ran custom QA script that creates OpenHandsAgentSettings with MCP config containing:

"env": {"GITHUB_TOKEN": "ghp-test-secret-token"},
"headers": {"Authorization": "Bearer secret-api-key-12345"}

Then called settings.model_dump(mode="json", context={"cipher": cipher}) to persist.

Step 2 — Verify on-disk encryption:

Inspected the generated JSON file:

On-disk content verification:
  - GitHub GITHUB_TOKEN starts with 'gAAAAA': True
  - API Authorization starts with 'gAAAAA': True
  - Plaintext 'ghp-test-secret-token' in file: False
  - Plaintext 'secret-api-key-12345' in file: False
  - Redaction marker '<redacted>' in file: False

  Encrypted GitHub token (first 50 chars): gAAAAABqAfJHVFaMH9fDm8z8S8MYLzRhJ8HTMDwswrXRAZ5DTO...
  Encrypted API auth (first 50 chars): gAAAAABqAfJHAj50dzgPpyzAXGUKHuR-rj6bPEXQzuEbQuILLC...

This confirms:

  • Secrets are encrypted with Fernet cipher (not plaintext)
  • No redaction occurred (values are encrypted, not replaced with <redacted>)
  • Non-secret fields (command, args, URL) remain plaintext for readability

Step 3 — Load and decrypt:

Loaded the same JSON with OpenHandsAgentSettings.model_validate(on_disk_json, context={"cipher": cipher}):

Decrypted values:
  - GitHub GITHUB_TOKEN: ghp-test-secret-token
  - API Authorization: Bearer secret-api-key-12345

This confirms round-trip encryption/decryption works correctly.


Test 2: Legacy Plaintext Migration

Step 1 — Simulate legacy plaintext on disk:

Created a settings payload with plaintext env (as old builds would write):

"env": {"API_KEY": "legacy-plaintext-key"}

Step 2 — Load with cipher:

Loaded with OpenHandsAgentSettings.model_validate(legacy_payload, context={"cipher": cipher}):

Decrypted API_KEY: legacy-plaintext-key

This confirms the validator passes through plaintext values (no data loss).

Step 3 — Re-save to migrate:

Called model_dump(mode="json", context={"cipher": cipher}) on the loaded settings:

Migrated API_KEY (first 50 chars): gAAAAABqAfJHAv-MFVGRVXCgHWZEj5UPLXYphhjyQ18X6SlLt_...
Starts with Fernet prefix: True

This confirms legacy plaintext values are encrypted on the next save.


Test 3: Agent-Server REST API Integration

Ran existing pytest test test_patch_settings_encrypts_mcp_env_and_headers_on_disk:

tests/agent_server/test_settings_router.py::test_patch_settings_encrypts_mcp_env_and_headers_on_disk PASSED

This test:

  1. PATCHes /api/settings with MCP env/headers containing secrets
  2. Reads the on-disk settings.json and asserts values start with gAAAA (Fernet ciphertext)
  3. Asserts plaintext secrets do NOT appear in the file
  4. GETs /api/settings with X-Expose-Secrets: plaintext and asserts decrypted values match originals

Result: Passed. The full REST API persistence flow works correctly.


Test 4: SDK Unit Tests

Ran all new/modified SDK tests:

uv run pytest tests/sdk/test_settings.py::test_mcp_config_encrypts_env_and_headers_with_cipher \
  tests/sdk/test_settings.py::test_openhands_agent_settings_mcp_config_decrypt_legacy_plaintext_on_disk \
  tests/sdk/test_settings.py::test_openhands_agent_settings_mcp_config_expose_encrypted_requires_cipher \
  tests/sdk/test_settings.py::test_openhands_agent_settings_mcp_config_expose_plaintext_passes_through -xvs

Result: All 4 tests passed.

These tests cover:

  • Encryption produces Fernet ciphertext and decrypts correctly
  • Legacy plaintext values migrate without data loss
  • expose_secrets="encrypted" without cipher raises MissingCipherError
  • expose_secrets="plaintext" overrides cipher and returns raw values

Issues Found

None. All verification passed cleanly.


Conclusion: This PR successfully implements MCP env/headers encryption at rest. Secrets are protected with Fernet cipher when persisted to disk, decrypt correctly on load, and legacy plaintext values migrate seamlessly. The implementation matches the design described in the PR and handles all edge cases (legacy migration, explicit plaintext exposure, missing cipher errors). No regressions detected.

@VascoSch92 VascoSch92 merged commit b3afbe8 into main May 11, 2026
39 of 50 checks passed
@VascoSch92 VascoSch92 deleted the feat/encrypt-mcp-headers-env branch May 11, 2026 16:20
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.

4 participants