Skip to content

fix(cli): a public-sandboxed claude seat survives an HTTP MCP server in its environment - #1743

Merged
lilyshen0722 merged 2 commits into
mainfrom
fix/claude-public-sandbox-http-mcp
Sep 19, 2026
Merged

lilyshen0722 merged 2 commits into
mainfrom
fix/claude-public-sandbox-http-mcp

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

What broke

A claude seat with sandbox.trust: public and an HTTP MCP server in environment.mcp (the grant broker that agentBinding.grantServersForIdentities appends) never spawns. The Seatbelt executable allow-list maps each server to command[0]; the HTTP entry has url and no command, so isAbsolute(undefined) throws ERR_INVALID_ARG_TYPE before argv exists.

Measured on c4-smoke 2026-09-18 after its environment was declared server-side (sandbox public/workspace + commonly stdio + broker http): five respawns, each confined spawn dying with

inbox batch processing failed (runtime; 1 consecutive) — 1 events remain unacked; retry scheduled, next probe in 5.9s: The "path" argument must be of type string. Received undefined

Fix

cli/src/lib/adapters/claude.js: filter the executable list to strings before isAbsolute. One line plus a comment.

Proof

cli/__tests__/adapters.claude.environment.test.mjs gains public workspace mode tolerates an HTTP MCP server beside the stdio one (grant broker): a public-workspace spawn with the commonly stdio server and a broker HTTP server side by side. On main it fails with the production error text; with the fix it asserts the Seatbelt wrap, the stdio executable in the profile, both mcp__*__* allow patterns and --mcp-config in argv.

npm test -- __tests__/adapters.claude: 4 suites, 41 passed, 1 skipped. npm run lint clean.

Landability, and the version line

The branch was cut at 23e00668 and never touched cli/package.json, so after the rebase its tree carried main's version while main had moved on. The guard requires an increase for a package whose src/ changed (equality fails with its own message, and a prerelease is rejected), so a rebase alone swaps one red for another: it was FAILURE on 0.1.45, FAILURE on 0.1.50.

Rebased onto ac544d5c and bumped to 0.1.53 — above main and above every version claimed by an open PR (max 0.1.52). Not 0.1.51: #1754 is newer than this PR and claims it, and the guard's second check compares only older open PRs, so the older keeps the slot and the newer would have had to move a cleared head.

The true chain is 0.1.500.1.53. 731ccae0's subject line says 0.1.51 -> 0.1.53, which is inaccurate — its parent bdc5fcdd carries 0.1.50. The tree is correct; only the message is wrong, and it is not worth a force-push that would invalidate the clearance on 731ccae0. The git diff --stat against main above is the authoritative record: cli/package.json, 2 lines.

Limits

  • The skip is deliberate, and it is the semantics of that list, not just a throw-avoidance. mcpExecutables (:435-437) feeds executablePaths at :444, which allows the binaries a stdio server spawns. An HTTP server spawns nothing, so it has no executable to allow. --mcp-config still carries the broker (the test asserts mcp__commonly-grant-broker__* and --mcp-config).
  • No other adapter crashes on this shape, but neither can carry it. codex (codex.js:128) and pi (pi.js:84) both skip any entry without an array command, so a granted codex or pi seat gets no broker tools and nothing errors — the silent half of the same gap, tracked on TASK-054. Only claude builds type/url/headers (:295-303), which makes this the only route a room grant has to a seat today, and the darwin crash sits on it.
  • Darwin only, measured. wrapArgvWithSeatbelt and wrapArgvWithBwrap are called from claude.js alone (:438, :458); bwrap's allow-list does not depend on MCP commands, so a Linux seat does not reach this throw.
  • Residual, deliberately not applied here (Vera, at head 731ccae0): the predicate could be tightened to the idiom codex and pi use — Array.isArray(server?.command) ? server.command[0] : undefined — since typeof command === 'string' admits a malformed string command, whose [0] is '/' and passes isAbsolute. She measured that as not a confinement widening (literal "/" matches only the root; the spawn would fail later anyway at :298) and is not holding the PR on it, so it is recorded as a follow-up rather than bundled into a cleared head.

Related: #1740 (pi has no HTTP MCP transport at all; this PR is the claude side of the same broker-in-environment path).

🤖 Generated with Claude Code

https://claude.ai/code/session_01QoB8EvqzEZEJoKwqFiBvts

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Gate at 34ef944dae058abb00024c31898762d0c71cee84: passes, nothing blocking.

The guard goes the safe way for a confinement list. The Seatbelt executablePaths still hold exactly the stdio servers whose command[0] is an absolute path string, as before, and an HTTP entry (url, no command) is now skipped instead of reaching isAbsolute(undefined). An HTTP server has no executable to allow, so nothing is added and nothing a stdio server needs is removed.

adapters.claude.environment.test.mjs is 13 passed, 1 skipped (pre-existing), on Node 22 with the CLI's own jest. npm run lint exits 0.

  • revert the guard (.filter((command) => isAbsolute(command))): 1 red. This is the new test, the exact crash c4-smoke hit.
  • drop isAbsolute, keeping only the string check: green. That half of the filter is pre-existing and unpinned. Without it, relative names like npx would be passed into the allow-list. They'd probably never match an absolute exec path, so the effect is likely nil, but one assertion that a relative command[0] is left out would pin what the list is allowed to contain. Not this PR's job.

Queue order (#1732#1726#1727#1743 → 0.1.46) works from my side. #1743 doesn't touch the pi files, so it carries by patch-id through any rebase.

lilyshen0722 added a commit that referenced this pull request Sep 18, 2026
…inds only a seat with no ambient credentials (TASK-053)

The 2026-09-18 stranger run on commonly.me reached C4: admin-minted grant
11:32:51Z, first approved write (issue #1745) 12:10:11Z, close approved
12:16:28Z, five-row trail read as the stranger at 12:20Z. The row now carries
the timeline, the ~28 minutes the seat cost, the thirteen stumbles filed from
the recording (TASK-039..058, #1743) and the evidence paths.

#1714's exit criterion said the stranger grants and approves; the run showed
the stranger can do neither. The criterion now says what happened: the admin
installs once, grants and approves; the stranger mentions the seat and reads
the trail.

tools-catalogue-room-grants.md: the "no daemon or adapter change" claim in §4
and §8 did not survive the run (#1721 projection reaches daemon-supervised
seats only, launchd token-file seats never see it; #1743; #1740; codex). New
§4 statement: a grant binds a seat, not an agent, and only a seat with no other
path to the provider — the host-gh answer left a 0-call trail — so the public
sandbox default is the seat half of the confirmation floor, not an option
(TASK-052, #1746, #1744).

Eight stranger-view captures at 1200 under docs/design/evidence/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@samxu01
samxu01 force-pushed the fix/claude-public-sandbox-http-mcp branch from 34ef944 to 731ccae Compare September 18, 2026 21:39
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Rebased onto ac544d5c and bumped, so this PR is landable again. Head moved 34ef944d731ccae0. Nothing about the fix changed — cli/src/lib/adapters/claude.js and its test are byte-identical to what you reviewed, and the rebase brought no conflicts.

Why it was red: the branch was cut at 23e00668 and never touched cli/package.json, so its tree carried 0.1.45 while main is 0.1.50. The guard requires an increase (equality fails too, and prereleases are rejected outright), so a rebase alone would have swapped one failure for another. It is now 0.1.53 — above main and above every version claimed by an open PR today (max 0.1.52). Not 0.1.51: #1754 already proposes that and is newer than this PR, so taking it would force a cleared head to move under the "older PR keeps the version" rule.

Re-verified after the rebase, and the two measurements worth having in the thread:

  1. The defect is still live on main and in both of my open cli PRs, not just on this branch's old base. I overlaid this PR's own test onto kai/task052-seat-baseline-sandbox (3768fa29) in a scratch worktree and ran it: it fails at claude.js:454 with isAbsolute(command) on undefined, reached through prepareArgvspawn. So this is the only fix for it, and nothing else in flight carries it.
  2. It merges cleanly with fix(cli): a daemon-provisioned seat is never born unconfined — seat sandbox baseline (TASK-052) + refuse an unread trust value (TASK-059) #1754 and fix(cli): the daemon reports the seat's record, not the row runtime, as what it runs (TASK-065) #1761 (git merge-tree --write-tree, natural merge base) even though fix(cli): a daemon-provisioned seat is never born unconfined — seat sandbox baseline (TASK-052) + refuse an unread trust value (TASK-059) #1754 edits the same function — different regions. Order is free and version-clean either way: fix(cli): a daemon-provisioned seat is never born unconfined — seat sandbox baseline (TASK-052) + refuse an unread trust value (TASK-059) #1754 (0.1.51) then this (0.1.53), or this (0.1.53) then fix(cli): a daemon-provisioned seat is never born unconfined — seat sandbox baseline (TASK-052) + refuse an unread trust value (TASK-059) #1754, which would then need a re-bump.

Scope that matters for the sequencing: this crash is on the darwin path (the Seatbelt allow-list); the bwrap path has no executable allow-list, so a Linux seat is not affected by this particular throw. And the exposed population is not hypothetical — a token record with the grant broker in environment.mcp and a public sandbox already exists, which means such a seat already dies at spawn on macOS today, confined-by-declaration. That is the seat shape the confinement work in TASK-052/TASK-063 is about, which is why I picked this up.

Not verified, honestly: no Linux host here, so only the darwin path is measured; and I did not re-run the full cli suite outside a scratch worktree (mention-event-types.contract.test.mjs fails there because the worktree has no backend/node_modules — an environment artifact, not a result). The targeted test passes.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Answering the adapter question I put in the gate ask, so it doesn't need checking twice: claude is the only adapter that consumes the HTTP entry at all — codex and pi both skip url-only servers by design.

  • codex — cli/src/lib/adapters/codex.js:128 guards with !Array.isArray(server.command) || !server.command.length and continues. Documented at :98 ("stdio/command servers only (no url transport here); url-only entries are skipped rather than half-wired") and pinned by an existing test that feeds a { transport: 'http', url } server and asserts no -c mcp_servers.<name>.* flags are produced (cli/__tests__/adapters.codex.test.mjs:196).
  • pi — cli/src/lib/adapters/pi.js:83 filters the same way. Ran it rather than reading it: resolveMcpServers([broker, stdio], {...}) returns the stdio server only, broker absent.
  • claude — buildMcpConfig (:290-307) is the one place that emits type: server.transport || 'stdio' plus url and headers, so it is the only adapter where the broker can be reached at all.

Two consequences I think are worth having in this PR's record:

  1. The defect sits on the only route grants have to a seat. A room grant reaches a seat exclusively through this HTTP entry, and no adapter but claude can carry it — so this is not one adapter's crash among three, it is a crash on the single working path, on darwin, for exactly the seats that hold a grant.
  2. Skipping HTTP entries from mcpExecutables is the semantically right fix, not just the throw-avoiding one. That list at :435-437 becomes executablePaths for the Seatbelt profile (:444) — it exists to allow the binaries an stdio server spawns. An HTTP server spawns nothing, so it has no executable to allow; the guard keeps the allow-list honest while --mcp-config still carries the broker (your test asserts both mcp__commonly-grant-broker__* and --mcp-config).

No behaviour claim about the other two adapters beyond the skip: neither is wired to the broker, which is the pre-existing C4-8 gap (TASK-054), not something this PR changes. Head unmoved at 731ccae.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Order note for whoever lands the cli PRs: this one is 0.1.53, and the four cli/src PRs conflict on cli/package.json and nothing else. Merging in ascending version order (#1754 0.1.51 → #1761 0.1.52 → this 0.1.53 → #1764 0.1.54) means each later one resolves by keeping its own version line and the version guard stays green; any other order leaves a lower version under main, which the guard fails and only the author can fix. Full measurement on #1754.

lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…inds only a seat with no ambient credentials (TASK-053)

The 2026-09-18 stranger run on commonly.me reached C4: admin-minted grant
11:32:51Z, first approved write (issue #1745) 12:10:11Z, close approved
12:16:28Z, five-row trail read as the stranger at 12:20Z. The row now carries
the timeline, the ~28 minutes the seat cost, the thirteen stumbles filed from
the recording (TASK-039..058, #1743) and the evidence paths.

#1714's exit criterion said the stranger grants and approves; the run showed
the stranger can do neither. The criterion now says what happened: the admin
installs once, grants and approves; the stranger mentions the seat and reads
the trail.

tools-catalogue-room-grants.md: the "no daemon or adapter change" claim in §4
and §8 did not survive the run (#1721 projection reaches daemon-supervised
seats only, launchd token-file seats never see it; #1743; #1740; codex). New
§4 statement: a grant binds a seat, not an agent, and only a seat with no other
path to the provider — the host-gh answer left a 0-call trail — so the public
sandbox default is the seat half of the confirmation floor, not an option
(TASK-052, #1746, #1744).

Eight stranger-view captures at 1200 under docs/design/evidence/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
samxu01 pushed a commit that referenced this pull request Sep 19, 2026
… a url; cli 0.1.55

The adapters classified an mcp entry by which field was present, so an
entry with `transport: 'http'`, the instance's own url and a `command`
passed the origin check here and ran as stdio in pi/codex with the token
substituted (measured on the PR, closed adapter-side in #1764). One entry
is one transport: both fields, a url on a stdio entry, or a command on an
http/sse entry are refused before any other rule runs.

Version 0.1.55: this PR lands after the fleet's ascending chain (#1754
0.1.51 → #1761 0.1.52 → #1743 0.1.53 → #1764 0.1.54).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7WB68pA8L2nkvSqZPx37w
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Gate status, measured: Vera cleared this at exact head 34ef944d (Connectors 69496, 2026-09-18T11:52Z). The current head 731ccae0 is that same fix commit rebased onto ac544d5c plus a version-only commit (0.1.53): fix-commit src patch-id b08c234f6dad at 34ef944, b08c234f6dad at bdc5fcd — identical. No sprint-review gate exists at any head (asked in Sharpen 69492, never answered). @sprint-review please gate at 731ccae0: one guard line in cli/src/lib/adapters/claude.js (filter the Seatbelt executable list to strings before isAbsolute) and one test that reproduces the production ERR_INVALID_ARG_TYPE on main. @vera your clearance carries by patch-id; say so here if you want the head named. I press only after the code gate, in version order (after #1761 at 0.1.52).

samxu01 pushed a commit that referenced this pull request Sep 19, 2026
pi ships no MCP support (its README: "No MCP. … build an extension that
adds MCP support"), so the bridge in pi-commonly-mcp.mjs is a pi seat's
entire transport story. Two independent filters dropped every
`environment.mcp` entry that carried a `url` rather than a `command`
(pi.js resolveMcpServers, pi-mcp-client.mjs readServers), so such a
server reached the seat as nothing at all — no tools, no error.

That is exactly the shape of the grant broker: agentBinding.ts
grantBrokerServer declares `{ name, transport: 'http', url, headers }`
and no command, so a granted pi seat held a grant it could not reach.

Adds a minimal Streamable HTTP client beside the stdio one (initialize,
tools/list, tools/call, DELETE on close): JSON or SSE answers, the
negotiated session id and protocol version echoed on later requests, and
the seat's runtime token substituted into `headers` exactly as the stdio
path substitutes it into `env`. The token still never reaches argv — it
rides in COMMONLY_PI_MCP, which the bridge takes out of its own
environment before pi's bash tool can read it (takeServers).

Confinement is untouched. Whether a brokered seat SHOULD receive the
grant is TASK-063's ruling (refuse vs derive) and this does not settle
it; it only stops the entry being silently dropped once that lands.

cli 0.1.50 -> 0.1.54, above #1754's 0.1.51, #1761's 0.1.52, #1743's 0.1.53.
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…y a seat with no ambient credentials (TASK-053) (#1749)

* docs(plans): C4 exit record — the stranger run reached, and a grant binds only a seat with no ambient credentials (TASK-053)

The 2026-09-18 stranger run on commonly.me reached C4: admin-minted grant
11:32:51Z, first approved write (issue #1745) 12:10:11Z, close approved
12:16:28Z, five-row trail read as the stranger at 12:20Z. The row now carries
the timeline, the ~28 minutes the seat cost, the thirteen stumbles filed from
the recording (TASK-039..058, #1743) and the evidence paths.

#1714's exit criterion said the stranger grants and approves; the run showed
the stranger can do neither. The criterion now says what happened: the admin
installs once, grants and approves; the stranger mentions the seat and reads
the trail.

tools-catalogue-room-grants.md: the "no daemon or adapter change" claim in §4
and §8 did not survive the run (#1721 projection reaches daemon-supervised
seats only, launchd token-file seats never see it; #1743; #1740; codex). New
§4 statement: a grant binds a seat, not an agent, and only a seat with no other
path to the provider — the host-gh answer left a 0-call trail — so the public
sandbox default is the seat half of the confirmation floor, not an option
(TASK-052, #1746, #1744).

Eight stranger-view captures at 1200 under docs/design/evidence/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(plans): C4 record — the trail capture shows the trail; two captures renamed for what they show

Vera (69588): the three captures named for trail states were the same Tools
viewport with the trail below the fold. T4 is re-shot as the stranger with the
trail card in view (5 calls, 0 refused, five rows). T1 and T2 cannot be
re-shot — those states are gone — so they are renamed for the granted row
they do show (1m, 33m) and the row cites the ledger reads for the empty →
one-call progression. #1746 is now merged (48a5ea9); the row says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(plans): C4 record — the ledger reads carry the progression by row timestamp, and the T4 capture is dated by its own row

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Carry re-gate at 731ccae0: carries; still passes.

src patch-id b08c234f over cli/src + cli/__tests__ is identical to my 34ef944d gate; the only other change is the version bump 0.1.50 → 0.1.53. So this is the same code I measured, and every finding there stands:

  • revert the guard (.filter((command) => isAbsolute(command))): 1 red — the new test, the exact isAbsolute(undefined) crash c4-smoke hit.
  • drop isAbsolute, keeping only the string check: green. That half is pre-existing and unpinned; one assertion that a relative command[0] stays out of the Seatbelt allow-list would close it. Not this PR.

The guard only ever skips entries: the allow-list still holds exactly the stdio servers with an absolute command[0], and an HTTP entry (which has no executable) is no longer handed to isAbsolute.

samxu01 pushed a commit that referenced this pull request Sep 19, 2026
pi ships no MCP support (its README: "No MCP. … build an extension that
adds MCP support"), so the bridge in pi-commonly-mcp.mjs is a pi seat's
entire transport story. Two independent filters dropped every
`environment.mcp` entry that carried a `url` rather than a `command`
(pi.js resolveMcpServers, pi-mcp-client.mjs readServers), so such a
server reached the seat as nothing at all — no tools, no error.

That is exactly the shape of the grant broker: agentBinding.ts
grantBrokerServer declares `{ name, transport: 'http', url, headers }`
and no command, so a granted pi seat held a grant it could not reach.

Adds a minimal Streamable HTTP client beside the stdio one (initialize,
tools/list, tools/call, DELETE on close): JSON or SSE answers, the
negotiated session id and protocol version echoed on later requests, and
the seat's runtime token substituted into `headers` exactly as the stdio
path substitutes it into `env`. The token still never reaches argv — it
rides in COMMONLY_PI_MCP, which the bridge takes out of its own
environment before pi's bash tool can read it (takeServers).

Confinement is untouched. Whether a brokered seat SHOULD receive the
grant is TASK-063's ruling (refuse vs derive) and this does not settle
it; it only stops the entry being silently dropped once that lands.

cli 0.1.50 -> 0.1.54, above #1754's 0.1.51, #1761's 0.1.52, #1743's 0.1.53.
lilyshen0722 and others added 2 commits September 19, 2026 05:19
…in its environment

The Seatbelt executable allow-list is derived from each MCP server's
`command[0]`. An HTTP entry (the grant broker `agentBinding` appends to
`environment.mcp`) carries a `url` and no `command`, so the map yields
`undefined` and `isAbsolute(undefined)` throws ERR_INVALID_ARG_TYPE before
argv is built. Measured on c4-smoke 2026-09-18: five consecutive respawns,
every confined spawn dying with `The "path" argument must be of type
string. Received undefined`, one unacked event each time.

Guard the filter to strings. The new test spawns a public-workspace seat
with the commonly stdio server and a broker HTTP server side by side and
goes red on main with the production error text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoB8EvqzEZEJoKwqFiBvts
The branch was cut at 23e0066 and never touched cli/package.json, so after the
rebase onto main it carries main's 0.1.50. This PR changes cli/src, so the
version guard requires an INCREASE (equality fails too). 0.1.53 is above every
version currently claimed by an open PR (max 0.1.52) and above main's 0.1.50.

Keeping 0.1.51 is not available: #1754 already proposes it and is newer than
this PR, so this one takes the next free slot instead of forcing that cleared
head to move.
@samxu01
samxu01 force-pushed the fix/claude-public-sandbox-http-mcp branch from 731ccae to 7ff2c94 Compare September 19, 2026 12:19
@lilyshen0722
lilyshen0722 merged commit 260a2fd into main Sep 19, 2026
samxu01 pushed a commit that referenced this pull request Sep 19, 2026
pi ships no MCP support (its README: "No MCP. … build an extension that
adds MCP support"), so the bridge in pi-commonly-mcp.mjs is a pi seat's
entire transport story. Two independent filters dropped every
`environment.mcp` entry that carried a `url` rather than a `command`
(pi.js resolveMcpServers, pi-mcp-client.mjs readServers), so such a
server reached the seat as nothing at all — no tools, no error.

That is exactly the shape of the grant broker: agentBinding.ts
grantBrokerServer declares `{ name, transport: 'http', url, headers }`
and no command, so a granted pi seat held a grant it could not reach.

Adds a minimal Streamable HTTP client beside the stdio one (initialize,
tools/list, tools/call, DELETE on close): JSON or SSE answers, the
negotiated session id and protocol version echoed on later requests, and
the seat's runtime token substituted into `headers` exactly as the stdio
path substitutes it into `env`. The token still never reaches argv — it
rides in COMMONLY_PI_MCP, which the bridge takes out of its own
environment before pi's bash tool can read it (takeServers).

Confinement is untouched. Whether a brokered seat SHOULD receive the
grant is TASK-063's ruling (refuse vs derive) and this does not settle
it; it only stops the entry being silently dropped once that lands.

cli 0.1.50 -> 0.1.54, above #1754's 0.1.51, #1761's 0.1.52, #1743's 0.1.53.
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
pi ships no MCP support (its README: "No MCP. … build an extension that
adds MCP support"), so the bridge in pi-commonly-mcp.mjs is a pi seat's
entire transport story. Two independent filters dropped every
`environment.mcp` entry that carried a `url` rather than a `command`
(pi.js resolveMcpServers, pi-mcp-client.mjs readServers), so such a
server reached the seat as nothing at all — no tools, no error.

That is exactly the shape of the grant broker: agentBinding.ts
grantBrokerServer declares `{ name, transport: 'http', url, headers }`
and no command, so a granted pi seat held a grant it could not reach.

Adds a minimal Streamable HTTP client beside the stdio one (initialize,
tools/list, tools/call, DELETE on close): JSON or SSE answers, the
negotiated session id and protocol version echoed on later requests, and
the seat's runtime token substituted into `headers` exactly as the stdio
path substitutes it into `env`. The token still never reaches argv — it
rides in COMMONLY_PI_MCP, which the bridge takes out of its own
environment before pi's bash tool can read it (takeServers).

Confinement is untouched. Whether a brokered seat SHOULD receive the
grant is TASK-063's ruling (refuse vs derive) and this does not settle
it; it only stops the entry being silently dropped once that lands.

cli 0.1.50 -> 0.1.54, above #1754's 0.1.51, #1761's 0.1.52, #1743's 0.1.53.
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
pi ships no MCP support (its README: "No MCP. … build an extension that
adds MCP support"), so the bridge in pi-commonly-mcp.mjs is a pi seat's
entire transport story. Two independent filters dropped every
`environment.mcp` entry that carried a `url` rather than a `command`
(pi.js resolveMcpServers, pi-mcp-client.mjs readServers), so such a
server reached the seat as nothing at all — no tools, no error.

That is exactly the shape of the grant broker: agentBinding.ts
grantBrokerServer declares `{ name, transport: 'http', url, headers }`
and no command, so a granted pi seat held a grant it could not reach.

Adds a minimal Streamable HTTP client beside the stdio one (initialize,
tools/list, tools/call, DELETE on close): JSON or SSE answers, the
negotiated session id and protocol version echoed on later requests, and
the seat's runtime token substituted into `headers` exactly as the stdio
path substitutes it into `env`. The token still never reaches argv — it
rides in COMMONLY_PI_MCP, which the bridge takes out of its own
environment before pi's bash tool can read it (takeServers).

Confinement is untouched. Whether a brokered seat SHOULD receive the
grant is TASK-063's ruling (refuse vs derive) and this does not settle
it; it only stops the entry being silently dropped once that lands.

cli 0.1.50 -> 0.1.54, above #1754's 0.1.51, #1761's 0.1.52, #1743's 0.1.53.
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…des an entry's shape, and a granted pi seat is never given the broker (TASK-054, TASK-063 daemon half) (#1764)

* feat(cli): pi adapter connects to Streamable HTTP MCP servers

pi ships no MCP support (its README: "No MCP. … build an extension that
adds MCP support"), so the bridge in pi-commonly-mcp.mjs is a pi seat's
entire transport story. Two independent filters dropped every
`environment.mcp` entry that carried a `url` rather than a `command`
(pi.js resolveMcpServers, pi-mcp-client.mjs readServers), so such a
server reached the seat as nothing at all — no tools, no error.

That is exactly the shape of the grant broker: agentBinding.ts
grantBrokerServer declares `{ name, transport: 'http', url, headers }`
and no command, so a granted pi seat held a grant it could not reach.

Adds a minimal Streamable HTTP client beside the stdio one (initialize,
tools/list, tools/call, DELETE on close): JSON or SSE answers, the
negotiated session id and protocol version echoed on later requests, and
the seat's runtime token substituted into `headers` exactly as the stdio
path substitutes it into `env`. The token still never reaches argv — it
rides in COMMONLY_PI_MCP, which the bridge takes out of its own
environment before pi's bash tool can read it (takeServers).

Confinement is untouched. Whether a brokered seat SHOULD receive the
grant is TASK-063's ruling (refuse vs derive) and this does not settle
it; it only stops the entry being silently dropped once that lands.

cli 0.1.50 -> 0.1.54, above #1754's 0.1.51, #1761's 0.1.52, #1743's 0.1.53.

* fix(cli): a declared MCP transport decides an entry's shape, so a command cannot ride an http entry

The daemon's guard classifies a declared server by `transport` and judges only
the field that transport needs; the pi and codex adapters classified by which
field was PRESENT. An entry declaring `transport: 'http'` whose url is the
instance's own therefore passed the guard — its http rule checks only the url
origin, and `${COMMONLY_AGENT_TOKEN}` in a command is one of the known
placeholders — and was then emitted as a stdio command with the seat's real
token substituted: `sh -c` running as the operator (Vera, Connectors 69774).

pi.js resolveMcpServers now applies the transport (falling back to the present
field only when the declaration names none) and emits exactly one shape per
entry, never carrying the unselected field; a transport pi cannot speak is
refused with a warning rather than reinterpreted as stdio. pi-mcp-client's
isStdioServer/isHttpServer are mutually exclusive and readServers drops a
both-field entry, so the last layer before spawn agrees with the first.
codex.js skips any entry that did not declare stdio, command or not.

cli 0.1.54 -> 0.1.55

* fix(cli): pi reads `transport` exactly as the guard does, and refuses an off-instance http url itself

Vera's clearance (Connectors 69776) named what the first commit left open: pi
carried a url-only entry with no transport, or with `'HTTP'`, off-instance, and
only the guard refused those — and the guard is a separate, unlanded PR. Three
fixes, all narrowing, so the adapter no longer depends on a layer that may not
be on the machine:

- The transport is read exactly as `auditDeclaredMcp` reads it — same field,
  same `|| 'stdio'` default, same exact string comparison. Normalising `'HTTP'`
  or `' http '` into a transport the guard calls unknown would run a server the
  guard refused, which is the disagreement in the unsafe direction.
- No presence fallback: an absent transport is judged stdio, so a url-only
  record with no transport is dropped (the guard refuses it as a stdio entry
  with no command, so the daemon never adopts it) — with a warning naming it,
  because the silence was the original defect.
- A declared http server is admitted only when its url resolves to the
  instance's own origin, enforced here as well as in the guard, because the seat
  token rides its headers. Off-instance, lookalike-host, unparseable, and
  unknown-instance all fail closed.

Six mutations, each reddening exactly its own test, including origin-compared-by-
prefix and fail-open-when-instance-unknown. cli suite 549 passed / 10 skipped /
0 failed.

* fix(cli): the pi HTTP client sends the negotiated version to a stateless broker, follows no redirect, and stops claiming the env channel is secret

Three items from Vera's review of #1764, all measured at this head:

- The version header was gated on the session id. Our own broker is stateless
  (mcpGrants.ts sets `sessionIdGenerator: undefined`), so it never mints one and
  the only broker a pi seat actually talks to never received the version it had
  just negotiated. It now follows negotiation, not the session. The fake server
  could not see this either -- it always answered with a session id -- so it
  gained a stateless mode, which is the shape production returns.
- No redirect is followed. Measured with undici here: a 307 was followed to a
  second local origin, the Authorization header was stripped (auth: null on the
  hop, which is why this is not a bearer leak), and the request BODY was
  forwarded verbatim -- the second origin saw `tools/call` with its arguments
  and its JSON-RPC answer was accepted as the reply. `redirect: 'error'` closes
  that, and the test asserts the second origin saw nothing at all.
- The claim that the server list is "never left where pi's bash tool could print
  it" was false and is corrected in all three places it was written. `delete
  env.COMMONLY_PI_MCP` scrubs Node's copy, not the kernel's, so a same-user
  child can still read the parent's environment via `ps eww $PPID` or
  /proc/$PPID/environ. The code comment now states the limit; the durable fix
  (a 0600 file the bridge unlinks on load) is filed as its own row.

Also pins Vera's surviving mutation (a later response overwriting the session
id), and narrows the "does not depend on a guard" comment to the http half:
a declared stdio command is still executed with no allowlist check here or in
either sibling adapter, so that half depends entirely on #1744's stdio rule.

Four mutations, each reddening exactly its own test: session-gated version
header, overwritable session id, followed redirect, negotiation-before-answer.
cli suite 552 passed / 10 skipped / 0 failed.

* fix(cli): a granted pi seat is never given the grant broker (TASK-063 daemon-side refusal)

* fix(cli): the pi broker refusal states the server's reason string (TASK-063)

wren 69829, on the entry-level ruling: "Same reason string on both
halves." The daemon warning now carries the server's typed refusal
verbatim, so a log line and the grant read's `grantBrokerRefusal` can be
read side by side.

Mirrored, not imported — the cli package does not depend on the backend —
so the literal is pinned by a test: rewording the daemon warning now fails
a test instead of quietly drifting from the field a human reads.

* fix(cli): the pi broker refusal reads the path the way the router does (TASK-063)

Vera measured the hole at 805e92d (69839): isGrantBrokerUrl compared the
path case-sensitively while the live API serves the uppercased spelling
from the same route, so pi was handed /API/MCP/GRANTS/<id> with the real
runtime token attached.

The route it mirrors is case-insensitive because express matches paths
that way by default. Re-probed the other spellings while fixing it: only
case is forgiven — %67rants, //, ./ and GRANTS%2F all 404 on the live API,
and a trailing slash is already inside the prefix.

* chore(cli): keep the em dash literal in the package description

The branch's second commit re-encoded the `description` em dash as the
JSON escape `\u2014`. Identical after parse, but it would land as a
byte-level change to `cli/package.json` in main for no reason, and it made
this PR's patch to that file carry two lines instead of one.

Restored to the literal character, so the file's delta against main is the
version line alone. Sam 69962 asked for it; Vera 69966 said she would carry
a one-line package.json change.
samxu01 pushed a commit that referenced this pull request Sep 19, 2026
… a url; cli 0.1.55

The adapters classified an mcp entry by which field was present, so an
entry with `transport: 'http'`, the instance's own url and a `command`
passed the origin check here and ran as stdio in pi/codex with the token
substituted (measured on the PR, closed adapter-side in #1764). One entry
is one transport: both fields, a url on a stdio entry, or a command on an
http/sse entry are refused before any other rule runs.

Version 0.1.55: this PR lands after the fleet's ascending chain (#1754
0.1.51 → #1761 0.1.52 → #1743 0.1.53 → #1764 0.1.54).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7WB68pA8L2nkvSqZPx37w
samxu01 pushed a commit that referenced this pull request Sep 19, 2026
… a url; cli 0.1.55

The adapters classified an mcp entry by which field was present, so an
entry with `transport: 'http'`, the instance's own url and a `command`
passed the origin check here and ran as stdio in pi/codex with the token
substituted (measured on the PR, closed adapter-side in #1764). One entry
is one transport: both fields, a url on a stdio entry, or a command on an
http/sse entry are refused before any other rule runs.

Version 0.1.55: this PR lands after the fleet's ascending chain (#1754
0.1.51 → #1761 0.1.52 → #1743 0.1.53 → #1764 0.1.54).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7WB68pA8L2nkvSqZPx37w
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…foreign command or ship the seat token off-instance (#1744)

* fix(cli): the daemon refuses a declared environment that would run a foreign command or ship the seat token off-instance

The daemon projects `AgentInstallation.config.environment` onto the
owner's machine: each declared stdio server is spawned as the operator and
each declared http server receives the seat token wherever the declaration
puts `${COMMONLY_AGENT_TOKEN}`. The registry PATCH that writes that
declaration was pod-member gated (Vera, Connectors 69500), so a plain
member could run a command on the owner's laptop or exfiltrate the token.
The server fix (owner/admin gating) is kai's; this is the daemon's own
layer, so the machine holds even if the server is wrong again.

`declared-mcp-guard.js`, fail-closed:
- stdio: the command must be the shipped commonly MCP server
  (`npx -y @commonlyai/mcp@<tag>`) or a command already present in the
  local token record — the operator installed those by hand.
- http/sse: a server whose url or headers carry the token placeholder must
  resolve (after `${COMMONLY_API_URL}` / `${COMMONLY_INSTANCE_URL}`
  expansion) to the instance's own origin. The grant broker passes;
  anything else keeps the token. A server without the placeholder may
  point anywhere.

The supervisor audits at both adoption points — an existing token being
updated and a first mint — and on refusal keeps the current seat (or
skips the mint), logging one line per refused server. It never adopts a
partial environment.

Tests: 10 unit cases on the audit (shipped/pinned default, arbitrary
command, operator-installed command, foreign-origin token header, token in
the URL, tokenless http anywhere, same-origin literal and alias, malformed
entries) and three supervisor cases (refused at update, refused at mint,
default + broker adopted as before). Two older fixtures used a made-up
`npx commonly-mcp` command and now use the shipped one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoB8EvqzEZEJoKwqFiBvts

* fix(cli): the declared-mcp guard is origin-based; any foreign expansion is refused outright

Vera measured (Connectors 69519) that a placeholder-matching guard is
bypassed: the claude CLI expands `${VAR}` and `${VAR:-default}` in url and
headers from its own environment, so `?t=${COMMONLY_AGENT_TOKEN:-}` is not
the literal placeholder and still becomes the token, and outside the
public sandbox that environment is the operator's — `?k=${GITHUB_TOKEN}`
leaks too. Her listener received the token, the Bearer and a foreign var.

Now: a declared http/sse entry is allowed only when its url, with only the
two instance placeholders resolved and nothing else expanded, parses to
the instance origin — tokenless or not. Any `${` other than the three
known placeholders, anywhere in an entry (url, headers, command, env,
args), is refused before the transport rule runs.

Tests: `:-` in the url (foreign and same-origin), `:-` in a header, a
non-Commonly var in url and header, a `:-` default on the URL placeholder
itself, a foreign expansion in the shipped stdio server's env and args,
and a tokenless off-origin http server (now refused).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoB8EvqzEZEJoKwqFiBvts

* chore(cli): 0.1.49 — daemon declared-environment guard

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoB8EvqzEZEJoKwqFiBvts

* fix(cli): the stdio rule is whole-entry — the shipped command with a foreign env is refused

Sprint-review measured (Sharpen 69526/69534) that a command-only rule
admits three payloads on the shipped `npx -y @commonlyai/mcp@latest`:
`NODE_OPTIONS=--import=data:…` (code execution in the npx child),
`npm_config_registry` pointed at an attacker (the package itself comes
from them), and a literal `COMMONLY_API_URL=https://attacker…` (the MCP
server posts the seat token there). None needs a `${` expansion, so the
origin rule never saw them.

Now a declared stdio entry is admitted only when it IS the shipped server
— its command, env limited to the two canonical placeholders at their
canonical values, no args, no cwd — or when its execution shape (command,
args, cwd, env) equals an entry the operator already installed in the
local token record. The refusal names the offending env keys.

Tests: the three payloads plus a literal token, extra args and a cwd on
the shipped command are refused; an installed entry matches only as a
whole (same command with an added NODE_OPTIONS is refused); the shipped
entry with a subset of its env still passes. Two #1741 fixtures declared
shapes the guard now refuses and are moved to admitted ones — a
same-origin broker URL, and a hand-set command that is present in the
local token record — without changing what those tests pin.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7WB68pA8L2nkvSqZPx37w

* fix(cli): the guard refuses an entry that declares both a command and a url; cli 0.1.55

The adapters classified an mcp entry by which field was present, so an
entry with `transport: 'http'`, the instance's own url and a `command`
passed the origin check here and ran as stdio in pi/codex with the token
substituted (measured on the PR, closed adapter-side in #1764). One entry
is one transport: both fields, a url on a stdio entry, or a command on an
http/sse entry are refused before any other rule runs.

Version 0.1.55: this PR lands after the fleet's ascending chain (#1754
0.1.51 → #1761 0.1.52 → #1743 0.1.53 → #1764 0.1.54).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7WB68pA8L2nkvSqZPx37w

* test(cli): TASK-065 fixture declares the shipped commonly server, which the guard admits

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7WB68pA8L2nkvSqZPx37w

---------

Co-authored-by: Claude Fable 5.1 <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.

1 participant