-
Notifications
You must be signed in to change notification settings - Fork 0
feat(hook)!: the handler door, the wiring repair, and the guards it does not touch #714
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9f462a6
c8de2d0
a1691ba
7b49705
142e539
54d2463
7248035
a0f357f
d4e232d
8ad1f43
238869f
552651f
52ca128
4c1d713
352b828
4ca16c7
65d4bd4
b228196
e244eb6
4f8f41b
ec8cfe4
f283533
57486de
729f179
b6b0f27
f92bc79
ebc2609
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3704,6 +3704,28 @@ wiring = [".claude/settings.json"] | |
| # --------------------------------------------------------------------------- | ||
| # What this repository attaches to hook events (house-style §9). | ||
| # | ||
| # THE `exclusive` CAPABILITY LANDS HERE UNDECLARED, AND THAT IS THE DECISION | ||
| # (CLOUD-893). `[hook] exclusive = true` says the only hook batten may register | ||
| # natively into any harness is `batten hook`, with nothing beside it. The engine | ||
| # half is built: `HookConfig::exclusive` in `action.rs`, and `doctor.rs` reports a | ||
| # sibling COUNT unconditionally and refuses only under the flag — because whether | ||
| # a hook beside batten's is legitimate is a consumer's judgement and | ||
| # `crates/batten` may not hold one (non-negotiable rule 1). | ||
| # | ||
| # WHY CONSUMER #1 DOES NOT YET DECLARE IT. The flag is GLOBAL rather than | ||
| # per-event: `doctor.rs`'s committed-surface scan refuses any non-batten entry | ||
| # under ANY event key, in every harness file. So declaring it obliges every | ||
| # remaining shell registration to move behind the door in the same change — and a | ||
| # guard cannot go behind the door without changing how it denies, which | ||
| # `shell-retirement` refuses for a governed file that is edited rather than | ||
| # retired. Two of this repository's guards cannot be retired yet (`run-shape-guard` | ||
| # on CLOUD-856, `hooks-wiring-check` on the `$HOME` surface), so declaring the flag | ||
| # would force exactly the edits the ratchet exists to refuse. | ||
| # | ||
| # The capability is therefore shipped and unused, which is the honest state: a | ||
| # consumer with no unretired registrations can declare it today, and this one | ||
| # declares it when its last guard retires rather than by editing bash to qualify. | ||
| # | ||
| # THE FIRST HANDLER, AND IT IS A WORKED EXAMPLE (CLOUD-898). `[[hook.action]]` | ||
| # shipped with one because a plugin surface nobody uses is a surface nobody has | ||
| # tested; the same standard applies here, and this row is that test on the real | ||
|
|
@@ -3725,11 +3747,22 @@ wiring = [".claude/settings.json"] | |
| # handlers nobody measured and this one IS measured: it reads MCP logs and | ||
| # returns in tens of milliseconds. A bound two orders above the measurement is | ||
| # generous without being meaningless. | ||
| # | ||
| # `owner` AND `expires` ARE WHAT MAKE THIS A DEBT RATHER THAN A DESTINATION | ||
| # (CLOUD-984). The door makes a dispatched program safe — a bound, central | ||
| # fail-open, one stated output shape — and none of that makes it POLICY. A | ||
| # predicate living behind a spawn is one a reader of the committed authority | ||
| # cannot discover, so a handler is always somebody's debt, and these two columns | ||
| # name the creditor and the date. `doctor` reports an unowned or overdue row; | ||
| # nothing switches the handler off, because a dispatched program that silently | ||
| # stopped running is the fail-open this surface exists to close. | ||
| [[hook.handler]] | ||
| id = "mcp-attach-check" | ||
| on = "user-prompt-submit" | ||
| run = ["mise-tasks/mcp-attach-check"] | ||
| run = ["mise-tasks/mcp-attach-check.sh"] | ||
| timeout_ms = 2000 | ||
| owner = "CLOUD-312" | ||
| expires = "2027-02-28" | ||
|
|
||
| # CLOUD-312 row 5: `connector-allow-guard` retires THROUGH THE DOOR rather than | ||
| # into a row, and the destination is a correction to CLOUD-312's own table. | ||
|
|
@@ -3783,12 +3816,37 @@ timeout_ms = 2000 | |
| # | ||
| # Halved, and the half that goes is the spawn. `mcp__*` calls still pay it, which is | ||
| # the point — they are the calls this decides. | ||
| # | ||
| # THIS ROW IS DECIDING NOTHING TODAY, AND THAT IS RECORDED HERE RATHER THAN FIXED | ||
| # (measured 2026-08-26, still true on `main`). The guard writes its verdict as a | ||
| # `hookSpecificOutput` document on stdout and exits 0. Behind the door | ||
| # `handler::impersonates_host` reads that shape BEFORE the exit code, returns | ||
| # `Outcome::Broke(ImpersonatedHost)`, and every `Broke` variant ALLOWS — so the | ||
| # verdict is reported as a pointer and dropped. Its own bats suite is green | ||
| # throughout, because a suite that never drives the real dispatch cannot see it. | ||
| # | ||
| # What made the loss invisible: every deny in the committed table is also covered | ||
| # by an unrelated engine row over the same tool, so a refusal still reached the | ||
| # host — from somewhere else. Only the ALLOW half, which nothing else provides, is | ||
| # actually gone, and with it the approval prompt CLOUD-191 exists to remove. | ||
| # | ||
| # WHY IT IS NOT REPAIRED HERE. The fix is one `case` in the guard's `decide()`, | ||
| # and that is an EDIT to a governed file, which `shell-retirement` refuses unless | ||
| # the file is retired instead. It cannot be retired: it reads | ||
| # `/tmp/mcp-config-cse_*.json` per call — off-tree, per-session, unstable — which a | ||
| # Rego module may not do (`Authority::Supplied` opens no file), and a Rust port | ||
| # would carry one consumer's file layout and vocabulary into `crates/batten` | ||
| # against non-negotiable rule 1. So the repair needs a decision this PR does not | ||
| # own, and `preapproves` is left undeclared because it cannot fire while the | ||
| # document is dropped — declaring it would assert a reach this row does not have. | ||
| [[hook.handler]] | ||
| id = "connector-allow-guard" | ||
| on = "pre-tool" | ||
| run = ["mise-tasks/connector-allow-guard.sh"] | ||
| matcher = "^mcp__" | ||
| timeout_ms = 2000 | ||
| owner = "CLOUD-312" | ||
| expires = "2027-02-28" | ||
|
Comment on lines
+3819
to
+3849
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift One description of
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
|
|
||
| # CLOUD-312 row 8: `mcp-allow-check --session` retires THROUGH THE DOOR, beside its | ||
| # own sibling. `mcp-attach-check` above went first as the cheapest honest proof of | ||
|
|
@@ -3826,6 +3884,8 @@ id = "mcp-allow-check" | |
| on = "user-prompt-submit" | ||
| run = ["mise-tasks/mcp-allow-check.sh", "--session"] | ||
| timeout_ms = 2000 | ||
| owner = "CLOUD-312" | ||
| expires = "2027-02-28" | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # THE REFUSAL VOCABULARY (CLOUD-1050). | ||
|
|
@@ -4827,3 +4887,86 @@ that cannot refuse is off rather than passing. | |
| id = "R-PLACE-AT-LEAST-ONE-ADAPTER" | ||
| kind = "document" | ||
| target = "policy/spawn-adapters.rego" | ||
|
|
||
| # WHY `run-shape-guard` IS NOT A HANDLER ROW HERE, AND WHY THAT IS THE RATCHET | ||
| # WORKING (CLOUD-312 row 4, deferred). | ||
| # | ||
| # Going behind the door is not a config move: a dispatched handler's stdout is | ||
| # INTERPRETED, so `handler::impersonates_host` reads this guard's | ||
| # `hookSpecificOutput` deny document and drops it before the exit code is read. | ||
| # The guard would have to deny as exit 2 with the reason on stderr — an EDIT to a | ||
| # governed file, which `shell-retirement` refuses unless the file is retired | ||
| # instead. | ||
| # | ||
| # And it cannot be retired yet. Three of its four families now live in | ||
| # `policy/run-shape.rego`; the fourth reads `mise.toml`'s task bodies, which no | ||
| # `mediated_call` row may do — `call_document` projects `Fact::Document` as | ||
| # `None`, and `sources` on a mediated row is refused at load. CLOUD-856 owns that. | ||
| # | ||
| # So the guard stays natively registered in `.claude/settings.json`, byte-identical, | ||
| # and moves behind the door in the change that retires it. Editing it to qualify | ||
| # for the door would be exactly the shrink-instead-of-retire the ratchet exists to | ||
| # refuse. | ||
|
|
||
| # THE LAST NATIVE REGISTRATION (CLOUD-312 row 10). Ten existed when this campaign | ||
| # started and the gate passed over all ten; this is the tenth, and the one that | ||
| # looked impossible for the longest — the program behind it is what INSTALLS the | ||
| # binary that would dispatch it, so retiring it read as a bootstrap paradox. | ||
| # | ||
| # WHAT BROKE THE PARADOX WAS A SECOND SOURCE, NOT A DECOMPOSITION. #711 hardened | ||
| # `install.sh` into a one-liner that takes the script from `main` and the binary | ||
| # from the latest release, so a container puts `batten` on PATH before any hook | ||
| # fires and this handler is dispatched by a binary that already exists. Nothing | ||
| # moved out of the program: `install:local` stays because on a dev clone the | ||
| # working tree's build must supersede the released binary, and it is the recovery | ||
| # path when the setup step never ran. An earlier plan proposed hoisting | ||
| # provisioning into the environment's setup script and was wrong — it would have | ||
| # cost both of those for nothing. | ||
| # | ||
| # FOUR FACTS MAKE THE MOVE BEHAVIOUR-PRESERVING, each read rather than assumed: | ||
| # | ||
| # 1. stdout carries 79 bytes on the all-clear path — `container-preflight` | ||
| # announces its own success there — which behind the door is an advisory | ||
| # delivered every session whose whole content is "nothing is wrong". Moving | ||
| # that line to stderr is an edit to a governed file, so it belongs to that | ||
| # file's retirement rather than to this move. Stated as a precondition of the | ||
| # move rather than as something already done: an earlier draft of this comment | ||
| # claimed the line "now goes to stderr", and it does not. | ||
| # 2. A failure is exit 1 with `::error::` lines on stderr, which `interpret` | ||
| # reads as `Outcome::Reported` — and that joins `Dispatched::advice()`, which | ||
| # `AdvisoryReach` delivers on `SessionStart` for this host. So the failure | ||
| # detail gains reach: today it lands in a hook log, behind the door it is | ||
| # context the agent reads. | ||
| # 3. The one other stdout writer is `reclaim-census report`, exit 0 with text → | ||
| # `Advise`, delivered on the same channel. Unchanged. | ||
| # 4. A timeout is `Outcome::Broke(TimedOut)`, which ALLOWS — the same fail-open | ||
| # a native SessionStart registration already has. | ||
| # | ||
| # THE BOUND IS MEASURED, and the margin is stop-guard's lesson two rows up rather | ||
| # than a preference. Warm, this container, three runs: 4 s, 4 s, 5 s. So | ||
| # `DEFAULT_TIMEOUT`'s 5 s would be a coin toss on the ordinary path — the exact | ||
| # shape that lost stop-guard all five of its rules. 180000 is 36x the measured | ||
| # warm maximum, and it is also well past Claude Code's own 60 s hook timeout, so | ||
| # the door is not tighter than the registration it replaces. A genuinely cold | ||
| # clone's release build can still exceed it; that fails open, as it does today, | ||
| # and #711's second source is why the cold case is no longer the bootstrap. | ||
| # | ||
| # TWO CONSTRAINTS FOR WHOEVER EDITS THE PROGRAM NEXT: | ||
| # | ||
| # * The session stamp must stay first. `claim-check`'s `refined-this-session` | ||
| # compares the tracker's `updatedAt` against that file's mtime, so a stamp | ||
| # written after the work dates the session to whenever the agent got round to | ||
| # claiming. It is already the first thing the script does, before `step` is | ||
| # even defined; the door changes nothing here, and this is why it must not be | ||
| # reordered. | ||
| # * NEVER add `batten wiring reclaim` to this handler. That verb writes the | ||
| # at-load record and `batten hook` expires it on this same event, so a reclaim | ||
| # dispatched from here would put the write and the clear inside one unordered | ||
| # batch — a coin toss between the honest red and the false green the record | ||
| # exists to refuse. `crates/batten/src/wiring.rs`'s header is the long form. | ||
| # DEFERRED WITH ITS SIBLING, and for one reason only: moving it obliges | ||
| # `[hook] exclusive`, which is global, which obliges `run-shape-guard` behind the | ||
| # door too — and that one cannot go. Everything above is the read that will make | ||
| # the move behaviour-preserving when its sibling is ready; nothing in it is | ||
| # invalidated by waiting. `.claude/hooks/session-start.sh` is not a governed path, | ||
| # so this row costs nothing to defer and nothing to restore. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Record only an actual repair.
Do not create the at-load record when reclaim removes zero siblings. A no-op reclaim currently writes an empty write-once record. A later reclaim in the same session can then remove siblings but cannot persist its nonzero count.
doctor hooksandhooks-wiring-checkreceive stale repair state.Update the reclaim implementation to create the record only for a real repair, or replace an empty record before the first real repair. Update this description to state that condition.
🤖 Prompt for AI Agents
Source: MCP tools