Skip to content

fix(cli): pi seats — seat token out of the shell env; unenforceable sandbox fails closed - #1727

Merged
lilyshen0722 merged 2 commits into
mainfrom
fix/pi-adapter-env-and-trust
Sep 18, 2026
Merged

lilyshen0722 merged 2 commits into
mainfrom
fix/pi-adapter-env-and-trust

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

Follow-up to #1724. Its gate asked for two changes, but it merged and was published as @commonlyai/cli@0.1.45 without 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 bash tool spawns with env ?? getShellEnv(), and getShellEnv() (@earendil-works/pi-coding-agent/dist/utils/shell.js:103, pi 0.84.1) returns { ...process.env, PATH }. pi.js put COMMONLY_PI_MCP into that env, and that JSON carries the substituted COMMONLY_AGENT_TOKEN. Executing the real function with fake values:

bash-tool env carries COMMONLY_PI_MCP: true | seat token visible: true
bash-tool env carries COMMONLY_LITELLM_KEY: true
control (unset var absent): false

Fix: takeServers(env) in pi-mcp-client.mjs reads 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_KEY stays visible to the shell, because pi resolves $COMMONLY_LITELLM_KEY from 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.js and codex.js refuse a public-trust seat they cannot confine. pi.js never read environment.sandbox: a trust: 'public', mode: 'workspace' seat ran unconfined. It now throws before pi starts, on trust: 'public' and on any sandbox.mode other 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-extensions in argv. At #1724, dropping it stayed green.

Proof

  • pi suites 16 → 22/22 (Node 22, the CLI's own jest).
  • Each new test was proven by mutating the fix (drop the 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.
  • Full CLI suite: 513 passed, 10 skipped, 1 failed. The failure is mention-event-types.contract.test.mjs, which fails identically on origin/main with 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

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

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.

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

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.

lilyshen0722 and others added 2 commits September 18, 2026 05:34
…; 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
@samxu01
samxu01 force-pushed the fix/pi-adapter-env-and-trust branch from 84102e1 to 9df10e2 Compare September 18, 2026 12:34

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

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.

@lilyshen0722
lilyshen0722 merged commit ac544d5 into main Sep 18, 2026
14 checks passed
samxu01 pushed a commit that referenced this pull request Sep 18, 2026
… (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.
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
… (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.
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
… (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.
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…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.
samxu01 pushed a commit that referenced this pull request Sep 20, 2026
…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.
lilyshen0722 added a commit that referenced this pull request Sep 20, 2026
…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.
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