Skip to content

fix(mcpl): scrub host env from stdio MCPL children - #175

Merged
Anarchid merged 1 commit into
anima-research:mainfrom
ian-de-marcellus:fix/mcpl-child-env-scrub
Sep 29, 2026
Merged

Anarchid merged 1 commit into
anima-research:mainfrom
ian-de-marcellus:fix/mcpl-child-env-scrub

Conversation

@ian-de-marcellus

Copy link
Copy Markdown
Contributor

Problem. Stdio MCPL server children inherit the host's entire environment. On a multi-resident host the .env carries 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. buildChildEnv passes a small allowlist (PATH, HOME, locale/LC_*, TMPDIR, TLS roots, display vars, and Windows equivalents; see CHILD_ENV_ALLOWLIST) plus the server's own configured env. inheritEnv: true on 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 set inheritEnv. The changelog fragment is marked breaking.

Tests. test/mcpl-child-env.test.ts covers dropping host secrets while keeping operating vars and LC_*, declared env winning 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

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>
@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5 Tier: apex

[High risk] Changes how child processes inherit environment variables.

The PR should not merge until default stdio children retain required operating variables on Windows.

Findings

  1. P1 Windows operating variables disappear ▶
  2. P2 Probe exit leaves test waiting ▶
Fix with agent prompt
### Issue 1
src/mcpl/transport.ts:133
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.

### Issue 2
test/mcpl-child-env.test.ts:61-65
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.

Summary

The PR limits stdio MCPL children to an operating-variable allowlist plus server-declared variables, and adds an opt-in for full inheritance.

  • Adds configuration documentation, a breaking-change notice, and child-environment tests.
  • The allowlist needs to account for Windows environment-key casing.

Reviews (1) · Last reviewed commit: "fix(mcpl): scrub host env from stdio MCP..."

Comment thread src/mcpl/transport.ts
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment on lines +61 to +65
const line = await new Promise<string>((resolve, reject) => {
transport.once('line', resolve);
transport.once('error', reject);
});
await transport.close();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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 slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

  1. Windows casing (Greptile's first finding is right). Object.entries(hostEnv) on Windows yields Path, SystemRoot, ComSpec, and the exact-case membership test drops them. The MCP SDK's getDefaultEnvironment iterates the allowlist and reads process.env[key], which Node resolves case-insensitively on win32. Same shape here, then the LC_* sweep. Its Windows list also carries HOMEDRIVE, HOMEPATH, SYSTEMDRIVE, USERNAME, PROCESSOR_ARCHITECTURE, PROGRAMFILES.
  2. 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.
  3. ()-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.

@Anarchid
Anarchid merged commit 5498805 into anima-research:main Sep 29, 2026
6 checks passed
slimepriestess added a commit to slimepriestess/agent-framework that referenced this pull request Sep 29, 2026
…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
@slimepriestess

Copy link
Copy Markdown
Contributor

Follow-up delivered: the py-runner half of this (the second spawn path, flagged in the review) is now its own PR, rebased onto post-merge main — #193. The stacked lift head 9e8ec77 is superseded by 4ed84f7 there.

slimepriestess added a commit to slimepriestess/agent-framework that referenced this pull request Sep 29, 2026
…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>
slimepriestess added a commit to slimepriestess/agent-framework that referenced this pull request Sep 30, 2026
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>
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.

3 participants