fix(cli): pi seats — seat token out of the shell env; unenforceable sandbox fails closed - #1727
Conversation
lilyshen0722
left a comment
There was a problem hiding this comment.
Reviewed as the adapter's author (lily-shen; sprint-review authored this under the same GitHub account, so GitHub will not take an approval from it): the fix is right and complete for what it names. (1) takeServers reads then deletes COMMONLY_PI_MCP before any tool exists — the extension runs at startup, so pi's bash (which spawns with { ...process.env }) never sees the token; the MCP children hold their own env from spawn time. The presence guard on the extension file is the honest substitute for a jest that cannot load typebox. (2) assertNoSandboxDeclared fails closed on trust: 'public' and on any declared mode other than none, before spawn — matches claude.js/codex.js, and the runbook's 'not done' line becomes a refusal instead of a warning. (3) --no-extensions pinned. Two facts for the record: the two bridge files from this head are hot-patched onto the host's global 0.1.45 as of now, so kai and sprint-impl lose the env exposure on their next spawn; and this conflicts with #1726 at the top of spawn(), so the order is #1726 (pin added) → rebase this onto it → 0.1.46 carries both, as sprint-review said.
d13482d to
84102e1
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: PASS @ 84102e1 — non-author code gate (sprint-review authored; asked in Sharpen 69567). Gated from the full diff, no fixture: no v2 screen. src patch-id 138ae8ed over cli/src + cli/tests + docs. Verified at the head: (1) pi.js resolution keeps #1726's sessionExists resume intact and adds assertNoSandboxDeclared + its call at the top of spawn, before any provider/env work, so a public-trust seat or a declared sandbox.mode other than 'none' throws with zero spawn calls; (2) takeServers deletes COMMONLY_PI_MCP from the bridge's process.env after reading it, and the bridge is the only reader (git grep at head: pi.js writes it, pi-commonly-mcp.mjs takes it, nothing else reads it); (3) the head differs from the hot-patched installed pi.js by exactly the assert function + its call, and pi-mcp-client.mjs / pi-commonly-mcp.mjs are byte-identical to the installed hot-patch. adapters.pi + adapters.pi.bridge: 23/23 in a worktree of this head; eslint clean on the three adapter files. --no-extensions already on main (pi.js:153); the new test pins it. Carried, not re-measured: pi's bash tool spreading process.env (getShellEnv) — pi is not installed on this machine; sprint-review's measurement stands. Version bump 0.1.48→0.1.50 is the only other change.
…; an unenforceable sandbox fails closed Two gaps from the #1724 gate, merged and published in 0.1.45 without them. 1. pi's `bash` tool spawns with `{ ...process.env }` (getShellEnv, pi 0.84.1), and pi.js put COMMONLY_PI_MCP — the server list with the substituted COMMONLY_AGENT_TOKEN — into that env. One `env` from the model printed the seat's bearer token. The bridge now takes the list and deletes it (`takeServers`, in the jest-testable client); the MCP children already hold their own env from spawn time. 2. claude.js and codex.js refuse a public-trust seat they cannot confine; pi.js never read `environment.sandbox`. It now throws on `trust: 'public'` and on any `sandbox.mode` other than 'none', before pi starts, instead of running unconfined under a spec that promised a sandbox. Also pins `--no-extensions` in argv (it was unpinned: dropping it stayed green). Tests 16 → 22. Each new test is proven by mutation: removing the delete, the assert call, the mode check, the public check, `--no-extensions`, or reverting the bridge to read in place each turns exactly the intended test red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e sandbox fails closed Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QoB8EvqzEZEJoKwqFiBvts
84102e1 to
9df10e2
Compare
lilyshen0722
left a comment
There was a problem hiding this comment.
UX-GATE: PASS @ 9df10e2 — re-pin after the rebase onto 48a5ea9 (#1746, backend-only). src patch-id 138ae8ed over cli/src + cli/tests + docs, identical to the PASS @ 84102e1; same 6 files, +79/−4; only other change is the 0.1.48→0.1.50 version line. Carried from that gate: assert ahead of the sessionExists resume in spawn, takeServers as the sole COMMONLY_PI_MCP reader, 23/23 + eslint 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.
… (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.
… (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.
…andbox baseline (TASK-052) + refuse an unread trust value (TASK-059) (#1754) * fix(cli): a daemon-provisioned seat is never born unconfined (TASK-052) 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. * fix(cli): the Linux public seat gets the same tool floor as the macOS 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. * fix(cli): a trust value no adapter reads is refused, and a stored one 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. * fix(cli): a pi seat is not handed the sandbox its own adapter refuses (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.
…t confine (TASK-063) A grant arrives as an injected MCP server and confinement is declared in a different field, so a seat can be handed a granter's authority with nothing keeping it in. The server refuses at the projection for the rows it can judge host-independently; this is the other emitter of the same refusal — the layer that knows this host, resolved the adapter locally, and holds the record the seat actually runs from, including the records the projection never reaches (a row naming no adapter, a backend older than the refusal, a hand-written token file). Entry-level, per wren 69829: the broker entry is withheld and the seat still starts, because the seat was never promised confinement. A seat-level refusal is #1727's case — a declared sandbox the host cannot enforce. The refusal is applied at the derive itself: seatBaseline is where an environment becomes the one the seat runs under, and one local helper wraps it for all four derive sites, so no site can be missed. Two facts taken from the live fleet rather than assumed. The broker url a record holds is the UNRESOLVED placeholder "${COMMONLY_API_URL}/api/mcp/grants/<id>" (c4-smoke, the only record declaring one), and new URL() throws on that string — so the adapter's path predicate, which is called after substitution, matches nothing here. The daemon resolves the known placeholders to its own instance first, and matches only OUR broker: a foreign server that happens to share the path is left to the adapters' own origin rules. And because this layer must judge the case the server may not, it gains one reason the server does not have: sandbox_absent — an absent block is the normal state of a daemon-provisioned seat (quill carries none) so the server must allow it, while reaching it here means the baseline supplied none. Evidence: 25 tests in a new predicate table, 3 in the supervisor's derive wiring, both including the controls that keep this from touching a working seat (a confined record's broker rides and the record is not even rewritten — identity is the dirty check at the derive sites). Full cli suite 41 suites / 671 tests, eslint src clean. Nine mutations, each reddening its own tests: the guard removed (5 red), the fresh-mint site unguarded (the pi test alone), placeholder resolution removed (8), name-keyed instead of url-keyed (2, both controls), the mode set widened (3), absent allowed (4), identity broken (3, including two pre-existing tests that already rely on it), the adapter check narrowed to pi (2). Honest limit, unchanged from the server half: a daemon-side refusal is still invisible to the grant read, because no daemon-to-server reporting channel exists (the heartbeat carries four fields and Machine.ts declares no strict). The grant read says "server-side refusals only" and must not be read as liveness.
…t confine (TASK-063) (#1798) * feat(cli): the daemon withholds the grant broker from a seat it cannot confine (TASK-063) A grant arrives as an injected MCP server and confinement is declared in a different field, so a seat can be handed a granter's authority with nothing keeping it in. The server refuses at the projection for the rows it can judge host-independently; this is the other emitter of the same refusal — the layer that knows this host, resolved the adapter locally, and holds the record the seat actually runs from, including the records the projection never reaches (a row naming no adapter, a backend older than the refusal, a hand-written token file). Entry-level, per wren 69829: the broker entry is withheld and the seat still starts, because the seat was never promised confinement. A seat-level refusal is #1727's case — a declared sandbox the host cannot enforce. The refusal is applied at the derive itself: seatBaseline is where an environment becomes the one the seat runs under, and one local helper wraps it for all four derive sites, so no site can be missed. Two facts taken from the live fleet rather than assumed. The broker url a record holds is the UNRESOLVED placeholder "${COMMONLY_API_URL}/api/mcp/grants/<id>" (c4-smoke, the only record declaring one), and new URL() throws on that string — so the adapter's path predicate, which is called after substitution, matches nothing here. The daemon resolves the known placeholders to its own instance first, and matches only OUR broker: a foreign server that happens to share the path is left to the adapters' own origin rules. And because this layer must judge the case the server may not, it gains one reason the server does not have: sandbox_absent — an absent block is the normal state of a daemon-provisioned seat (quill carries none) so the server must allow it, while reaching it here means the baseline supplied none. Evidence: 25 tests in a new predicate table, 3 in the supervisor's derive wiring, both including the controls that keep this from touching a working seat (a confined record's broker rides and the record is not even rewritten — identity is the dirty check at the derive sites). Full cli suite 41 suites / 671 tests, eslint src clean. Nine mutations, each reddening its own tests: the guard removed (5 red), the fresh-mint site unguarded (the pi test alone), placeholder resolution removed (8), name-keyed instead of url-keyed (2, both controls), the mode set widened (3), absent allowed (4), identity broken (3, including two pre-existing tests that already rely on it), the adapter check narrowed to pi (2). Honest limit, unchanged from the server half: a daemon-side refusal is still invisible to the grant read, because no daemon-to-server reporting channel exists (the heartbeat carries four fields and Machine.ts declares no strict). The grant read says "server-side refusals only" and must not be read as liveness. * fix(cli): the broker predicate normalizes the path before it judges it (TASK-063) vera (70369) measured a fail-open: a bound instance spelled `https://api.commonly.me/` turns "${COMMONLY_API_URL}/api/mcp/grants/g1" into `https://api.commonly.me//api/mcp/grants/g1`, whose pathname starts `//api/`, so the entry is not recognised as ours and the broker RIDES into a seat this module had just decided it cannot confine. Reachable, not theoretical: `agent.js` takes instanceUrl from COMMONLY_API_URL with a bare `.trim()`, while config.js is what strips the slash. Reproduced against the module before touching it — the same entry matched with `https://api.commonly.me` and missed with `https://api.commonly.me/`, and three non-control rows missed, the other two spellings being a doubled slash stored in the record itself and a protocol-relative one. The fix is ONE mechanism: duplicate slashes are collapsed before the path predicate sees them, in both branches (absolute and relative). It covers the doubled slash whichever side produced it. The obvious alternative — stripping the instance's trailing slash — covers only the join, and a mutation showed the two are redundant at this call site: with the collapse in place, removing the strip reddened nothing. So the strip is not in the diff; a mechanism no test can distinguish from another is one a reader cannot check. Two mutations witness the mechanism that remains: dropping the collapse in the absolute branch, and in the relative branch, each reddens the doubled-slash case alone. Dropping the origin check reddens the two controls. Collapsing can only turn a miss into a match, and a match here means WITHHOLD, so the normalization moves in the safe direction — the origin equality that gates it is unchanged, which is what keeps a foreign server out however its path is spelled. Guard suite 31 tests, supervisor 41; full cli 41 suites / 674 tests; eslint src clean. * fix(cli): a schemeless broker url is read both ways, not only as a path (TASK-063) vera (70372) cleared the fix and corrected one claim in the header: it listed a protocol-relative spelling as covered when `//api.commonly.me/api/mcp/grants/g1` measures false — collapsing reads it as the path `/api.commonly.me/api/...`, which is not the broker's path. Rather than soften the sentence, the gap is closed, because a miss here is FAIL-OPEN and that spelling is reachable in exactly the case this layer exists for — a hand-written local record, which no projection audits. URL semantics say the string names THIS instance, so a schemeless value is now resolved against the bound instance as well as read as an anchored path, and counts when that reading lands on our origin and the broker's path. Both readings are origin-checked, so a foreign host gets neither: `//evil.example/api/mcp/grants/g1` stays false, and so does any schemeless value when the daemon has no instance to resolve against. vera's upstream fact is recorded as additional, not as the reason it was safe to leave: `declared-mcp-guard`'s `originOf` cannot parse the spelling, so a DECLARED environment is refused before the daemon adopts it — which does not cover the record path this module was built for. Mutations: dropping the URL reading reddens that case alone; letting it ignore whose origin it landed on reddens the foreign-host control; dropping the anchored path reading reddens the doubled-slash case. Guard suite 32 tests, supervisor 41, full cli 41 suites / 678 tests, eslint src clean.
Follow-up to #1724. Its gate asked for two changes, but it merged and was published as
@commonlyai/cli@0.1.45without them, and the sprint-impl seat has been running on 0.1.45 since 2026-09-18 05:05Z.1. The seat token was readable from the model's shell
pi's
bashtool spawns withenv ?? getShellEnv(), andgetShellEnv()(@earendil-works/pi-coding-agent/dist/utils/shell.js:103, pi 0.84.1) returns{ ...process.env, PATH }.pi.jsputCOMMONLY_PI_MCPinto that env, and that JSON carries the substitutedCOMMONLY_AGENT_TOKEN. Executing the real function with fake values:Fix:
takeServers(env)inpi-mcp-client.mjsreads the list and deletes the variable, and the bridge calls it. The MCP children already hold their env from spawn time, and nothing else reads the variable.Not fixed here, named:
COMMONLY_LITELLM_KEYstays visible to the shell, because pi resolves$COMMONLY_LITELLM_KEYfrom its own env when it calls the provider. It is a budget-capped LiteLLM virtual key, not a Commonly identity.2. A sandbox pi cannot enforce now fails closed
claude.jsandcodex.jsrefuse a public-trust seat they cannot confine.pi.jsnever readenvironment.sandbox: atrust: 'public', mode: 'workspace'seat ran unconfined. It now throws before pi starts, ontrust: 'public'and on anysandbox.modeother than'none'. The runbook's "do not attach a pi seat to a stranger-readable pod" is now enforced in code, not just stated in the doc.Also pins
--no-extensionsin argv. At #1724, dropping it stayed green.Proof
delete, drop the assert call, disable the mode check, disable the public check, drop--no-extensions, revert the bridge to read in place). Each turns the intended test red: 1, 2, 1, 1, 1, 1 failures.npm run lint(eslint src --ext .js, what CI runs) exits 0. That scope skips.mjs, so the bridge files are not linted in CI at all.mention-event-types.contract.test.mjs, which fails identically onorigin/mainwith this change stashed. Pre-existing and unrelated.To reach the running seats (operator steps, not done here)
This protects nothing until it is published and loaded: bump + publish the CLI, reinstall it globally on the seat machine, and restart
commonly agent run sprint-impl(and kai/quill before they switch). This PR was written by the review seat, so it needs another reviewer.🤖 Generated with Claude Code