fix(cli): a daemon-provisioned seat is never born unconfined — seat sandbox baseline (TASK-052) + refuse an unread trust value (TASK-059) - #1754
Conversation
|
Merge-order note (measured, not inferred) — This PR, #1761 and #1744 each edit Cheapest order: land this first, then #1761 rebases and resolves the line to 0.1.52 — which still passes the guard, because 0.1.52 ≥ main's 0.1.51. Reversed order costs more: if #1761 lands first, main becomes 0.1.52 and this PR's 0.1.51 is below it, so it must be re-bumped to 0.1.53 rather than merely re-resolved. In either order the second PR's head moves, and a head move does not carry the existing clearance — re-ping Vera. Two clean pairs worth knowing: #1750 + #1757 auto-merge, and #1751 + either of them auto-merges. |
|
Correction to my own note above, and it is worth reading because the method that produced it was wrong. I wrote "#1744 + #1760 is clean too". That came from running the simulation as Re-run with the natural merge base (no
Forced-base is right for the question "can both land from today's main?" only when neither branch is behind main; the natural base is right for reality. |
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.
|
Coupling worth knowing before these land, measured after the note above. This PR is cleared at
They compose textually (clean One ordering consideration, stated without overstating it: this PR widens the set of seats that are confined by declaration, and confinement is what triggers the crash — so if a daemon-derived seat with a broker HTTP MCP entry exists, this PR is what turns it from "works, unconfined" into "dies at spawn". I could not measure that population (no cluster access here); what I can say is that the one record known to declare the grant broker already declares a public sandbox, so such a seat is already exposed on |
|
Merge-queue facts, measured just now, because this is the first of four Nothing technical is blocking any of them. Two consequences of
So: land them in ascending version order, and each later one resolves by keeping its own version line. The guard's "must increase" rule then stays green and nobody re-bumps. Any other order leaves a lower version under main and the guard fails on that PR with its own message ("version is still X"), which is fixable only by its author bumping again — that is the entire reason the order matters. The other eleven are docs/tests/frontend only — no version line, no cross-PR conflict: #1750 #1752 #1753 #1755 #1756 #1757 #1758 #1759 #1760 #1762 #1763. |
… 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
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.
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.
3768fa2 to
e4a16ae
Compare
The self-serve install ships no environment, the daemon projected that verbatim, and the seat spawned with no sandbox — `sandbox.mode` defaults to 'none' in the adapters, so an absent block means NO confinement while the seat takes instructions from everyone in the room (C4-6). The c4 smoke seat had to be hand-confined through a row PATCH. The baseline is now both halves: the kernel MCP server (TASK-048, #1741) and an enforced sandbox. The stored block is `sandbox: { trust: 'public' }` and carries NO mode, because the record is platform-independent and the host is not — `mode: 'workspace'` is Seatbelt on macOS and is refused on Linux, `mode: 'bwrap'` is meaningless on macOS, so either one moves a host fact into the database and breaks the day the seat is re-homed (Wren 69545). The mode is resolved at spawn instead, in cli/src/lib/sandbox/mode.js: darwin → 'workspace' (Seatbelt), else → 'bwrap'; an explicit mode in a record still wins. That resolution had to land in the same change, or the baseline would be worse than the hole it closes. Measured on main: `{ trust: 'public' }` with no mode did NOT engage anything — `publicNativeSandbox` required a mode in {workspace, read-only} and the "public requires an enforced mode" throw only fired on the literal 'none', so an absent mode fell straight through to a bare, unconfined `claude` spawn, while the record claimed confinement. So the claude adapter now resolves the mode and REFUSES any public trust that does not end up enforced, and the codex adapter (whose mode is read-vs-write access, not a host mechanism) defaults an absent mode to 'workspace' rather than throwing `got unset` — which would have made every derived codex seat unspawnable. Which environments get it is a trust boundary: environments the daemon DERIVED — a server declaration, or a seat nobody has authored anything for (fresh mint, a record with no environment, the TASK-048 heal path). An operator-authored local environment keeps its own choice: a private-pod record with no sandbox is what `agent attach` permits, and silently confining it would break seats that deliberately run with host reach. `withDefaultSandbox` replaces an explicitly disengaged `mode: 'none'` rather than honouring it, because on this path 'none' and absent are the same thing to the spawn. `assertSandboxDeclaredForPublicPod` now accepts the same shapes the daemon writes (a public trust, or an explicit non-'none' mode), so its own baseline is not a shape it would refuse on a public pod; attach still validates the resolved mode's host mechanism (Seatbelt present / bwrap installed / codex version). Tests: 6 new in default-environment.test.mjs (including "stores NO mode"), 5 in daemon-supervisor.test.mjs, 3 in adapters.claude.environment.test.mjs (mode-less public resolves to Seatbelt on darwin and bwrap elsewhere; an unresolvable explicit mode refuses), 1 in adapters.codex.test.mjs, 2 in public-pod-sandbox-gate.test.mjs. Two merged tests pinned behaviour this change deliberately inverts: the C4-2 baseline assertion from #1741 asserted the mcp-only baseline, and codex's "public trust fails closed when no mode is declared" asserted the throw that would have stopped every derived codex seat. Cli suite 39 suites / 550 passed / 10 skipped; lint:cli clean. Verified on the real writers (real supervisor + real saveAgentToken / loadAgentToken, temp HOME): the self-serve row shape writes {sandbox:{trust: 'public'}, mcp:[commonly], model} and the hand-confined c4-smoke shape is byte-preserved with no rewrite on the next tick. NOT verified by running on a Linux host — the Linux path is asserted with a mocked process.platform plus the adapter's own bwrap branch. Confinement holds for claude and codex. A pi seat gets the block and is NOT confined by it: the pi adapter has no sandbox path until #1740 gives it one, so do not read this key on a pi record as confinement. Cli version 0.1.48 → 0.1.49 (main took 0.1.48 with #1726; Wave's #1744 slots this in the cli chain). Not a guard: nothing is refused and no seat is stopped — an installed seat gains the confinement on its next daemon tick. Refusing a declared command or a foreign-origin token address is layer 2, Wave's #1744.
… one (TASK-052) Wren 69586 + Vera 69578, folded in before the PR opens. "public" was one contract implemented twice, and the Linux half was half of it. `buildPublicClaudePolicyArgs` — settings off, permission mode dontAsk, the denied reads and tools, strict MCP config — was only reachable inside the Seatbelt branch, so a derived Linux seat ran inside bwrap with Bash, Write and WebFetch intact and the operator's own Claude settings loaded. The jail bounds the filesystem and the policy bounds the tools; a jail alone is half a floor. Now a public trust that resolves to bwrap gets the same policy args inside the namespace, and a bwrap seat with no public trust still gets only the jail — the policy is the public contract, not a property of wrapping. Asserted both ways. The mechanism is also verified where the mode is chosen: a public seat on a host without bwrap now fails its derivation with an actionable message (`bwrap not found on PATH. Install bubblewrap: ...`) instead of every spawn failing with a bare ENOENT. On macOS it reports the macOS-only message, the same one the wrapper would have thrown later. `ctx._detectBwrap` is a test seam, alongside the existing `_spawnImpl`. Tests: the derived-Linux test now asserts the policy args are inside the namespace, and two cases cover a host with no bwrap (refused, and nothing spawned) and a trust-less bwrap seat (no policy args). cli 39 suites / 553 passed / 10 skipped, lint clean.
… resolves toward confinement (TASK-059) Wren 69585, on Vera's 69592 premise correction, folded into this PR because the runtime half of it IS this PR's resolver: `internal` resolves as `public`, and a public declaration is then confined or refuses to derive. Landing the enum change alone would have made a legacy record *look* public while still falling through to the bare spawn. Two halves: 1. Validation refuses `trust: 'internal'` for new declarations, and the message names what does something — `public`, or omitting the field for your own seat. The value was in ALLOWED_SANDBOX_TRUST and read by no adapter: on the attach path it could only name a mode the platform cannot resolve, and on the daemon path it was silently inert — claude fell through to an unconfined spawn and codex took `--dangerously-bypass-approvals-and-sandbox`. A declaration that reads as "confine me, not as a public agent" meant the opposite of what it said. 2. A record that already carries it is resolved TOWARD confinement (`normalizeSandboxTrust`/`isLegacySandboxTrust` in environment.js), with one line in the spawn log saying what was read and why. On macOS a legacy internal+workspace seat now gets Seatbelt; on Linux that same record refuses to derive rather than running bare, and codex gets the public permission profile instead of the bypass flag. Nothing resolves toward the bare spawn. The claude adapter resolves it ONCE at the top of spawn and threads it to both consumers, because my first cut normalized inside the argv builder only, and the seat then died with "public Claude state was not prepared before argv construction" — the state preparation and the argv builder had read the same declaration independently. That is the bug this whole change is about, in miniature. Tests: validation rejects `internal` and accepts `public` or an omitted field; the legacy resolution is unit-tested (and leaves every other declaration, and an absent sandbox block, untouched); claude confines a legacy internal+workspace seat under Seatbelt; codex gives it the public permission profile and never the bypass flag. cli 39 suites / 559 passed / 10 skipped, lint clean.
… (TASK-052) The sandbox half of the daemon baseline was written for every adapter that consumes mcp[] — claude, codex AND pi — because it piggybacked on ADAPTERS_WITH_DEFAULT_MCP. pi cannot enforce a sandbox, and since #1727 `sandbox.trust: 'public'` makes it fail closed BEFORE pi starts (`assertNoSandboxDeclared`, adapters/pi.js). So the default did not merely fail to confine a pi seat: it made every daemon-derived pi seat unspawnable, with `pi adapter: public-trust seats are not supported`. The seat would have been born dead rather than born unconfined, which is the failure that hides best — the daemon writes the record, the spawn throws, and the record looks correct. The predicate was the bug: "consumes mcp[]" is not "can enforce a sandbox". `ADAPTERS_WITH_DEFAULT_SANDBOX` (claude, codex) is now the set the sandbox half uses, so a pi seat gets the mcp half only, exactly as it did before this branch. The stale claim in the doc comment ("a pi seat gets the block and is not confined by it") was the opposite of the truth and is replaced with the mechanism. The C4-2 fixture in daemon-supervisor.test.mjs is a pi row and pinned the buggy both-halves shape; it now asserts the mcp half and runs the derived value through pi's own guard, because the invariant is not "no sandbox key" but "the spec this adapter receives is one this adapter accepts". Mutation-proven: reverting the guard to ADAPTERS_WITH_DEFAULT_MCP reds exactly two tests, the new seatBaseline case and that fixture. cli 39 suites / 567 passed / 10 skipped, lint:cli clean. Residual, stated rather than implied: a derived pi seat still runs unconfined, because refusing it is a policy choice (fail closed, as #1727 does when a sandbox IS declared) and not something this default gets to decide by writing a declaration pi rejects. Raised with the pod rather than settled here.
e4a16ae to
70ded04
Compare
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.
…in its environment (#1743) * fix(cli): a public-sandboxed claude seat survives an HTTP MCP server 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 * chore(cli): 0.1.51 -> 0.1.53 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. --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.
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.
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.
…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.
… 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
… 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
…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>
…onsumer accepts Adds a section + rule 26 at the end of the file, per the doc's numbering norm (append to the tail; rule numbers ascend in document order and are cited by number elsewhere, so nothing above is renumbered). The rule is earned by #1754/TASK-052, where four instances of one family all surfaced from running the consumer rather than inspecting the derived shape: the predicate was ADAPTERS_WITH_DEFAULT_MCP ("consumes mcp[]"), which is not the set that can enforce a sandbox. Two of the four were fatal-and-silent — codex's throw and pi's assertNoSandboxDeclared would each have made every daemon-derived seat of that adapter unspawnable while the stored record looked correct. No version bump: docs only.
…onsumer accepts (#1755) Adds a section + rule 26 at the end of the file, per the doc's numbering norm (append to the tail; rule numbers ascend in document order and are cited by number elsewhere, so nothing above is renumbered). The rule is earned by #1754/TASK-052, where four instances of one family all surfaced from running the consumer rather than inspecting the derived shape: the predicate was ADAPTERS_WITH_DEFAULT_MCP ("consumes mcp[]"), which is not the set that can enforce a sandbox. Two of the four were fatal-and-silent — codex's throw and pi's assertNoSandboxDeclared would each have made every daemon-derived seat of that adapter unspawnable while the stored record looked correct. No version bump: docs only.
What this closes (C4-6 / TASK-052, with TASK-059 folded in)
A self-serve wrapper install ships no environment at all. The daemon projected that verbatim (
environmentFor(row)→null), and the seat spawned with no sandbox —sandbox.modedefaults to'none'in the adapters, so an absent block means no confinement while the seat takes instructions from everyone in the room. The c4 smoke seat had to be hand-confined through a row PATCH to get the sandboxagent attachwould have refused to skip for a public pod.The baseline is now both halves: the kernel MCP server (TASK-048, #1741) and an enforced sandbox.
Three commits, one policy: a sandbox declaration must never engage nothing — not the derived default, not the declared trust enum, not half of "public".
The stored shape carries no mode
Deliberately mode-less. The record is platform-independent and the host is not:
mode: 'workspace'maps to Seatbelt on macOS and is refused on Linux, whilemode: 'bwrap'is meaningless on macOS. Writing either into the row moves a host fact into the database and breaks the day the seat is re-homed (Wren 69545). The mode is resolved at spawn in the newcli/src/lib/sandbox/mode.js: darwin →workspace(Seatbelt), else →bwrap, and an explicit mode in a record still wins."public" is one contract, on both platforms
buildPublicClaudePolicyArgs—--setting-sources '',--disable-slash-commands,--strict-mcp-config,--no-chrome,--permission-mode dontAsk,--allowedTools Read(./**)plus the mcp patterns,--disallowedToolswith the denied reads — was only reachable inside the Seatbelt branch. So a derived Linux seat ran inside bwrap with Bash, Write and WebFetch intact and on the operator's own Claude settings: the jail bounded the filesystem and nothing bounded the tools (Vera 69578, Wren 69586).Now a public trust that resolves to bwrap gets the same policy args inside the namespace, and a bwrap seat with no public trust still gets only the jail — asserted in both directions, so the policy is the public contract and not a property of wrapping.
The mechanism is also checked where the mode is chosen: a public seat on a host without bwrap now fails its derivation with
bwrap not found on PATH. Install bubblewrap: …instead of every spawn failing with a bare ENOENT. On macOS it reports the same macOS-only message the wrapper would have thrown later, so no existing message changed. (ctx._detectBwrapis a test seam, alongside the existing_spawnImpl.)Mechanism per platform, stated rather than implied: darwin →
/usr/bin/sandbox-execwith the policy args and the isolated per-identityHOME; linux →bwrap(--unshare-all,--die-with-parent) with the policy args inside the namespace and no isolatedHOME— claude's Linux credentials live inHOME, so isolating it would break auth, and--setting-sources ''is what protects there.Why the adapter changes are in this PR and not a follow-up
Measured on the shape alone:
{ trust: 'public' }with no mode engaged nothing.publicNativeSandboxrequired a mode in{workspace, read-only}, the "public requires an enforced mode" throw only fired on the literal'none', and an absent mode fell straight through to the bareunconfinedclaudespawn — a record claiming confinement it never applied (Vera 69548). Shipping the stored default without the adapter change would have been worse than the hole it closes.So, in the same PR:
'none', a typo, a non-string) instead of falling through.workspace. Itsmodeis read-vs-write access, not a host mechanism — its permission profiles run on both macOS and Linux — so it needs no platform branch. Before this, a mode-less public record threwgot unsetand no derived codex seat could spawn at all.assertSandboxDeclaredForPublicPodnow uses the same predicate as the daemon (a public trust, or an explicit non-'none'mode), so the baseline the daemon writes is not a shape attach would refuse on a public pod. Attach still validates the resolved mode's host mechanism: Seatbelt present, bwrap installed, codex version. A side effect worth naming:mode: 'bwrap'without a trust field is now accepted on a public pod — the old predicate required trust and mode, so a genuinely confined bwrap seat was being refused.TASK-059, folded in: a trust value no adapter reads (Wren 69585)
sandbox.trust: 'internal'was in the allowed set and read by no adapter: on the daemon path claude fell through to a bare unconfined spawn and codex took--dangerously-bypass-approvals-and-sandbox, so a declaration that reads as "confine me, not as a public agent" meant the opposite of what it said (Vera 69592, correcting my own filed premise — it was refused on the attach path, andbwrap.jsreads the network/filesystem sub-blocks; the defect is the asymmetry).ALLOWED_SANDBOX_TRUSTis now just['public'], and the message names what does something:sandbox.trust must be 'public' — it marks a seat anyone in the pod can talk to; omit it for your own seat.normalizeSandboxTrustreadsinternalaspublic, with one line in the spawn log saying what was read and why. The alternative — landing the enum change alone — would have left a record that looks public while still falling through to the bare spawn: the same defect wearing a better label. Its runtime half is this PR's resolver.'internal'fromALLOWED_SANDBOX_TRUSTmeansvalidateEnvironmentSpecnow refuses a declaration it previously accepted on the attach path. No test pinned the old acceptance, so this is a contract change sitting in a two-line constant diff with no red test pointing a reader at it; it is called out here so it is not inferred from the diff. Blast radius measured by Vera, not reasoned: no live record carriesinternal— the sandbox blocks that exist are 2 dev installations ({trust:'public', mode:'workspace'}and{mode:'none'}) and 2 of 34 local token files, bothtrust:'public'. So the rejection breaks nothing today and the normalization path is currently theoretical.{mode:'bwrap', trust:'internal'}gains the full policy it never had;{mode:'workspace', trust:'internal'}goes from a bare unconfined spawn to Seatbelt on darwin and to a refusal on Linux (public +workspacenames a macOS mechanism); a codex seat with that declaration goes from the bypass flag to the public profile. An operator seat that relied on Bash under aninternaldeclaration loses it and must declarepublicon purpose, or drop the field. A refusal and a stricter jail are both "toward confinement"; nothing here resolves toward the bare spawn.spawn, and threads it to both consumers — the public-state preparation and the argv builder. My first cut normalized in the argv builder only, and the seat died withpublic Claude state was not prepared before argv construction: the two consumers had read one declaration independently. That is this row's own bug reproduced inside its fix.The pi seat: the default was written for an adapter that refuses it (fourth commit)
The sandbox half piggybacked on
ADAPTERS_WITH_DEFAULT_MCP, the set of adapters that consumemcp[]— which is not the set that can enforce a sandbox.piis in the first and not in the second, and since #1727 it throws before starting onsandbox.trust: 'public'(assertNoSandboxDeclared). So the default did not leave a pi seat unconfined, it left it dead: the daemon writes the record, the spawn refuses, and the record looks correct in the meantime.ADAPTERS_WITH_DEFAULT_SANDBOX(claude,codex) is now the predicate, and a pi seat gets themcp[]half exactly as it did before this branch.The stale claim was in the doc comment — "a pi seat gets the block and is NOT confined by it" — which was the opposite of the truth and is replaced by the mechanism. The C4-2 fixture in
daemon-supervisor.test.mjsis a pi row and pinned the buggy both-halves shape; it now asserts the mcp half and runs the derived value through pi's own guard, because the invariant is not "no sandbox key" but "the spec this adapter receives is one this adapter accepts". Mutation-proven: reverting the guard toADAPTERS_WITH_DEFAULT_MCPreds exactly two tests — the newseatBaselinecase and that fixture.This is the fourth thing this PR got wrong the first time and is worth reading as a pattern rather than an incident: each one was found by running the consumer, not the serializer — claude's two consumers of one declaration, codex's throw that would have stopped every derived codex seat, and now pi's refusal. Grepping the readers of a shape before shipping it is the habit; "the tests are green" was true on all four.
The trust boundary
Which environments get the sandbox is the same boundary the daemon already uses: environments it derived —
An operator-authored local environment keeps its own choice. A private-pod record with no sandbox is exactly what
agent attachpermits, and silently confining it would break seats that deliberately run with host reach.withDefaultSandboxreplaces an explicitly disengagedmode: 'none'rather than honouring it, because on this path'none'and absent are the same thing to the spawn.Honest limits
piseat gets themcp[]half only: pi has no sandbox path until cli: the pi adapter keeps only stdio MCP servers, so a pi seat holding a room grant never sees the broker tools #1740 and fails closed on a declared sandbox (fix(cli): pi seats — seat token out of the shell env; unenforceable sandbox fails closed #1727), so writing this default onto a pi record does not leave it merely unconfined — it makes the seat unspawnable. That is fixed in the fourth commit below, and the residual is stated with it.piseat therefore still runs unconfined, and the case that matters is not the operator's machine. It is a pi seat in a room containing humans the owner does not control — precisely c4-smoke at 11:34, the configuration that produced the host-ghbypass with an empty grant trail. Vera's recommendation, adopted here as the shape to file rather than to settle in this PR: keep the exclusion (a declaration pi refuses is strictly worse than none, and it costs the seat's availability to no one's benefit), and treat "derive a pi seat into a room with non-owner humans" as a refusal at derive time — loud, naming the seat and the room — instead of a silent unconfined spawn. That is a policy call for Sam and Wren. Filed as TASK-063; not decided here.process.platform, the adapter's bwrap branch and the_detectBwrapseam. The behaviour of the added policy args inside a real namespace is unmeasured here.Testing
39 suites / 567 passed / 10 skipped;
lint:cliclean.default-environment.test.mjs, including "stores NO mode" and the idempotence that keeps a derived record from being rewritten (and its seat restarted) every tick.daemon-supervisor.test.mjs: fresh seat confined, a declared environment that names no sandbox gains it, the live c4-smoke shape left byte-identical with no rewrite on the next tick, an operator-authored environment keeps its own choice, a record with no environment is confined.adapters.claude.environment.test.mjs: mode-less public resolves to Seatbelt on darwin; a derived Linux seat carries the same policy args inside bwrap; a host with no bwrap refuses to derive and spawns nothing; a trust-less bwrap seat gets the jail with no policy args; an unresolvable explicit mode refuses; a legacytrust:'internal'+workspaceseat is confined under Seatbelt.adapters.codex.test.mjs(derived default, and a legacyinternalseat getting the public profile and never the bypass flag); 2 new inpublic-pod-sandbox-gate.test.mjs.environment.test.mjs(validation refusesinternaland names the two declarations that do something; the legacy resolution, including that it leaves an absent sandbox block alone).Merged tests whose pinned behaviour this change inverts, deliberately — both are in the list because a reader of the diff will find them:
Verified on the real writers, not only injected ones (real supervisor tick, real
saveAgentToken/loadAgentToken, tempHOME): the self-serve row shape writes{sandbox: {trust: 'public'}, mcp: [commonly], model}, and the hand-confined c4-smoke shape is byte-preserved with no rewrite on the following tick.Version
cli0.1.50 → 0.1.51, rebased onto mainac544d5c. The number did not move again for the pi correction: the branch already sits above main and above every in-flight claim, and re-bumping would only re-open a slot somebody else may be holding.The number kept moving and is the reason for the rebase, so here is the chain read off the tree rather than recalled:
d620c25bwas 0.1.47, #1726 took 0.1.48, #1727 (ac544d5c) took 0.1.50 — skipping 0.1.49, which Wave's open #1744 branch holds — and this branch had been cut from48a5ea92at 0.1.49. Sitting on that base meantSource changed ⇒ version bumpedwould fail and this PR would have shipped a version below main's. Rebased ontoac544d5cand renumbered to 0.1.51: above main and above every in-flight claim.Patch identity across the rebase, on the only file where rebasing could matter —
cli/package.json, whose version line conflicts:is empty apart from a blob-index line (the base content differs, so the hash must); the version hunk is the only real change, 0.1.48→0.1.49 becoming 0.1.50→0.1.51.
Overlap with #1744, stated rather than hidden: Wave's PR and this one both touch
cli/src/lib/daemon-supervisor.jsand its test. Different concerns — his is the declared-MCP guard, this is the seat's environment baseline — so they should compose, but whoever lands second rebases into the first and the other should re-read that file rather than assume. I had been holding this PR back so #1744 could take 0.1.49; now that the version question is settled independently (0.1.51 clears both), holding it only hides a conflict, so it is open.