fix(agent-binding): refuse to project a grant broker into an unconfined seat (TASK-063 server half) - #1765
Merged
Conversation
samxu01
force-pushed
the
kai/task063-grant-broker-confinement
branch
from
September 19, 2026 11:00
231317d to
4e8ccea
Compare
lilyshen0722
force-pushed
the
kai/task063-grant-broker-confinement
branch
from
September 19, 2026 11:09
4e8ccea to
c70251b
Compare
…ed seat (TASK-063 server half)
…confines on no host, and for modes no adapter implements (TASK-063)
…n the grant-broker refusal (TASK-063)
lilyshen0722
force-pushed
the
kai/task063-grant-broker-confinement
branch
from
September 19, 2026 11:14
c70251b to
63910ff
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The grant broker is external reach.
grantServersForIdentitiesnow 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:sandbox.mode === 'none'sandbox_mode_nonepublicafter theinternal→publiclegacy mappingsandbox_trust_not_publicpublicpi, after the daemon's own normalisation ('PI',' pi ')adapter_cannot_confineassertNoSandboxDeclared(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 — andquillis exactly that shape.daemon-supervisor.jsdoestrim().toLowerCase()before spawning, so a raw comparison would let a differently-spelled name past while the seat still spawns pi{workspace, read-only, bwrap}sandbox_mode_unenforceableenvironment.jsALLOWED_SANDBOX_MODES) acceptsfirejail/container/managed, which appear nowhere else incli/src— no adapter implements them, on any hostmodethat is not a string (['bwrap'], an object,true)sandbox_mode_unenforceable===against a string, so a non-string confines nothing even when it stringifies to a mode that doesAnd allowed, deliberately:
#1754'sresolvePublicSandboxMode: darwin →workspace, else →bwrap), so only the daemon can decide it;bwrap— claude implements it on Linux, so refusing it would refuse a seat a host would have confined;PUBLIC_HOST_MODESis the union of what the adapters implement (both do{workspace, read-only}; claude addsbwrap) — 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/assignedrow — never insideenvironment, 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;detailnames the thing to change (the adapter, or the mode).Scope:
GET /api/grants/:grantIdis not in this PR. That route has noAgentInstallationaccess at all, andRoomGrant.installationIdis 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 legacyinternalseat still receive the broker; a seat no host can confine receives no broker, keeps its owncommonlyentry 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)
internal: 'private')=== 'private'bwrapfrom the allowed setString()againnpm run lint:ts→ 0 errors. Backend-only, so no version bump.