Skip to content

fix(agent-binding): refuse to project a grant broker into an unconfined seat (TASK-063 server half) - #1765

Merged
lilyshen0722 merged 3 commits into
mainfrom
kai/task063-grant-broker-confinement
Sep 19, 2026
Merged

lilyshen0722 merged 3 commits into
mainfrom
kai/task063-grant-broker-confinement

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

What

The grant broker is external reach. grantServersForIdentities now withholds it from a seat that no host can confine, and reports a typed refusal on the assignment row instead of silently handing over the capability.

Ruling (wren): refuse, do not derive — the server projection refuses, the daemon refuses at derive for the records the projection never reaches, one typed code for both, plus a field naming the deciding layer.

Merge order: #1764 must not land before this. #1764 gives pi the Streamable-HTTP transport the broker uses; today a pi seat is safe only because it cannot speak HTTP. This is the refusal that keeps that reach confined, so the other order opens it first.

Ordering alone is not the guarantee, though (Vera 69822): this PR's effect arrives with the backend deploy while #1764's arrives with the CLI release, and a row that names no adapter passes this predicate by design. The order-independent half is the daemon-side refusal wren ruled, carried by #1764 itself — pi refuses any entry under the instance's /api/mcp/grants/ path, which holds regardless of deploy skew, of what the row declares, and of which layer is older.

What is refused, and why exactly these

The predicate lives once, in backend/services/grantBrokerConfinement.ts, and refuses only what is host-independent — the part of the enforcing layers' rule the server can know without knowing the host:

clause reason why it fails everywhere
sandbox.mode === 'none' sandbox_mode_none no host confines it
declared trust not public after the internalpublic legacy mapping sandbox_trust_not_public no host confines a seat whose trust is not public
adapter is pi, after the daemon's own normalisation ('PI', ' pi ') adapter_cannot_confine pi confines on no host: its assertNoSandboxDeclared (cli/src/lib/adapters/pi.js) throws only when a sandbox is declared, so a seat declaring nothing spawns unconfined and nothing ever derives one — and quill is exactly that shape. daemon-supervisor.js does trim().toLowerCase() before spawning, so a raw comparison would let a differently-spelled name past while the seat still spawns pi
declared public mode outside {workspace, read-only, bwrap} sandbox_mode_unenforceable the write-time schema (environment.js ALLOWED_SANDBOX_MODES) accepts firejail / container / managed, which appear nowhere else in cli/src — no adapter implements them, on any host
a declared mode that is not a string (['bwrap'], an object, true) sandbox_mode_unenforceable every adapter compares the mode with === against a string, so a non-string confines nothing even when it stringifies to a mode that does

And allowed, deliberately:

  • public trust with no declared mode — the mode is host-resolved (#1754's resolvePublicSandboxMode: darwin → workspace, else → bwrap), so only the daemon can decide it;
  • an absent sandbox block — the normal state of a daemon-provisioned seat, whose baseline is derived at the daemon;
  • bwrap — claude implements it on Linux, so refusing it would refuse a seat a host would have confined;
  • an adapter the row does not name — the daemon detects the local adapter, so its refusal covers that case.

PUBLIC_HOST_MODES is the union of what the adapters implement (both do {workspace, read-only}; claude adds bwrap) — wider than any single adapter, so it cannot refuse something a host would have confined. Test asserts its membership, because widening it silently starts refusing working seats.

Honest limit

The three allow-clauses are sound only where the CLI derives a baseline (#1754). On a pre-#1754 host, trust: 'public' with no declared mode still falls past claude's Seatbelt branch into a bare spawn. This PR narrows reach; it does not by itself close that case — #1754's derive plus the daemon-side refusal (TASK-063) do. Nothing here claims otherwise.

Surfacing

grantBrokerRefusal: {code, decidedBy, reason, detail} is a top-level field on the /assigned row — never inside environment, which is spec-validated and handed to the adapter as-is. The daemon is the consumer that would otherwise guess why a live grant produced no entry; detail names the thing to change (the adapter, or the mode).

Scope: GET /api/grants/:grantId is not in this PR. That route has no AgentInstallation access at all, and RoomGrant.installationId is the GitHub App connection's installation id, not an install record — so the seat's environment cannot be resolved from the grant being displayed without a second resolver, i.e. the second predicate definition the ruling forbids. It needs the identity→environment projection extracted from this route into a shared service; until then the grant read shows server-side refusals only.

Tests (50, in two new files)

__tests__/unit/services/grantBrokerConfinement.test.js — the predicate tables: the declaration table (incl. the rows that must NOT refuse), the adapter table, and the mode-universe table.

__tests__/unit/routes/agentBinding.grantConfinement.test.js — the route, against a live unexpired seat grant: a confined seat and a legacy internal seat still receive the broker; a seat no host can confine receives no broker, keeps its own commonly entry untouched, gets the typed refusal, and has its declaration left unrewritten; mode: 'none' refused; a pi seat refused even with no sandbox block (proves the adapter reaches the predicate from the row); absent block and public-trust-without-mode still receive the broker.

Mutation proofs (each independent, measured at this head)

mutation result
legacy map → no-op 3 failed
legacy map inverted (internal: 'private') 3 failed
refuse the absent block 5 failed — incl. the working-path test
trust check narrowed to === 'private' 4 failed
ignore the refusal in the route 3 failed
drop the adapter guard 4 failed — the three pi rows + the pi route test
drop the mode-universe check 6 failed — exactly the unenforced-mode rows
route stops passing the runtime 1 failed — the pi route test alone (the wiring, not the table)
drop bwrap from the allowed set 2 failed — incl. the set-membership pin
adapter normalisation removed (raw name compare) 4 failed — the three case variants + the normalisation pin
mode check coerces with String() again 2 failed — the array and the object mode

npm run lint:ts0 errors. Backend-only, so no version bump.

@lilyshen0722
lilyshen0722 force-pushed the kai/task063-grant-broker-confinement branch from c70251b to 63910ff Compare September 19, 2026 11:14
@lilyshen0722
lilyshen0722 merged commit 373ac26 into main Sep 19, 2026
14 checks passed
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…t installation that declares either half

TASK-019 ruling, option i (wren 69800). The pair is taken from the OLDEST
active installation (`_id` ascending) that declares EITHER half; a half that
installation is silent about stays empty rather than being filled from a
sibling row; `podIds` stays a union across the identity's installations.

The site is not the parked patch's, and that is the point. The parked change
(branch `kai/task019-i-pair-atomic`, 59d9938 + 7a354d95) patched the /assigned
handler in `routes/agentBinding.ts`. TASK-063 (#1765, #1766) then refactored
that handler to call `projectSeatEnvironments`, shared with the grant read, so
the handler no longer holds the projection — applying the parked patch to
82eabcd conflicted on exactly that hunk. The behaviour therefore lands once, in
`services/seatEnvironmentProjection.ts`, where both the daemon list and the
grant read resolve it.

Also `.sort({ _id: 1 })` on the query: the previous winner was Mongo's stored
order, and the doc comment here said so.
lilyshen0722 added a commit that referenced this pull request Sep 19, 2026
…t installation that declares either half (TASK-019 ruling i) (#1772)

* fix(agent-binding): the runtime+environment pair comes from the oldest installation that declares either half

TASK-019 ruling, option i (wren 69800). The pair is taken from the OLDEST
active installation (`_id` ascending) that declares EITHER half; a half that
installation is silent about stays empty rather than being filled from a
sibling row; `podIds` stays a union across the identity's installations.

The site is not the parked patch's, and that is the point. The parked change
(branch `kai/task019-i-pair-atomic`, 59d9938 + 7a354d95) patched the /assigned
handler in `routes/agentBinding.ts`. TASK-063 (#1765, #1766) then refactored
that handler to call `projectSeatEnvironments`, shared with the grant read, so
the handler no longer holds the projection — applying the parked patch to
82eabcd conflicted on exactly that hunk. The behaviour therefore lands once, in
`services/seatEnvironmentProjection.ts`, where both the daemon list and the
grant read resolve it.

Also `.sort({ _id: 1 })` on the query: the previous winner was Mongo's stored
order, and the doc comment here said so.

* test(agent-binding): pin the pair-atomic projection, the silent half, and the oldest-_id ordering

Witnesses for the change above, at the route tier that consumes it and at the
service tier that defines it:
- the union test now pins that BOTH halves come from the base fixture and that
  the second installation's values appear nowhere in the projected agent;
- `never fills a silent half from a sibling installation` — the case the ruling
  is about, visible only when the halves live on different rows;
- `takes the pair from the oldest _id even when that row was inserted last` —
  explicit ObjectIds, newer row inserted first, so insertion order and `_id`
  order disagree and only the find's sort can pick the older pair.

Mutations (27 passing across the two suites):
- per-field fill -> 1 failed, the silent-half test alone;
- `.sort({ _id: -1 })` -> 3 failed (pair-atomic, silent half, ordering);
- dropping `.sort()` -> 1 failed, the ordering witness alone. That clause was
  an unwitnessed tripwire in the parked fold; it now has its witness.

* fix(agent-binding): a declaration counts only when it delivers something

Vera's residual on 268370c: the predicate tested the raw key, so an OLDER row
whose environment the allow-list reduces to nothing (`environment: {}`, or only
fields the projection drops) became the source of the pair and a NEWER row's
real environment was never read. She is right that it fails toward the daemon
baseline rather than acting as a bypass, but the oldest-wins rule then picks the
row that does not describe the seat, and the seat loses the environment it
declares. "Declares" now means delivers: a half counts when it projects to
something (or, for runtime, when it is present at all).

Mutations, each run against the change (28 passing across the two suites):
- raw-truthiness predicate, i.e. the defect above -> 1 failed, the new test alone;
- marking the identity sourced on the first row regardless of whether it
  declared anything -> 1 failed, the new test alone.

Not fixed here, and named rather than left implied: `runtime: {}` still counts
as a declaration. Runtime is not passed through a projection, so the equivalent
test is a schema question (an empty object, and a nested Map that
`Object.keys` reads as empty) rather than a reduce, and guessing it here could
skip a genuine runtime fleet-wide.
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