Skip to content

Harden the dormant MCP secret-file helper to use exec stdin - #277

Merged
jameswnl merged 4 commits into
lightspeed-core:mainfrom
jameswnl:fix-mcp-secret-delivery
Nov 1, 2026
Merged

jameswnl merged 4 commits into
lightspeed-core:mainfrom
jameswnl:fix-mcp-secret-delivery

Conversation

@jameswnl

@jameswnl jameswnl commented Nov 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

OpenShellSpawner._inject_mcp_secrets wrote each MCP secret file with an exec command that embedded the base64-encoded value in the command string. The OpenShell gateway joins exec env and argv into one command string and logs a preview of it, so secret material in argv or env can reach gateway logs. Stdin is forwarded separately.

This PR hardens that helper. It does not fix an active production leak, and it does not enable secret delivery. Neither production caller (workflow/executor/temporal/activities.py, workflow/core/step_runner.py) passes MCP secret values to spawn() today, so _inject_mcp_secrets currently logs a warning and skips every secret. The helper is dormant; this makes it safer if it is wired up later.

Changes

  • New _write_secret_file runs a fixed command, sh -c 'umask 077; cat > "$1"' sh <path>, with the secret on exec stdin. The path is a separate argument.
  • A secret larger than the exec request cap (1 MiB minus 64 KiB headroom) raises before anything is written.
  • The secret write and the preceding mkdir check the exec exit code and raise on failure. The error message contains no secret content, and spawn()'s existing cleanup deletes the sandbox.
  • MCP secret keys are kept out of spec.environment and out of the server process's exec env, the same way provider credentials already are. The server env is now filtered after merging the spawner's extra_env, so extra_env can't reintroduce those keys.

Testing

  • Tests were written first and failed on the base or the previous commit (9 in total), covering: no secret value or base64 form in any exec command or env; the value delivered on stdin; oversize rejection; nonzero write/mkdir exits; sandbox cleanup on failure; spec.environment; extra_env re-introduction.
  • Unit suite: 2197 passed, 14 skipped. Example and DEPLOYMENT.md YAML tests: 50 passed, 5 skipped. black and ruff clean.
  • Not run: integration and e2e (need Temporal, Podman or Kind). Nothing was run against a live gateway, so stdin delivery and the write permission on /var/secrets/mcp in a real sandbox are unverified.

Not in scope

  • Plain MCP headers values from catalog entries are serialized into LIGHTSPEED_MCP_SERVERS, which reaches spec.environment and the server's exec env. Moving them out needs a matching change in the sandbox image.
  • umask 077 only affects newly created files; the helper does not handle an existing file or symlink at the target path, and error messages include raw remote stderr. These need addressing before secret-bearing MCP delivery is enabled.
  • _do_write_file still embeds base64 content in the command; its only caller writes conversation messages, not credentials.

🤖 Generated with Claude Code

jameswnl and others added 4 commits October 31, 2026 21:48
OpenShellSpawner wrote MCP secret header files by embedding the
base64-encoded value in the exec command. The gateway joins exec env
and command into one string and logs a prefix of it, so secret
material should not travel there.

Write MCP secret files with a fixed command (umask 077; cat > "$1")
and pass the bytes on exec stdin, with the path as a positional
argument. Reject secrets too large to fit in a single exec request
with a clear error, which goes through spawn()'s existing sandbox
cleanup. Also keep MCP secret values out of the env passed to the
sandbox server's exec, matching how provider credentials are filtered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
exec_stream() does not raise on a nonzero exit, so a failed mkdir or
secret-file write previously let spawn() continue without the file.
Collect the exec result for both and raise RuntimeError on a nonzero
exit, following _materialize_allowed_skills(); spawn()'s existing
cleanup then deletes the sandbox. Error messages carry the path and
stderr only.

Also filter MCP secret names out of spec.environment, the same way
provider credentials are filtered, so a caller-supplied secret value
reaches the sandbox only as its mounted file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sandbox server's exec env merged the spawner's extra_env after the
caller env had been filtered, so an extra_env entry whose key matched
a provider credential or MCP secret key was passed through. Merge
first, then drop credential and MCP secret keys from the final env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Reviewed merge-result head 3ffb2a1 against base b0104d0. The target-relative diff contains the previously reviewed dormant-helper hardening only; the integration with the exec-output fixes introduces no blocking finding.

The helper sends credential file contents through exec stdin rather than logged command/environment fields, checks write/mkdir exit status, and filters identified secret keys after the server environment merge. Inspected source and regression tests, the installed SDK stdin interface, and the referenced gateway command assembly/logging code. This was read-only review; I did not independently rerun local tests.

Confirmed exact-head build, lint, unit-tests and e2e checks succeeded. The integration workflow is not triggered for these paths: its PR filter covers src/cloud_agents/workflow/** and tests/integration/**, while this diff changes the spawner and its unit tests only. That is not an integration-test pass.

Approval is limited to hardening this existing dormant helper. It does not activate secret-bearing MCP, establish end-to-end credential delivery, or prove gateway/image/Landlock behavior. Current producers do not populate the secret-value input. Existing-file/symlink safety, agent-readable credential files, diagnostic redaction and plain-header exposure remain outside this change; no broader confidentiality or deployment guarantee is implied.

@jameswnl
jameswnl merged commit a432a65 into lightspeed-core:main Nov 1, 2026
5 checks passed
jameswnl added a commit to jameswnl/lightspeed-cloud-agents that referenced this pull request Nov 1, 2026
…#277 MCP secret delivery)

Conflicts were confined to import blocks and one test pair, all resolved
by keeping both sides:
- openshell_spawner.py, step_runner.py, activities.py: keep upstream's
  AGENT_EVENT_LOG_PATH import from spawner.base alongside this branch's
  spawner.lifetime imports.
- tests/unit/workflow/test_step_runner.py: keep both new tests,
  test_run_step_passes_per_step_lifetime and
  test_run_step_sets_shared_agent_event_log.

lightspeed-core#277's post-create work in _do_spawn (the _inject_mcp_secrets call and the
server env filtered after merging extra_env) merged cleanly into
_create_and_start, so MCP secret writes now run inside the spawn budget
that starts at the expires-at stamp, and a failed or stalled write goes
through the bounded BaseException-safe cleanup that deletes the exact
created sandbox. The mcp_secret_keys filter on spec.environment stays
before CreateSandbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jameswnl added a commit to jameswnl/lightspeed-cloud-agents that referenced this pull request Nov 1, 2026
Since the merge of lightspeed-core#277, MCP secret files are written within
_create_and_start, under the spawn budget. Regressions using the real
OpenShellSpawner against a fake gateway:
- a failing secret write deletes exactly the created sandbox and restores
  the active count;
- a hanging secret write is cut off by the spawn budget, raising the
  deadline error, and the sandbox is deleted;
- the MCP secret key stays out of spec.environment while other env
  passes through (read from either the real protobuf map or the
  session-wide openshell stub that test_openshell_spawner.py may install).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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