Skip to content

fix(cli): a daemon-provisioned seat is never born unconfined — seat sandbox baseline (TASK-052) + refuse an unread trust value (TASK-059) - #1754

Merged
lilyshen0722 merged 4 commits into
mainfrom
kai/task052-seat-baseline-sandbox
Sep 19, 2026
Merged

lilyshen0722 merged 4 commits into
mainfrom
kai/task052-seat-baseline-sandbox

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

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.mode defaults 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 sandbox agent attach would 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

"sandbox": { "trust": "public" }

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, while mode: '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 new cli/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, --disallowedTools with 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._detectBwrap is a test seam, alongside the existing _spawnImpl.)

Mechanism per platform, stated rather than implied: darwin/usr/bin/sandbox-exec with the policy args and the isolated per-identity HOME; linuxbwrap (--unshare-all, --die-with-parent) with the policy args inside the namespace and no isolated HOME — claude's Linux credentials live in HOME, 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. publicNativeSandbox required 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 bare unconfined claude spawn — 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:

  • claude resolves the mode, and refuses any public trust that does not end up enforced (the literal 'none', a typo, a non-string) instead of falling through.
  • codex defaults an absent mode to workspace. Its mode is 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 threw got unset and no derived codex seat could spawn at all.
  • assertSandboxDeclaredForPublicPod now 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, and bwrap.js reads the network/filesystem sub-blocks; the defect is the asymmetry).

  • Refused for new declarations. ALLOWED_SANDBOX_TRUST is 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.
  • Resolved toward confinement for a record that already carries it. normalizeSandboxTrust reads internal as public, 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.
  • The contract itself moved, and nothing went red to show it (Vera 69668 — the fourth inversion, and the only one that is not a test). Dropping 'internal' from ALLOWED_SANDBOX_TRUST means validateEnvironmentSpec now 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 carries internal — the sandbox blocks that exist are 2 dev installations ({trust:'public', mode:'workspace'} and {mode:'none'}) and 2 of 34 local token files, both trust:'public'. So the rejection breaks nothing today and the normalization path is currently theoretical.
  • Not a no-op for existing seats; this direction is deliberate and it tightens them. {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 + workspace names 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 an internal declaration loses it and must declare public on purpose, or drop the field. A refusal and a stricter jail are both "toward confinement"; nothing here resolves toward the bare spawn.
  • The claude adapter resolves the trust once, at the top of 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 with public 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 consume mcp[] — which is not the set that can enforce a sandbox. pi is in the first and not in the second, and since #1727 it throws before starting on sandbox.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 the mcp[] 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.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.

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

  • a server-declared environment, 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 exactly 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.

Honest limits

  • Confinement holds for claude and codex, and only they are ever handed the block. A pi seat gets the mcp[] 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.
  • A derived pi seat 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-gh bypass 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.
  • Not verified on a real Linux host. The Linux path is asserted with a mocked process.platform, the adapter's bwrap branch and the _detectBwrap seam. The behaviour of the added policy args inside a real namespace is unmeasured here.
  • 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 fix(cli): the daemon refuses a declared environment that would run a foreign command or ship the seat token off-instance #1744.

Testing

39 suites / 567 passed / 10 skipped; lint:cli clean.

  • 6 new in 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.
  • 5 new in 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.
  • 5 new in 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 legacy trust:'internal' + workspace seat is confined under Seatbelt.
  • 2 new in adapters.codex.test.mjs (derived default, and a legacy internal seat getting the public profile and never the bypass flag); 2 new in public-pod-sandbox-gate.test.mjs.
  • 4 new in environment.test.mjs (validation refuses internal and 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:

  1. The C4-2 baseline assertion from fix(cli): daemon-provisioned seats get the commonly MCP baseline (TASK-048) #1741 asserted the mcp-only baseline — that assertion encoded this gap. It was first inverted to both halves, and is now the pi mcp-half assertion: the fixture is a pi row, and pi is the one adapter that must not receive the sandbox.
  2. Codex's "public trust fails closed when no enforced public sandbox mode is declared" asserted the throw that would have stopped every derived codex seat.

Verified on the real writers, not only injected ones (real supervisor tick, 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 following tick.

Version

cli 0.1.50 → 0.1.51, rebased onto main ac544d5c. 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: d620c25b was 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 from 48a5ea92 at 0.1.49. Sitting on that base meant Source changed ⇒ version bumped would fail and this PR would have shipped a version below main's. Rebased onto ac544d5c and 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:

diff <(git diff 48a5ea92 322c0bba -- cli | grep -v '"version"') \
     <(git diff ac544d5c 40899704 -- cli | grep -v '"version"')

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.js and 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.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Merge-order note (measured, not inferred) — git merge-tree --write-tree --merge-base=origin/main, git 2.54.

This PR, #1761 and #1744 each edit cli/package.json's version line from the same base (main = 0.1.50), so they pairwise CONFLICT on that one line. Head versions: #1744 = 0.1.49 (below main, so it fails the version guard as-is and needs a rebase + bump in any order), this PR = 0.1.51, #1761 = 0.1.52. Everything else in the three trees auto-merges cleanly (cli/src/lib/daemon-supervisor.js and cli/__tests__/daemon-supervisor.test.mjs merge without conflict; #1744 + #1760 is clean too).

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.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

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 git merge-tree --write-tree --merge-base=origin/main A B. Forcing main as the base is only valid when both branches were cut from main's tip. #1744's is not: its true base predates main's 0.1.50 bump, so pretending main is the base made the version line look single-sided and produced a "clean" that isn't real.

Re-run with the natural merge base (no --merge-base), which is the model that matches how these actually land — B merges into main first, then A merges into the result — the picture is:

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.

samxu01 pushed a commit that referenced this pull request Sep 18, 2026
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.
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Coupling worth knowing before these land, measured after the note above. This PR is cleared at 3768fa29 and stays there — no commit follows from this.

kai/1743 (fix/claude-public-sandbox-http-mcp) is the only fix for a live crash on the darwin path: a freshly derived claude seat that carries an HTTP MCP entry in environment.mcp dies at spawn in isAbsolute(undefined) (cli/src/lib/adapters/claude.js:454), reached via prepareArgv. I ran #1743's own test against this branch head in a scratch worktree — it fails there exactly as it does on main — so the two are independent, and this PR does not carry the fix.

They compose textually (clean merge-tree, same function but different regions) and the land order is free and version-clean: this PR at 0.1.51 first, then #1743 at 0.1.53; or #1743 first, in which case this one needs a re-bump rather than a resolution.

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 main today and #1743 is the fix for it. Landing #1743 first is therefore the safer order if the merge queue lets you choose, and the cost of that order is one re-bump on this PR.

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Merge-queue facts, measured just now, because this is the first of four cli/ PRs and the order they land in has a cost.

Nothing technical is blocking any of them. gh api repos/Team-Commonly/commonly/branches/main/protection → required check Test & Coverage only, required_status_checks.strict = true, no required reviews. So all 15 of my CLEAN PRs are mergeable now, by anyone with write access, without an approval.

Two consequences of strict = true worth knowing before starting:

  1. Every merge puts the other 14 out of date; each needs an update-branch before it can land. The queue is serialized no matter what order you pick, so pick the order that costs least.

  2. Four PRs touch cli/src and therefore bump cli/package.json:

    PR version
    this one (fix(cli): a daemon-provisioned seat is never born unconfined — seat sandbox baseline (TASK-052) + refuse an unread trust value (TASK-059) #1754) 0.1.51
    fix(cli): the daemon reports the seat's record, not the row runtime, as what it runs (TASK-065) #1761 0.1.52
    fix(cli): a public-sandboxed claude seat survives an HTTP MCP server in its environment #1743 0.1.53
    fix(cli): pi Streamable HTTP MCP transport, a declared transport decides an entry's shape, and a granted pi seat is never given the broker (TASK-054, TASK-063 daemon half) #1764 0.1.54

    I re-ran the six pair-wise merge simulations with natural merge bases (git merge-tree --write-tree A B, no --merge-base): every pair conflicts on cli/package.json and on nothing elseclaude.js, daemon-supervisor.js and the test files auto-merge.

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.

samxu01 pushed a commit that referenced this pull request Sep 19, 2026
… a url; cli 0.1.55

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7WB68pA8L2nkvSqZPx37w
samxu01 pushed a commit that referenced this pull request Sep 19, 2026
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.
samxu01 pushed a commit that referenced this pull request Sep 19, 2026
pi ships no MCP support (its README: "No MCP. … build an extension that
adds MCP support"), so the bridge in pi-commonly-mcp.mjs is a pi seat's
entire transport story. Two independent filters dropped every
`environment.mcp` entry that carried a `url` rather than a `command`
(pi.js resolveMcpServers, pi-mcp-client.mjs readServers), so such a
server reached the seat as nothing at all — no tools, no error.

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

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

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

cli 0.1.50 -> 0.1.54, above #1754's 0.1.51, #1761's 0.1.52, #1743's 0.1.53.
@lilyshen0722
lilyshen0722 force-pushed the kai/task052-seat-baseline-sandbox branch from 3768fa2 to e4a16ae Compare September 19, 2026 11:38
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.
@lilyshen0722
lilyshen0722 force-pushed the kai/task052-seat-baseline-sandbox branch from e4a16ae to 70ded04 Compare September 19, 2026 11:50
@lilyshen0722
lilyshen0722 merged commit 66a7827 into main Sep 19, 2026
13 checks passed
samxu01 pushed a commit that referenced this pull request Sep 19, 2026
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.
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…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>
samxu01 pushed a commit that referenced this pull request Sep 19, 2026
pi ships no MCP support (its README: "No MCP. … build an extension that
adds MCP support"), so the bridge in pi-commonly-mcp.mjs is a pi seat's
entire transport story. Two independent filters dropped every
`environment.mcp` entry that carried a `url` rather than a `command`
(pi.js resolveMcpServers, pi-mcp-client.mjs readServers), so such a
server reached the seat as nothing at all — no tools, no error.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

cli 0.1.54 -> 0.1.55

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…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.
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…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.
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