fix(mcpl): scrub host env from stdio MCPL children - #175
Conversation
Stdio MCPL/MCP servers were spawned with { ...process.env, ...config.env },
so every server (including third-party ones) could read the host's
ANTHROPIC_AUTH_TOKEN, DISCORD_TOKEN, etc.
Children now get a small operating allowlist (PATH, HOME, USER, SHELL,
TERM, LANG, LC_*, TMPDIR, XDG_*, TLS cert vars, DISPLAY, ...) plus the
server's declared `env`, which still wins on conflicts. Recipes already
declare what servers need via mcpServers.<id>.env with ${VAR}
substitution. `inheritEnv: true` on a server config restores the old
full inheritance for anything that genuinely needs it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
| const env: NodeJS.ProcessEnv = {}; | ||
| for (const [key, value] of Object.entries(hostEnv)) { | ||
| if (value === undefined) continue; | ||
| if (CHILD_ENV_ALLOWLIST.includes(key) || key.startsWith('LC_')) env[key] = value; |
There was a problem hiding this comment.
Windows operating variables disappear
If a Windows host enumerates variables as Path, SystemRoot, or ComSpec, this exact-case check drops them because the allowlist contains only uppercase spellings. Every default stdio child then starts without variables it previously inherited, so a server that launches tools by name or needs SystemRoot can fail. Match these names case-insensitively on Windows while keeping their original spelling.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/mcpl/transport.ts
Line: 133
Comment:
**Windows operating variables disappear**
If a Windows host enumerates variables as `Path`, `SystemRoot`, or `ComSpec`, this exact-case check drops them because the allowlist contains only uppercase spellings. Every default stdio child then starts without variables it previously inherited, so a server that launches tools by name or needs `SystemRoot` can fail. Match these names case-insensitively on Windows while keeping their original spelling.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| const line = await new Promise<string>((resolve, reject) => { | ||
| transport.once('line', resolve); | ||
| transport.once('error', reject); | ||
| }); | ||
| await transport.close(); |
There was a problem hiding this comment.
Probe exit leaves test waiting
If the spawned probe exits without printing a line, the transport emits close, but this promise listens only for line and error. The test cannot report the exit as a useful failure or reach transport.close(); a child that stays running without output can stall the suite. Handle close and ensure cleanup on every outcome.
Prompt To Fix With AI
This is a comment left during a code review.
Path: test/mcpl-child-env.test.ts
Line: 61-65
Comment:
**Probe exit leaves test waiting**
If the spawned probe exits without printing a line, the transport emits `close`, but this promise listens only for `line` and `error`. The test cannot report the exit as a useful failure or reach `transport.close()`; a child that stays running without output can stall the suite. Handle `close` and ensure cleanup on every outcome.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
slimepriestess
left a comment
There was a problem hiding this comment.
Review — exact head 81353cba943188527348157676c8e805e090aea9
Disposition: APPROVE for what it does, with one thing the changelog must say (or one lift to take) before that changelog sentence is true, and three non-blocking asks.
What it does. Stdio MCPL children stop inheriting the host's whole environment; buildChildEnv gives them an operating allowlist (CHILD_ENV_ALLOWLIST + LC_*) plus the server's declared env, with inheritEnv: true as the documented way back. The threat is real on a multi-resident host: every connector child could read every other bot's token and the provider keys.
Receipts, worktree on the exact head. mcpl-child-env 4/4. Mutation: put the spawn back to { ...process.env, ...config.env } and only the spawned-child case goes red (the three pure cases stay green, as they should — they test buildChildEnv, not the spawn). Full suite from a clean dist/: 962 tests / 958 pass / 0 fail. Merge-tree against current main 507ea8b: no conflicts. git diff --check clean.
The fix is partial, and the changelog doesn't say so. The framework spawns exactly two kinds of child. This PR fixes one. The other, the python interpreter behind codeExecution (src/code-execution/py-runner.ts, env: { ...process.env, PYTHONUNBUFFERED: '1' }), runs model-authored code, so on a host with code execution enabled any agent can still read every provider key and bot token with one os.environ. That's the same threat this PR names, on a child that deserves the discipline more. Probe on this head: a PyRunner script printing os.environ.get(<host secret>) prints the secret. (Found by the second read on our side; verified here.)
Lift, stacked on this head, yours to cherry-pick: slimepriestess/agent-framework@9e8ec77c790591223199fb797d87a68523842084 (fix/child-env-code-execution). PyRunner builds its env through the same buildChildEnv (allowlist + its own env, inheritEnv as the escape hatch, PYTHONUNBUFFERED kept); CodeExecutionConfig carries env/inheritEnv and both runner sites thread them; the changelog fragment gains the code-execution line. Test code-execution-env: the secret comes back None (red on 81353cb, printed the secret), declared env reaches the interpreter, inheritEnv restores the host env. Suite with the lift: 964 / 960 / 0. If you'd rather keep this PR to MCPL, the changelog sentence "one server's credentials … are no longer readable by another" needs a clause saying code-execution children still inherit until a follow-up.
Impact on the recipes I can see. connectome-host's mcpServers entries resolve to McplServerConfig, so they all go through this transport. clerk and knowledge-miner declare their secrets as ${VAR} in env and set ZULIP_RC_PATH, so the design's own story holds for them. zulip-miner declares no rc path and points at ../zulip-mcp (hyphen) rather than ../zulip_mcp: if that build reads ZULIP_* from env it now starts and then fails auth, which is exactly how this break presents. You've run this on the 5-resident host since 9/23 so you'll know which servers needed inheritEnv; worth one sentence in the fragment naming the symptom (the child starts, its first platform call fails).
Non-blocking asks.
- Windows casing (Greptile's first finding is right).
Object.entries(hostEnv)on Windows yieldsPath,SystemRoot,ComSpec, and the exact-case membership test drops them. The MCP SDK'sgetDefaultEnvironmentiterates the allowlist and readsprocess.env[key], which Node resolves case-insensitively on win32. Same shape here, then theLC_*sweep. Its Windows list also carriesHOMEDRIVE,HOMEPATH,SYSTEMDRIVE,USERNAME,PROCESSOR_ARCHITECTURE,PROGRAMFILES. - Proxy variables.
HTTP_PROXY/HTTPS_PROXY/NO_PROXY(and lowercase twins) aren't on the list, and connectors are network clients: behind a proxy the child starts and then can't reach Discord or Zulip, with no hint that the env is why. Operator-level, not another server's credential, inherited before. I'd add them. ()-prefixed values. The SDK skips values starting with()(exported shell functions). Cheap to copy.
Greptile's second finding (the spawned-child probe waits only on line/error, so a child that exits without printing hangs the test) is test hygiene; a close listener that rejects would make a failure legible.
Reviewed from the fork-and-worktree side only; merge is yours/antra's.
…too, not the host's secrets anima-research#175 stops stdio MCPL children inheriting the host environment. The other child the framework spawns, the python interpreter behind code_execution, still got `{ ...process.env }` — and it runs model-authored code, so any agent with code execution could read every provider key and bot token on the host with one os.environ. The changelog's "one server's credentials are no longer readable by another" was not yet true on a host with code execution on. PyRunner builds its child env through buildChildEnv: the same operating allowlist (+ LC_*), plus the runner's own `env`, with `inheritEnv: true` as the same escape hatch. CodeExecutionConfig carries both and the framework threads them to both runner sites. PYTHONUNBUFFERED stays. Test: model-authored python asks os.environ for a host secret and gets None (red on the PR head: it printed the secret); declared env reaches the interpreter; inheritEnv restores the host env. Suite 964 / 960 / 0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CoQK2cP55YhezE6ajSx58h
…itively on Windows Greptile's two findings on anima-research#193, both real: - The runner snapshotted its child env at construction, so a respawn after idle reclaim reused stale host values where the old per-spawn `{ ...process.env }` saw current ones. The env options are stored and buildChildEnv runs at every spawn. - Windows enumerates env names in arbitrary spellings (`Path`, `SystemRoot`, `ComSpec`) and the exact-case allowlist dropped them, starting every stdio/python child without its search path. On win32 the allowlist now matches by upper-cased name, keeping the original spelling; POSIX stays case-sensitive (`Path` is a different variable there). This also closes the anima-research#175 review's standing Windows note. Both tests red on the previous head: freshness 0/1, win32 casing 0/1; full suite 976/972/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The branch's earlier fragment landed on main with anima-research#175, so the check saw no new entry for this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem. Stdio MCPL server children inherit the host's entire environment. On a multi-resident host the
.envcarries provider API keys and several bots' tokens, and every connector child can read all of them, including the ones meant for a different server.Fix.
buildChildEnvpasses a small allowlist (PATH,HOME, locale/LC_*,TMPDIR, TLS roots, display vars, and Windows equivalents; seeCHILD_ENV_ALLOWLIST) plus the server's own configuredenv.inheritEnv: trueon a server config restores full inheritance for servers that genuinely need it.Breaking for servers that relied on an inherited variable: declare it in the server's
env(recipes can${VAR}-substitute) or setinheritEnv. The changelog fragment is marked breaking.Tests.
test/mcpl-child-env.test.tscovers dropping host secrets while keeping operating vars andLC_*, declaredenvwinning over host values,inheritEnv, and a real spawned stdio child seeing only allowlist + declared env. Full suite: 958 pass / 0 fail.This has run in production on a 5-resident host since 2026-09-23.
🤖 Generated with Claude Code