feat(cli): the daemon withholds the grant broker from a seat it cannot confine (TASK-063) - #1798
Merged
Merged
Conversation
…t confine (TASK-063) A grant arrives as an injected MCP server and confinement is declared in a different field, so a seat can be handed a granter's authority with nothing keeping it in. The server refuses at the projection for the rows it can judge host-independently; this is the other emitter of the same refusal — the layer that knows this host, resolved the adapter locally, and holds the record the seat actually runs from, including the records the projection never reaches (a row naming no adapter, a backend older than the refusal, a hand-written token file). Entry-level, per wren 69829: the broker entry is withheld and the seat still starts, because the seat was never promised confinement. A seat-level refusal is #1727's case — a declared sandbox the host cannot enforce. The refusal is applied at the derive itself: seatBaseline is where an environment becomes the one the seat runs under, and one local helper wraps it for all four derive sites, so no site can be missed. Two facts taken from the live fleet rather than assumed. The broker url a record holds is the UNRESOLVED placeholder "${COMMONLY_API_URL}/api/mcp/grants/<id>" (c4-smoke, the only record declaring one), and new URL() throws on that string — so the adapter's path predicate, which is called after substitution, matches nothing here. The daemon resolves the known placeholders to its own instance first, and matches only OUR broker: a foreign server that happens to share the path is left to the adapters' own origin rules. And because this layer must judge the case the server may not, it gains one reason the server does not have: sandbox_absent — an absent block is the normal state of a daemon-provisioned seat (quill carries none) so the server must allow it, while reaching it here means the baseline supplied none. Evidence: 25 tests in a new predicate table, 3 in the supervisor's derive wiring, both including the controls that keep this from touching a working seat (a confined record's broker rides and the record is not even rewritten — identity is the dirty check at the derive sites). Full cli suite 41 suites / 671 tests, eslint src clean. Nine mutations, each reddening its own tests: the guard removed (5 red), the fresh-mint site unguarded (the pi test alone), placeholder resolution removed (8), name-keyed instead of url-keyed (2, both controls), the mode set widened (3), absent allowed (4), identity broken (3, including two pre-existing tests that already rely on it), the adapter check narrowed to pi (2). Honest limit, unchanged from the server half: a daemon-side refusal is still invisible to the grant read, because no daemon-to-server reporting channel exists (the heartbeat carries four fields and Machine.ts declares no strict). The grant read says "server-side refusals only" and must not be read as liveness.
…t (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.
…th (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.
samxu01
force-pushed
the
kai/task063-daemon-refusal
branch
from
September 20, 2026 01:40
0b9359b to
89aefdf
Compare
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.
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/" (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.