From 4b2da11020258d40086b3435786ad7f512239d5a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 20:47:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(hook):=20the=20adjudicator's=20first=20sli?= =?UTF-8?q?ce=20=E2=80=94=20envelope,=20parser,=20gh=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs: CLOUD-202 The agent-neutral hook layer existed as five single-harness bash programs with one ~60-line command parser copy-pasted three times; that duplication has produced two landed bug fixes and two open defects. This lands the Rust core the port converges on: hook.rs the normalized envelope (event/tool/command); the wrapper-lookthrough parser — quoted spans neutralised, segments split, env prefixes and wrapper programs (env, timeout, sudo, mise exec --) looked through so policy judges the EFFECTIVE program, the exact shape CLOUD-181 hardened; and the gh lifecycle table ported verbatim from gh-guard-check, reasons and all. cli.rs `batten hook --harness `. The claude-code adapter answers in the host's JSON decision object (exit 0, the channel the production guards already use); the exit-code adapter is the neutral §7 inversion — exit 2 denies, reason on stderr. effect.rs `hook` is unclassified: its own run only reads, but its decision mediates writes, so it must not leak into the derived read-only allowlist. Fail-open throughout: unreadable stdin, undecodable payload, empty command, and the BATTEN_GH_GUARD_BYPASS hatch all allow. Unit tests pin the parser's sharpest bats cases (wrapper look-through, quoted-span non-match, interposed flag values, lookalike verbs); behavioural tests pin both adapters' channels over the compiled binary. The shell guards stay wired in settings.json until the remaining policies (receipts, issue-key, run-shape, protected paths) are ported; flipping the settings entry is CLOUD-202's item 5, not this commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VS5eTnMTruCWytpAk2gW7k --- .serena/memories/core.md | 5 + crates/batten/src/cli.rs | 7 + crates/batten/src/effect.rs | 5 + crates/batten/src/hook.rs | 390 ++++++++++++++++++++++++++++++++++++ crates/batten/src/lib.rs | 40 +++- crates/batten/tests/cli.rs | 100 ++++++++- 6 files changed, 545 insertions(+), 2 deletions(-) create mode 100644 crates/batten/src/hook.rs diff --git a/.serena/memories/core.md b/.serena/memories/core.md index 8a49a9775..b3d1f4f65 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -38,6 +38,11 @@ repo config > default`, declared as data in `SETTINGS` (per-key env var/flag), predicates over the repo. `run_static` (read-effect, no process spawn) backs `check`; `run_all` (every kind) backs `enforce`. `check` refuses a command-executing rule rather than silently skipping it. +- `hook.rs` — the `hook` adjudicator (CLOUD-202): the normalized envelope, the + wrapper-lookthrough command parser, and the policy tables, ported from the + shell guards. Harness adapters decode/encode at the edges; the core is + harness-blind and fail-open, and the §7 exit-2-denies inversion lives in the + exit-code adapter only. - `identity.rs` — finding-identity fingerprints (CLOUD-123): SHA-256 over a normalized, kind-discriminated tuple — never raw `file:line` — so line insertion doesn't re-mint a finding; content changes correctly do. diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index e915b137a..59078ebb3 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -13,6 +13,7 @@ use clap::{Parser, Subcommand, ValueEnum}; use crate::config::Strictness; +use crate::hook::Harness; /// Repo-agnostic policy engine that keeps "done" aligned with landed-and-verified work. #[derive(Debug, Parser)] @@ -56,6 +57,12 @@ pub enum Command { #[arg(long, value_enum, default_value_t = SpecFormat::Json)] format: SpecFormat, }, + /// Adjudicate a mediated tool call read from stdin (under this verb, exit 2 denies). + Hook { + /// The harness whose payload to decode and whose decision channel to answer in. + #[arg(long, value_enum)] + harness: Harness, + }, } /// Subcommands of `config`. diff --git a/crates/batten/src/effect.rs b/crates/batten/src/effect.rs index d41ba1a3e..b2d2707e0 100644 --- a/crates/batten/src/effect.rs +++ b/crates/batten/src/effect.rs @@ -84,6 +84,11 @@ fn table() -> BTreeMap<&'static str, Effect> { ("enforce", Effect::Unclassified), ("config show", Effect::Read), ("spec", Effect::Read), + // `hook` adjudicates another tool's call: its own execution only reads + // stdin and config, but its *decision* mediates writes, so it is listed + // unclassified rather than allowed to leak into the derived read-only + // allowlist (CLOUD-202). + ("hook", Effect::Unclassified), ]) } diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs new file mode 100644 index 000000000..afe26daed --- /dev/null +++ b/crates/batten/src/hook.rs @@ -0,0 +1,390 @@ +//! The `hook` adjudicator (CLOUD-202): the agent-neutral envelope, the +//! wrapper-lookthrough command parser, and the first policy table, ported from +//! the battle-tested shell guards (`mise-tasks/gh-guard-check` et al.). +//! +//! Three layers, deliberately separated: +//! +//! * [`Envelope`] — the one normalized shape every harness adapter decodes +//! into: `event`, `tool`, `input`, `cwd?`, `session?`. Harness-specific field +//! names live in the adapter for that harness, never here. +//! * the parser — quoted spans are neutralised, segments split on shell +//! separators, env prefixes and wrapper programs (`env`, `timeout`, +//! `mise exec -- …`) looked through, so policy judges the **effective** +//! program. Judging the wrapper token instead is the bug class CLOUD-181 +//! hardened the shell guards against; the port keeps that hard-won shape. +//! * the policy — a table from command shape to a deny with a fix pointer. +//! This slice carries the `gh` lifecycle table; the receipt, issue-key, +//! run-shape, and protected-path policies follow (CLOUD-202 items 3–5). +//! +//! **Posture: fail open.** Unreadable stdin, unparseable JSON, an envelope with +//! no command — all resolve to [`Decision::Allow`]. A guard must never be the +//! reason a session cannot proceed; the escape hatch (`BATTEN_GH_GUARD_BYPASS`) +//! is honoured exactly as the shell guard honours it. The exit-code contract +//! inversion (§7: under `hook`, exit `2` denies) lives at this layer only, in +//! [`Harness::exit_code_for`]. + +use serde::Serialize; +use serde_json::Value; + +/// The harness adapters `batten hook` can speak. Each owns the decode of its +/// host's payload into an [`Envelope`] and the encode of a [`Decision`] into +/// what that host consumes; the core between them is harness-blind. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum Harness { + /// Claude Code's `PreToolUse` payload; a deny is returned as the + /// `hookSpecificOutput.permissionDecision` JSON object on stdout with exit + /// `0` — the channel the production shell guards already use. + ClaudeCode, + /// The neutral core contract: envelope in, decision as exit code out — + /// `0` allow, `2` deny (reason on stderr). The §7 inversion, for any host + /// whose only decision channel is an exit status. + ExitCode, +} + +/// The normalized hook envelope — the shape the core adjudicates, whatever the +/// host called its fields. `session` is optional by design: some harnesses +/// expose two ids, some events none, so anything keyed on it degrades to +/// per-invocation (contracts doc). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Envelope { + /// The lifecycle event, e.g. `PreToolUse`. + pub event: String, + /// The tool being mediated, e.g. `Bash`. + pub tool: String, + /// The command text for shell-shaped tools; empty when the tool has none. + pub command: String, +} + +/// The adjudication verdict. `Deny` carries the reason, which by the refusal +/// contract (CLOUD-122) names the redirect — the exact command to run instead — +/// and the bypass hatch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Decision { + /// Let the mediated call proceed. + Allow, + /// Block the mediated call, with an actionable reason. + Deny(String), +} + +/// Decode a harness payload into the normalized envelope. +/// +/// Fail-open by returning `None` for anything that does not decode: absent +/// fields are an allow, never an error. Claude Code's `PreToolUse` shape is +/// `{hook_event_name, tool_name, tool_input: {command, …}, …}`. +#[must_use] +pub fn decode(harness: Harness, raw: &str) -> Option { + match harness { + Harness::ClaudeCode | Harness::ExitCode => { + let value: Value = serde_json::from_str(raw).ok()?; + let command = value + .pointer("/tool_input/command") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + Some(Envelope { + event: value + .get("hook_event_name") + .and_then(Value::as_str) + .unwrap_or("PreToolUse") + .to_owned(), + tool: value + .get("tool_name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + command, + }) + } + } +} + +/// Adjudicate an envelope against the policy tables. +/// +/// `bypass` is the caller-resolved escape hatch (the boundary reads +/// `BATTEN_GH_GUARD_BYPASS`, keeping this function pure and testable). +#[must_use] +pub fn adjudicate(envelope: &Envelope, bypass: bool) -> Decision { + if bypass || envelope.command.is_empty() { + return Decision::Allow; + } + gh_lifecycle(&envelope.command) +} + +/// The `gh` lifecycle policy, ported from `mise-tasks/gh-guard-check`: deny a +/// hand-rolled shape this repo's config routes through a task, allow reads and +/// everything unrecognised. +fn gh_lifecycle(command: &str) -> Decision { + for segment in segments(command) { + let tokens: Vec<&str> = segment.split_whitespace().collect(); + let Some(program_index) = effective_program(&tokens) else { + continue; + }; + if tokens[program_index] != "gh" { + continue; + } + // Subcommand words with flags dropped. A value-taking flag leaves its + // value behind, but the blocked pairs are adjacent, so that never + // hides a real match (`gh -R o/r pr merge` still matches; `gh pr view + // merge-fix` never does). + let words: Vec<&str> = tokens[program_index + 1..] + .iter() + .copied() + .filter(|t| !t.starts_with('-')) + .collect(); + for pair in words.windows(2) { + let decision = match (pair[0], pair[1]) { + ("pr", "merge") => Some( + "Refused: `gh pr merge` is blocked — it (like the merge button) rewrites \ + commits under new SHAs, discarding the exact objects CI tested. Use `mise \ + run land`, which comments /fast-forward so main advances to this branch's \ + already-passed commits. Bypass with BATTEN_GH_GUARD_BYPASS=1.", + ), + // Only a comment carrying the landing directive; an ordinary + // `gh pr comment` is not the lifecycle. Tested against the + // ORIGINAL command, since the directive lives inside the + // quoted --body that scrubbing neutralised. + ("pr", "comment") if command.contains("fast-forward") => Some( + "Refused: commenting /fast-forward by hand only STARTS the merge — it does \ + not wait for it, and the usual pairing with a guessed `sleep` reports \ + before the merge lands. Use `mise run land` (backgrounded): it comments, \ + then blocks until the PR is MERGED or the fast-forward bot refuses. Bypass \ + with BATTEN_GH_GUARD_BYPASS=1.", + ), + ("pr", "checks") | ("run", "watch") => Some( + "Refused: hand-watching CI is the ad-hoc shape `mise run ci-wait` replaces. \ + It polls check-runs for HEAD until every one is terminal, prints their \ + conclusions, and exits non-zero unless all are green — with no timeout to \ + reintroduce the VM-reap gap. Background it. (`gh pr view`/`list`/`create`, \ + `gh pr ready`, `gh api`, `gh run view` are NOT blocked.) Bypass with \ + BATTEN_GH_GUARD_BYPASS=1.", + ), + _ => None, + }; + if let Some(reason) = decision { + return Decision::Deny(reason.to_owned()); + } + } + } + Decision::Allow +} + +/// Neutralise quoted spans, then split the command on shell separators, so +/// `git commit -m "gh pr merge"` carries no `gh` segment at all. +fn segments(command: &str) -> Vec { + let mut scrubbed = String::with_capacity(command.len()); + let mut chars = command.chars(); + while let Some(c) = chars.next() { + match c { + '\'' | '"' => { + let quote = c; + // Consume through the closing quote; an unterminated span runs + // to the end, which still neutralises it. + for inner in chars.by_ref() { + if inner == quote { + break; + } + } + scrubbed.push_str("QUOTED"); + } + _ => scrubbed.push(c), + } + } + scrubbed + .replace("&&", "\n") + .replace("||", "\n") + .replace([';', '|', '&'], "\n") + .lines() + .map(str::to_owned) + .collect() +} + +/// Find the index of the effective program in a segment's tokens: skip +/// `VAR=value` env prefixes, then look through known wrapper programs so the +/// wrapped program is judged, not the wrapper. Known wrappers only; anything +/// unrecognised keeps the fail-open posture. +fn effective_program(tokens: &[&str]) -> Option { + let mut i = 0; + while i < tokens.len() && is_env_assignment(tokens[i]) { + i += 1; + } + loop { + match *tokens.get(i)? { + "env" | "command" | "nice" | "stdbuf" | "timeout" | "xargs" | "sudo" | "doas" => { + i += 1; + // The wrapper's own flags, env assignments, and bare numeric + // arguments (timeout's duration) precede the wrapped program. + while i < tokens.len() + && (tokens[i].starts_with('-') + || is_env_assignment(tokens[i]) + || tokens[i].starts_with(|c: char| c.is_ascii_digit())) + { + i += 1; + } + } + "mise" => { + // Only `mise exec` / `mise x` run another program; `mise run` + // names a task, which is the sanctioned surface. + match tokens.get(i + 1) { + Some(&("exec" | "x")) => { + i += 2; + // Tool pins (node@22), flags, and the `--` separator + // precede the program. + while i < tokens.len() + && (tokens[i].starts_with('-') || tokens[i].contains('@')) + { + i += 1; + } + } + _ => return Some(i), + } + } + _ => return Some(i), + } + } +} + +fn is_env_assignment(token: &str) -> bool { + let mut chars = token.chars(); + matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_') + && token + .chars() + .take_while(|&c| c != '=') + .all(|c| c.is_ascii_alphanumeric() || c == '_') + && token.contains('=') +} + +/// Claude Code's deny payload: the `hookSpecificOutput.permissionDecision` +/// object the host reads from stdout. Field order is struct order, so the +/// emission is byte-stable. +#[derive(Serialize)] +struct ClaudeDeny<'a> { + #[serde(rename = "hookSpecificOutput")] + hook_specific_output: ClaudeDenyInner<'a>, +} + +#[derive(Serialize)] +struct ClaudeDenyInner<'a> { + #[serde(rename = "hookEventName")] + hook_event_name: &'a str, + #[serde(rename = "permissionDecision")] + permission_decision: &'a str, + #[serde(rename = "permissionDecisionReason")] + permission_decision_reason: &'a str, +} + +/// Encode a deny for the Claude Code adapter. +/// +/// # Errors +/// +/// Serialization of this fixed shape cannot practically fail; the `Result` is +/// the honest signature for a serde boundary. +pub fn encode_claude_deny(event: &str, reason: &str) -> serde_json::Result { + serde_json::to_string(&ClaudeDeny { + hook_specific_output: ClaudeDenyInner { + hook_event_name: event, + permission_decision: "deny", + permission_decision_reason: reason, + }, + }) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + fn adjudicate_command(command: &str) -> Decision { + adjudicate( + &Envelope { + event: "PreToolUse".to_owned(), + tool: "Bash".to_owned(), + command: command.to_owned(), + }, + false, + ) + } + + fn is_deny(command: &str) -> bool { + matches!(adjudicate_command(command), Decision::Deny(_)) + } + + #[test] + fn blocked_shapes_are_denied() { + assert!(is_deny("gh pr merge 42")); + assert!(is_deny("gh pr checks --watch")); + assert!(is_deny("gh run watch 123")); + assert!(is_deny("gh pr comment 7 --body /fast-forward")); + } + + #[test] + fn wrapper_lookthrough_judges_the_effective_program() { + // The web-sandbox shape: the wrapper form is the only working form, so + // a guard that stops at the wrapper token sees none of the calls that + // matter (CLOUD-181). + assert!(is_deny("mise exec -- gh pr merge 42")); + assert!(is_deny("env GH_PAGER= gh pr merge")); + assert!(is_deny("timeout 30 gh pr checks")); + assert!(is_deny("FOO=bar gh pr merge")); + } + + #[test] + fn interposed_flag_values_do_not_hide_a_match() { + assert!(is_deny("gh -R owner/repo pr merge")); + } + + #[test] + fn reads_and_lookalikes_are_allowed() { + assert!(!is_deny("gh pr view 42")); + assert!(!is_deny("gh pr ready 42")); + assert!(!is_deny("gh pr view merge-fix")); + assert!(!is_deny("gh api repos/o/r/pulls")); + assert!(!is_deny("mise run land")); + assert!(!is_deny("gh pr comment 7 --body thanks")); + } + + #[test] + fn quoted_spans_are_not_commands() { + assert!(!is_deny("git commit -m \"gh pr merge\"")); + assert!(!is_deny("echo 'gh run watch'")); + } + + #[test] + fn a_denied_shape_in_any_segment_is_a_deny() { + assert!(is_deny("git push && gh pr merge 42")); + } + + #[test] + fn bypass_allows_everything() { + let envelope = Envelope { + event: "PreToolUse".to_owned(), + tool: "Bash".to_owned(), + command: "gh pr merge".to_owned(), + }; + assert_eq!(adjudicate(&envelope, true), Decision::Allow); + } + + #[test] + fn decode_reads_the_claude_payload() { + let raw = r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"gh pr merge"}}"#; + let envelope = decode(Harness::ClaudeCode, raw).expect("decodes"); + assert_eq!(envelope.command, "gh pr merge"); + assert_eq!(envelope.tool, "Bash"); + } + + #[test] + fn decode_fails_open_on_junk() { + assert_eq!(decode(Harness::ClaudeCode, "not json"), None); + // A payload with no command decodes to an empty command, which + // adjudicates to Allow rather than erroring. + let envelope = decode(Harness::ClaudeCode, "{}").expect("decodes"); + assert_eq!(adjudicate(&envelope, false), Decision::Allow); + } + + #[test] + fn the_claude_deny_shape_is_byte_stable() { + let one = encode_claude_deny("PreToolUse", "reason").expect("serializes"); + let two = encode_claude_deny("PreToolUse", "reason").expect("serializes"); + assert_eq!(one, two); + assert!(one.contains("\"permissionDecision\":\"deny\"")); + } +} diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 18a3e1ba7..de1420109 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -13,6 +13,7 @@ pub mod config; pub mod effect; pub mod error; pub mod exit; +pub mod hook; pub mod identity; pub mod resolve; pub mod rules; @@ -20,7 +21,7 @@ pub mod severity; pub mod spec; pub mod state; -use std::io::Write; +use std::io::{Read, Write}; use std::path::Path; use anyhow::Result; @@ -64,6 +65,43 @@ pub fn run(cli: Cli, out: &mut dyn Write) -> Result { Some(Command::Enforce) => run_rules(out, overrides, rules::run_all), Some(Command::Config { command }) => run_config(&command, overrides, out), Some(Command::Spec { format }) => run_spec(format, out), + Some(Command::Hook { harness }) => run_hook(harness, out), + } +} + +/// Adjudicate one mediated call read from stdin (CLOUD-202). +/// +/// Fail open at every boundary — unreadable stdin, an undecodable payload, an +/// envelope with no command all allow: a guard must never be the reason a +/// session cannot proceed. The bypass env var is the same hatch the shell +/// guards honour, resolved here at the boundary so the core stays pure. +/// +/// The deny channel is per-harness: the Claude Code adapter answers in the +/// host's JSON decision object (exit 0); the neutral exit-code adapter is the +/// §7 inversion — exit 2 denies, with the reason on stderr via the usage-error +/// path, which is the one sanctioned stderr boundary. +fn run_hook(harness: hook::Harness, out: &mut dyn Write) -> Result { + let mut raw = String::new(); + if std::io::stdin().read_to_string(&mut raw).is_err() { + return Ok(ExitCode::Success); + } + let bypass = std::env::var_os("BATTEN_GH_GUARD_BYPASS").is_some_and(|v| !v.is_empty()); + let Some(envelope) = hook::decode(harness, &raw) else { + return Ok(ExitCode::Success); + }; + match hook::adjudicate(&envelope, bypass) { + hook::Decision::Allow => Ok(ExitCode::Success), + hook::Decision::Deny(reason) => match harness { + hook::Harness::ClaudeCode => { + writeln!( + out, + "{}", + hook::encode_claude_deny(&envelope.event, &reason)? + )?; + Ok(ExitCode::Success) + } + hook::Harness::ExitCode => Err(UsageError::raise(reason)), + }, } } diff --git a/crates/batten/tests/cli.rs b/crates/batten/tests/cli.rs index 9d85f19df..98019f37b 100644 --- a/crates/batten/tests/cli.rs +++ b/crates/batten/tests/cli.rs @@ -8,13 +8,49 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use std::fs; +use std::io::Write; use std::path::PathBuf; -use std::process::Command; +use std::process::{Command, Output, Stdio}; fn batten() -> Command { Command::new(env!("CARGO_BIN_EXE_batten")) } +/// Run `batten hook --harness ` with `payload` piped to stdin. +/// +/// 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 { + let mut command = batten(); + command + .args(["hook", "--harness", harness]) + .env_remove("BATTEN_GH_GUARD_BYPASS") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if bypass { + command.env("BATTEN_GH_GUARD_BYPASS", "1"); + } + let mut child = command.spawn().expect("spawn batten hook"); + child + .stdin + .take() + .expect("piped stdin") + .write_all(payload.as_bytes()) + .expect("write payload"); + child.wait_with_output().expect("run batten hook") +} + +/// A Claude Code `PreToolUse` payload wrapping one Bash command. +fn claude_payload(command: &str) -> String { + serde_json::json!({ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { "command": command } + }) + .to_string() +} + /// Create a fresh temp directory under the test target dir containing a /// `batten.toml` with `contents`, and return its path so a command can run there. fn repo_with_config(name: &str, contents: &str) -> PathBuf { @@ -548,6 +584,68 @@ fn a_local_override_may_add_a_rule_but_not_redefine_one() { ); } +#[test] +fn hook_denies_a_blocked_shape_in_the_harness_channel() { + // The claude-code adapter answers in the host's JSON decision object with + // exit 0 — the channel the production shell guards already use. The + // wrapper form is the load-bearing case: judging the wrapper token instead + // of the effective program is the bug class CLOUD-181 hardened against. + let output = run_hook( + "claude-code", + &claude_payload("mise exec -- gh pr merge 42"), + false, + ); + assert_eq!(output.status.code(), Some(0)); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("\"permissionDecision\":\"deny\""), + "got: {stdout}" + ); + assert!( + stdout.contains("mise run land"), + "the deny must name the redirect" + ); +} + +#[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); + assert_eq!(output.status.code(), Some(0), "command: {command}"); + assert!( + output.stdout.is_empty(), + "an allow emits nothing: {command}" + ); + } +} + +#[test] +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); + 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); + assert_eq!(output.status.code(), Some(0)); + assert!(output.stdout.is_empty()); +} + +#[test] +fn hook_exit_code_harness_denies_with_exit_2() { + // The §7 inversion, at the hook layer only: under `hook` exit 2 denies, + // with the reason on stderr — the neutral channel for a host whose only + // decision vocabulary is an exit status. + let output = run_hook("exit-code", &claude_payload("gh pr merge 42"), false); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("Refused")); +} + #[test] fn bare_invocation_lists_subcommands() { // §2: bare invocation lists subcommands and never performs a default