Skip to content

feat(cli,mcp): the runtime token reaches the MCP child on a pipe, not in its environment (TASK-078) - #1780

Merged
lilyshen0722 merged 1 commit into
mainfrom
kai/mcp-token-pipe
Sep 20, 2026
Merged

lilyshen0722 merged 1 commit into
mainfrom
kai/mcp-token-pipe

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

TASK-078. Sam ruled on the posture card (70188/70199, 2026-09-19): take the token out of the environment — inherited pipe. This is the pi half; the claude/codex half cannot use a pipe at all and is explained below rather than silently left out.

What was leaking

connectStdioMcp spawned every declared stdio server with {...process.env, ...env}, and the default declaration puts the seat's runtime token in that env map (default-environment.js:80, COMMONLY_AGENT_TOKEN: '${COMMONLY_AGENT_TOKEN}' substituted in pi.js:75). So the token sat in the MCP child's environment, where any same-user process reads it back with ps eww <pid> or /proc/<pid>/environ — the boundary does not hold on a shared host.

What replaces it

The token rides an inherited pipe on fd 3 — the same channel TASK-070 already uses for the server list — and the child's environment carries only a pointer, COMMONLY_TOKEN_FD=3, which is not a secret. commonly-mcp reads the credential from that fd when the pointer is declared.

The declared fd is authoritative. A read that fails does NOT fall back to the environment: silently using the weaker channel after being told to use the stronger one is precisely the class of bug this removes. The failure names the fd.

The inherited environment is stripped even when the declaration asks for nothing. {...process.env, ...env} meant the daemon's own token reached a child that never asked for it; a declaration that does not name the token gets none.

The fd is read to EOF and never closed — on macOS a closeSync after a failed read aborts the process (kqueue.c), which TASK-070 already paid for.

Who still gets the environment variable, and why

The pipe is only available where WE spawn the server. describeMcpCommand decides from the declaration, and the fallback is a property of the server, not of this change's mood:

declaration channel why
@commonlyai/mcp@<>= 0.3.11>, or a local checkout of it at that version pipe it can read the fd
@commonlyai/mcp@latest, or unpinned pipe it tracks the published release, and the merge publishes 0.3.11 (.github/workflows/npm-publish.yml fires on commonly-mcp/**)
@commonlyai/mcp@< 0.3.11 (a pin — the sprint seats run a staging checkout of 0.3.4) env, with a warning naming the pin handing a server a pipe it cannot read takes its tools away rather than its secret
a server that is not @commonlyai/mcp at all env, untouched its declaration asked for the key and it has no reason to know about a pipe
COMMONLY_TOKEN_CHANNEL=env in the entry env an explicit opt-out, and the escape hatch if this ever goes wrong on a seat

The first draft of this change piped any server that declared the key. That is a silent contract change to a stranger's server, so the identification step exists, and mutation M7 is what keeps it honest.

The claude/codex half is NOT done here, deliberately

Those two runtimes start the MCP server themselves: claude expands ${COMMONLY_AGENT_TOKEN} inside its own process environment (claude.js:280, where the comment records that this replaced materializing the token in a transient JSON file) and codex hands it over in mcp_servers.*.env_vars (codex.js:175). A pipe opened by the daemon never reaches that grandchild, so "the env var dropped per runtime" can only be true for pi. Those two need a launcher-based channel (a per-spawn 0600 file, or a local socket the launcher reads) or the scoped per-spawn token from option B — a decision I am not making inside a PR. Flagged in the pod (70204) with wren.

Evidence

  • 12 new CLI tests incl. three that spawn a real child which reports its own fd 3, its own COMMONLY_TOKEN_FD, and its own COMMONLY_AGENT_TOKEN — because an argument passed to an injected fake is not proof that a process could not read the secret. 6 new mcp tests.
  • cli 41 suites / 645 tests green; commonly-mcp 3 suites / 66 tests green.
  • 7 mutations, each reddening a distinct witness, no survivors, both sources restored byte-identical: M1 the pipe path also leaves the token in env; M2 the inherited env is no longer stripped; M3 the version comparison inverts; M4 the pointer variable is dropped; M5 the reader falls back to env when the declared pipe fails; M6 the reader stops trimming; M7 a stranger's server is piped.
  • The real-child test caught a real defect while being written: the first implementation deleted the token from the child env whenever it was not piped, which broke the old-pinned fallback it had just been given. That is what the end-to-end witness is for.

Versions

cli 0.1.61 (0.1.60 is #1778's, 0.1.59 the TASK-052 baseline's) and @commonlyai/mcp 0.3.11 — the reader must be published for the pipe to be readable, and npm-publish.yml publishes it on merge. If a daemon updates inside the publish window, the child fails loudly (No runtime token: either COMMONLY_AGENT_TOKEN must be set, or COMMONLY_TOKEN_FD must name a pipe carrying one), not silently; COMMONLY_TOKEN_CHANNEL=env on the entry is the immediate escape.

… in its environment (TASK-078)

Sam's ruling on the posture card: take the token out of the environment.

`connectStdioMcp` spawned every declared stdio server with
`{...process.env, ...env}` and the default declaration put the seat's runtime
token in that map, so the token sat in the MCP child's environment where any
same-user process reads it with `ps eww <pid>` or /proc/<pid>/environ.

The token now rides an inherited fd 3 — the channel TASK-070 already uses for the
server list — and the child's environment carries only the pointer
`COMMONLY_TOKEN_FD=3`. `commonly-mcp` reads it from there and never falls back to
the environment when the fd is declared but unreadable. The inherited environment
is stripped of the key even when a declaration does not name it, because
`...process.env` made the daemon's own token a channel into every child.

A server that cannot read the pipe keeps the variable: a pin older than 0.3.11
(measured from the command, with a warning naming it), a server that is not
@commonlyai/mcp at all, or an explicit COMMONLY_TOKEN_CHANNEL=env.

claude and codex start the MCP server themselves, so a pipe we open never
reaches that grandchild; they still hand the token over in their runtime's
environment, and that half is flagged rather than silently assumed to be done.
@lilyshen0722
lilyshen0722 merged commit f064d57 into main Sep 20, 2026
13 checks passed
samxu01 pushed a commit that referenced this pull request Sep 20, 2026
Two slots, and the numbers are the ones free behind #1780's 0.1.61/0.3.11 —
re-derive both against main if the queue moves before this is cut.

The cli bump is for the three adapter changes and the hook read; the mcp bump is
for the credential reader, which resolves fd, then file, then env, and treats a
declared source as authoritative. Both files are touched only on the version
line.
lilyshen0722 added a commit that referenced this pull request Sep 20, 2026
… not as an inherited environment value (#1801)

* feat(mcp): the credential reader resolves fd, then file, then env — by declaration (TASK-083)

A declared source is authoritative: a failed read throws and never falls
through to the weaker channel. New COMMONLY_TOKEN_FILE=<path> branch, read
to EOF and trimmed, refusing an empty credential. Needed because claude and
codex cannot be handed a pipe we opened — claude expands ${VAR} from its own
environment and codex forwards declared variables from its own — so a path
is the strongest channel either of them can carry.

6 new tests; 5 mutations, each reddening a distinct witness, no survivors.

* feat(cli): an MCP child gets an allowlist and a launcher credential file (TASK-083)

The pi bridge spawned every declared MCP server with {...process.env, ...declared},
so a third-party server inherited the seat token and the model key — measured on
2026-09-19: a @playwright/mcp process held COMMONLY_AGENT_TOKEN, and a pi seat's
MCP child held COMMONLY_LITELLM_KEY. The child environment is now the derived
allowlist {PATH, HOME, TMPDIR, and the proxy/CA names} plus what the entry itself
declares.

The credential may now also be named as a PATH (COMMONLY_TOKEN_FILE) instead of
carried as a value: the bridge reads that file and pipes the token on fd 3, so the
child is handed neither the secret nor the path. A declared file is authoritative
(a failed read throws rather than falling through), and an old pinned server that
cannot read the pipe still gets the value in its environment, with the warning.

New cli/src/lib/credential-file.js writes the per-spawn 0600 file (chmod asserted
after the write, since writeFileSync's mode is filtered by umask) inside a 0700
directory. Also corrects the stale doc line claiming the staging seats run 0.3.4;
they run 0.3.7 (measured).

14 new tests, including two real-child probes that read their own fd 3 and their
own environment; 9 mutations, no survivors.

* refactor(cli): one version predicate and one credential-delivery decision (TASK-083)

The channel question — does this declared server read a pipe, a file, or only a
value in its environment — was about to be answered separately in the pi bridge,
the claude adapter and the codex adapter. Three copies of a version threshold
drift, and the drift is silent in whichever one nobody is looking at, so the
predicate moves to mcp-server-version.js (describeMcpCommand, the pipe-reader
release 0.3.11 and the file-reader release 0.3.12) and the decision to
mcp-credential-delivery.js.

deliverSeatCredential rewrites a DECLARATION, not a value: an entry naming
COMMONLY_AGENT_TOKEN comes back naming COMMONLY_TOKEN_FILE, so the token never
exists in the runtime's environment for its whole process subtree to inherit.
Four honest outcomes: 'path' (moved), 'env' (kept deliberately — somebody else's
server, or a checkout that predates the file reader, each with its own warning),
'none' (no credential declared) and 'unavailable' (no launcher file this spawn,
so nothing is rewritten rather than pointing at nowhere).

10 new tests. Also moves CREDENTIAL_KEY beside CREDENTIAL_FILE_VAR so the two
channels are named in one place.

* feat(cli): the claude seat hands its MCP servers a credential file, not a token (TASK-083)

claude spawns its MCP servers inside its own process tree, so there is no pipe to
inherit: the only two channels are a value in claude's environment, which every
child and every hook inherits, or a PATH that the declared server opens itself.
The default declaration takes the path. deliverSeatCredential rewrites the config
claude actually reads — an entry naming COMMONLY_AGENT_TOKEN comes back naming
COMMONLY_TOKEN_FILE — so the token is never in claude's environment to be
inherited, logged, or dumped by any other server the seat was granted.

WHERE the file lives is part of the contract, measured rather than assumed:
sandbox/seatbelt.js admits exactly one path outside the workspace to a confined
seat, a subpath rule on the per-spawn --mcp-config directory. A credential file
under ~/.commonly would be unreadable by the very child it was written for, so
the file is written inside that directory, in a 0700 subdirectory, at 0600, and
removed by the spawn's existing finally block.

A second handover is deliberately NOT moved, and is now warned about: an entry
naming the token in args, url, headers, or inside a larger string gets a literal
substitution from claude, where a file path is not a value. The expansion loop
already exposed only values the declaration references, and the rewrite is what
stops the default declaration referencing the token — so the value survives for
those spawns, and only those, with a warning naming the trade. That is the
carve-out TASK-082 exists for (the grant broker's bearer header), and the three
pre-existing claude environment tests are its witnesses: they pass unchanged.

11 new tests, plus C1-C8 mutations with no survivors (the config rewrite, the
file's location, the cleanup, the warning, the expansion guard, the stranger
branch, the version threshold, the no-file branch). Full cli suite 43/690.

* feat(cli): the codex seat forwards a credential path, never the token (TASK-083)

Same decision as the claude half, different mechanism. codex takes its MCP
servers as `-c mcp_servers.*` overrides, and its existing anti-leak measure
diverted a token-bearing ENV VALUE into `env_vars`, which codex resolves from its
own environment — measured on a live codex seat: three MCP children each carrying
a 73-char token. The declaration rewrite removes the need for that route
entirely: the credential becomes COMMONLY_TOKEN_FILE, a path, which is not
token-bearing, so it lands in `env={...}` on the command line where a path is
harmless and the child opens the file itself.

Substituting the file placeholder needed one line in SUBSTITUTION_KEYS, because
the path itself is the thing being handed over.

Two things this change made visible, both now handled rather than assumed:

- A token wanted as a COMMAND ARGUMENT had no env_vars route and was substituted
  literally into `-c mcp_servers.<name>.args`, i.e. onto a command line readable
  by every same-user process — while the doc block in this file promised bearer
  tokens never ride in argv. That entry is now refused whole, with the reason,
  rather than half-wired or published.
- A declaration that needs the token as a literal (an env value containing it, or
  a command argument) still gets it forwarded, and now says so: the seat whose
  environment carries a secret is named instead of assumed fixed.

The credential file is written into the per-spawn directory this adapter already
creates and removes for --output-last-message, so a child codex spawns can reach
it and no second lifecycle has to be right.

Updated the existing mcp-override regression test, whose expected argv was
`env_vars=["COMMONLY_AGENT_TOKEN"]` — the leak this change closes — and added
three tests for the carve-out and the argv refusal. D1-D6 mutations, no
survivors. Full cli suite 43/693.

* feat(cli): a pi seat's MCP servers get the credential from the launcher file (TASK-083)

The pi bridge already had the strongest channel — it reads a credential file and
puts the VALUE on the child's fd 3, so the token never reaches a child's
environment — but nothing wrote that file for a pi seat, so the declaration had
to name the token variable and pi substituted the value into the payload it hands
the bridge. Now the launcher writes the file and the declaration names it: the
payload carries a PATH, the bridge reads it, and the value still travels only on
fd 3.

The file is written into this seat's own 0700 home, one per spawn, and removed in
a finally when the turn ends — a token file that outlives its turn is a token
file that sits in a home for the next one. pi has no enforced sandbox, so the
seat home is the narrowest place the bridge can still read; the claude and codex
halves cannot use it and write into the per-spawn directory each already hands
its runtime instead.

Updated the fd-3 payload test, whose expected payload was the token itself, and
added two tests: one that witnesses the file DURING the spawn (holding the token,
0600, inside a 0700 directory in the seat home — an after-the-fact read can only
prove absence, since the finally is deliberate) and one that a declaration asking
for no credential is untouched. E1-E4 mutations, no survivors. Full cli suite
43/695.

* feat(cli): a hook reads the credential the way its runtime now carries it (TASK-083)

Emptying the token out of a seat runtime's environment has a second consumer
nobody asked about: the tool-policy hooks are child processes of that runtime, so
they read exactly the environment the token was removed from. Left alone they
would find nothing, and because a hook fails OPEN by design (an unwritable ledger
must not become an accidental blocker), the symptom would not be an error — it
would be a hook that silently stopped deciding anything.

So resolveHookToken prefers the launcher file and falls back to the value
variable for a seat whose declaration has not migrated. Unlike the MCP reader it
does not throw on a declared-but-unreadable file: a hook that dies is a hook the
runtime reports as broken, and failing open is the documented posture here.

The shipped default declaration stays EXACTLY canonical — it still names the
token variable, and each adapter rewrites it per spawn. That is deliberate for
deploy skew: a record that named the file directly would be one an older CLI
could not serve, and the guard's canonical-shape check is a security property
rather than a formality, so it is left alone and the decision is asserted instead
of widened.

Tests: four for the hook credential (file wins, unreadable falls back, empty
falls back, a blank declaration is not consulted, and the pre-existing
behaviour) and one asserting the shipped entry remains canonical. F1-F4 and G1
mutations, no survivors. Full cli suite 43/699.

* chore(release): cli 0.1.62, @commonlyai/mcp 0.3.12 (TASK-083)

Two slots, and the numbers are the ones free behind #1780's 0.1.61/0.3.11 —
re-derive both against main if the queue moves before this is cut.

The cli bump is for the three adapter changes and the hook read; the mcp bump is
for the credential reader, which resolves fd, then file, then env, and treats a
declared source as authoritative. Both files are touched only on the version
line.

* fix(cli): the runtime's own environment is not a credential channel (TASK-083)

Vera 70455: four cli tests named the value in the runtime's environment, and the
value they found was the runner's own. The declaration rewrite moved our MCP
servers onto a launcher file, but every adapter derives its runtime environment
from `process.env` -- where the documented bootstrap export lives -- so the
credential came back into the runtime and from there into every child below it:
claude's MCP children and hooks, codex's MCP children, pi's own bash tool (which
spawns with `{ ...process.env }`).

One rule, in the module that already owns the delivery decision
(`withholdRuntimeCredential`): the PATH of this spawn's file goes into the
runtime environment, because a path is not a secret and a hook resolves its
credential from it; the VALUE is deleted, unless a carve-out genuinely needs it
there. The adapter passes that answer in rather than inferring it -- claude's
literal args/url/headers, codex's forwarded env -- so the spawn that still
carries a secret is the one that warns about it.

The hook path was wired to nothing. `resolveHookToken` (launcher file first,
value second) existed, was documented, and was unit-tested, while `agent
hooks-forward` -- the command hooks-config actually installs into Claude
settings -- read `process.env.COMMONLY_AGENT_TOKEN` directly. On a migrated seat
the value is absent, so the forwarder returned `hook_unavailable` and the
tool-policy hook silently stopped deciding anything: the fail-open posture turns
a dead credential into a silence rather than an error. The command now resolves
through `resolveHookToken`, and a test drives that command through
`registerAgent` and asserts the Authorization header the backend would see.

Tests: the four that failed under an exported variable are now positive
controls -- they plant the launcher's value explicitly instead of inheriting
whatever the runner happens to have, which is why they passed in one worktree
and failed in another. New: a pi witness (value out, path in, provider key
kept) and the hooks-forward end-to-end witness. Mutations M1-M7, all run with
the variable exported: no survivors, every source restored byte-identical.
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.

1 participant