diff --git a/CONTRACT.md b/CONTRACT.md index d89c6c5..4732aba 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -15,6 +15,46 @@ fail-open guarantee. This is deliberate: correctness never depends on the gate being present or healthy. +**Fail-open is not fail-silent.** A gate that cannot evaluate must say so; a check that did not run +must never be reported as a check that passed. Concretely (commitward#7): + +- The **CLI** keeps exit 0 on a malformed registry, with a stderr diagnostic — unchanged. +- The **`gate` envelope** returns `status: "error"` and a non-zero exit when a *supplied* registry + will not parse. Under ADR-0052 that tells the consumer "do not trust this result, fall back to + your in-process path", so the system still fails open — audibly at both layers rather than + silently at one. An **absent** registry remains an empty set: supplying nothing is a + configuration choice, supplying something unparseable is a defect. +- Every `ok` envelope carries `body.warnings`, naming the guards that could not run. + +**The default registry carries self-protection, with a documented residual.** The shipped +`checkpoints.yaml` carries `gate-self-mod` (path) and `checkpoint-removed` (semantic), so removing +*a* checkpoint and exercising what it guarded in the same commit fires two independent guards rather +than nothing. + +Those two entries do not survive removal of themselves — they live in the file they guard, and +`checkpoint-removed` additionally needs base checkpoint names, so with no base registry it cannot +fire at all (commitward#4). A registry cannot be the sole thing that protects the registry. + +**So one checkpoint is not in the registry.** `compile()` merges `anchor_checkpoints()` — +`anchor-gate-integrity`, compiled into the binary — into *every* registry, including an empty one, +and applies it last so a same-named on-disk entry cannot shadow it. It watches the gate's own files +(`checkpoints.yaml` at any depth, `.commitward/checkpoints.yaml`, the commit-msg hook, +`install-hook.sh`). There is no edit to a YAML file that removes it, and no registry at all is still +not an unguarded gate. + +Consequences worth stating: a commit that touches a registry or the hook now **always** fires at +least one checkpoint, including the commit that first adopts a registry — acknowledge it with a +`HITL-ACK` line like any other. And the anchor is deliberately narrow: it covers the gate's own +integrity, not policy. An anchor that grew to cover policy would be a second registry that no repo +could declare or amend, which is the thing this design exists to avoid. + +Still open: `residual_gap_adr0010_checkpoint_removed_itself_removed` — a removed +`checkpoint_removed` entry still produces no *semantic* fire. The anchor covers the act (the file +changed), not the semantics of what was removed from it. + +This remains an honest-operator control, not an adversarial one — the acknowledgement protocol below +is self-acknowledgeable by the committing agent, by design. + ## Front door 1 — CLI ``` @@ -28,6 +68,10 @@ commitward [OPTIONS] | `--commit-msg-file ` | — | file holding the commit message to scan for `HITL-ACK:` trailers | | `--registry ` | `$COMMITWARD_REGISTRY`, else `checkpoints.yaml` beside the binary | global checkpoint baseline | | `--repo-registry ` | `.commitward/checkpoints.yaml` | repo-local overrides (override global by name) | + +Both registry paths, plus the installed `commit-msg` hook and `install-hook.sh`, are guarded by the +default `gate-self-mod` checkpoint. A registry located via `$COMMITWARD_REGISTRY` cannot be matched +by a static pattern — add its path to `gate-self-mod` yourself if you use that variable. | `--format ` | `text` | output format | | `-h`, `--help` | — | usage | diff --git a/README.md b/README.md index 0dc11a0..35c4cf9 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,15 @@ Request fields (all optional; evaluation fails open on absent inputs): `diff`, ` `base_repo_registry_yaml` (for checkpoint-removed detection). The block **decision** is carried in `body.exit_class` (0 none fired · 1 fired-but-all-acked · 2 unacked fire), **not** the process exit code — the process exits 0 on any successful evaluation, so a consumer never mistakes a fired gate -for a transport failure. A malformed request yields `status:"error"` + a non-zero exit. The native -git-reading CLI above stays the path for commit-msg hooks and standalone use. +for a transport failure. A malformed request — or a **supplied registry that will not parse** — +yields `status:"error"` + a non-zero exit. The native git-reading CLI above stays the path for +commit-msg hooks and standalone use. + +`body.warnings` is a (possibly empty) list of checks that could **not** be performed. `exit_class: +0` means "nothing fired", which is indistinguishable from "nothing was checked" unless the response +says so — e.g. omitting `base_*_registry_yaml` leaves the `checkpoint-removed` guard inactive, and +supplying no registry at all means every commit passes. Treat a non-empty `warnings` as a +configuration defect, not as noise. ## License diff --git a/checkpoints.yaml b/checkpoints.yaml index b60d05e..7448a16 100644 --- a/checkpoints.yaml +++ b/checkpoints.yaml @@ -6,6 +6,44 @@ checkpoints: - "(^|/)CLAUDE\\.md$" - "(^|/)AGENTS\\.md$" - "(^|/)rules/.*\\.md$" + - name: gate-self-mod + summary: "Modifying the gate itself — its registry, or the hook that invokes it" + # NF2 (commitward#7). Without this, an agent can delete a checkpoint and use the + # thing that checkpoint guarded in the SAME commit, and nothing fires: the deleted + # checkpoint cannot fire, and no other checkpoint in this file watches the registry. + # + # This entry is still removable — it lives in the file it guards. That leg is covered + # by `anchor-gate-integrity`, compiled into the binary and merged into every registry + # by `compile()` (commitward#9), which watches these same paths and cannot be deleted + # by editing YAML. Keep this one anyway: a repo that narrows or widens the on-disk + # version stays in control of its own policy, and two independent guards on the same + # act means neither is a single point of failure. + # + # These are the paths *commitward itself* resolves — the default registry beside the + # executable, the repo-local override (`main.rs`), and the installed commit-msg hook, + # which is the quieter way to disable the gate. A registry pointed at by + # COMMITWARD_REGISTRY cannot be matched by a static pattern; add its path here if you + # use that variable. + # + # `(^|/)checkpoints\.yaml$` deliberately matches at any depth, so a registry vendored + # under a subdirectory (or a test fixture registry) is guarded too. It fires — it does + # not block; a deliberate change is acknowledged with a HITL-ACK line. + paths: + - "(^|/)checkpoints\\.yaml$" + - "(^|/)\\.commitward/checkpoints\\.yaml$" + - "(^|/)\\.git-hooks/commit-msg$" + - "(^|/)\\.git/hooks/commit-msg$" + - "(^|/)install-hook\\.sh$" + - name: checkpoint-removed + summary: "A checkpoint was removed from a registry (the gate was weakened)" + # The semantic counterpart to gate-self-mod: that one fires on *touching* a registry, + # this one on a checkpoint present at the base ref being absent now. Two independent + # guards on the same act, so neither is a single point of failure. + # + # Requires the caller to supply base checkpoint names (`--base`, or the base registry + # in a `gate` request). Without them this cannot fire — which is why an absent base is + # now reported as a warning rather than passing silently (NF3). + semantic: checkpoint_removed - name: destructive-ops summary: "Destructive git/filesystem command entering a committed file" content: diff --git a/src/lib.rs b/src/lib.rs index 373a55e..714a260 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -205,13 +205,59 @@ pub fn merge(global: Vec, repo: Vec) -> Vec out } +/// The out-of-band anchor: checkpoints [`compile`] applies no matter what the on-disk +/// registry says (commitward#9). +/// +/// The registry cannot be the sole protector of the registry. `gate-self-mod` and +/// `checkpoint-removed` ship in the default registry, which means deleting the registry +/// deletes its own guard **in the same act** — and `checkpoint-removed` cannot fire at all +/// without a base registry to compare against (commitward#4). A single commit that dropped +/// the whole file therefore fired nothing, and the shipped `checkpoints.yaml` said so in +/// its own comments. +/// +/// So this one lives in the binary. There is no edit to a YAML file that removes it, and +/// no name a registry can declare that shadows it — [`compile`] applies the anchor *after* +/// the user's entries, so a same-named entry loses. +/// +/// Deliberately minimal: one path checkpoint over the registry and hook files themselves. +/// An anchor that grew to cover policy would be a second, undeclarable registry — the +/// thing this codebase exists to avoid. Everything else stays editable, on disk, where a +/// repo can see and change it. +pub fn anchor_checkpoints() -> Vec { + vec![Checkpoint { + name: "anchor-gate-integrity".to_string(), + summary: "the commit gate's own registry or hook changed — compiled-in anchor, not \ + removable by editing a registry" + .to_string(), + standards_doc: None, + paths: vec![ + r"(^|/)\.commitward/checkpoints\.yaml$".to_string(), + r"(^|/)checkpoints\.yaml$".to_string(), + r"(^|/)\.git-hooks/commit-msg$".to_string(), + r"(^|/)\.git/hooks/commit-msg$".to_string(), + r"(^|/)install-hook\.sh$".to_string(), + ], + content: vec![], + content_exempt_paths: vec![], + semantic: None, + }] +} + /// Compile raw checkpoints into their matched representations. /// /// Each checkpoint must declare exactly one of `paths`, `content`, or /// `semantic`; mixed declarations produce `Err(AmbiguousMode)`. All regex /// patterns are compiled through the private `compile_ci` helper (case-insensitive). +/// +/// [`anchor_checkpoints`] is merged in last, so **every** compiled registry — including an +/// empty one — carries the anchor. Placing it here rather than at the call sites is the +/// point: a consumer cannot obtain a compiled registry without it, so the floor does not +/// depend on each caller remembering to add it. pub fn compile(cps: Vec) -> Result, CheckpointError> { - cps.into_iter().map(compile_one).collect() + merge(cps, anchor_checkpoints()) + .into_iter() + .map(compile_one) + .collect() } fn compile_one(cp: Checkpoint) -> Result { @@ -775,8 +821,13 @@ checkpoints: // ADR-0010 known limitation: when the `checkpoint_removed`-semantic entry // is itself removed from the registry, no SemanticKind::CheckpointRemoved // checkpoint remains in the compiled set to trigger detection. Consequently - // no `checkpoint-removed` Fired is produced. Path-based `gate-self-mod` - // (guarding checkpoints.yaml) is the practical backstop for this scenario. + // no `checkpoint-removed` Fired is produced. + // + // Still true, and no longer the end of the story: the backstop used to be + // `gate-self-mod`, which lives in the same removable file. It is now + // `anchor-gate-integrity`, compiled into the binary (commitward#9) — this test's + // `files` deliberately do not include a registry path, which is why nothing fires + // here and the semantic gap stays visible rather than being masked by the anchor. let base_names = vec![ "guard-a".to_string(), "guard-b".to_string(), diff --git a/src/main.rs b/src/main.rs index 30107f9..75b3853 100644 --- a/src/main.rs +++ b/src/main.rs @@ -118,6 +118,10 @@ fn gate_envelope(input: &str) -> Result { // self-cleaning temp file; an absent registry is an empty set (fail-open, mirrors the native CLI). let global_cps = load_inlined_registry(req.global_registry_yaml.as_deref(), "global")?; let repo_cps = load_inlined_registry(req.repo_registry_yaml.as_deref(), "repo")?; + // Asked *before* compile, which now always adds the compiled-in anchor (commitward#9). + // The NF3 warning below is about what the caller supplied — "you configured nothing" is + // still true and still worth saying when the only thing standing is the anchor. + let no_registry_supplied = global_cps.is_empty() && repo_cps.is_empty(); let compiled = compile(merge(global_cps, repo_cps)).map_err(|e| format!("registry compile error: {e}"))?; @@ -146,22 +150,56 @@ fn gate_envelope(input: &str) -> Result { let (_acked, unacked) = partition_ack(&fired, &acks); let ec = exit_class(fired.len(), unacked.len()); + // NF3: name every guard that could not run. A clean `exit_class: 0` means "nothing + // fired", which a consumer reads as "nothing to worry about" — so the result has to + // say which checks were not performed, or the two are indistinguishable. + let mut warnings: Vec = Vec::new(); + if no_registry_supplied { + warnings.push( + "no checkpoints were supplied (global_registry_yaml / repo_registry_yaml both \ + absent or empty) — only the compiled-in gate-integrity anchor applies, so every \ + commit that does not touch the gate's own files passes" + .to_string(), + ); + } + if base_names.is_none() { + warnings.push( + "no base registry supplied (base_repo_registry_yaml / base_global_registry_yaml) \ + — the checkpoint-removed guard is INACTIVE for this call, so a checkpoint \ + deleted in this change will not be detected" + .to_string(), + ); + } + let unacked_names: Vec<&str> = unacked.iter().map(|f| f.name.as_str()).collect(); let body = serde_json::json!({ "fired": &fired, "unacked": unacked_names, "exit_class": ec, + "warnings": warnings, }); Ok(ok_envelope(body)) } -/// Load an inlined-YAML registry via a self-cleaning temp file. A parse error is fail-open (empty -/// set, as the native CLI does); only a temp-file infrastructure error propagates. +/// Load an inlined-YAML registry via a self-cleaning temp file. +/// +/// A parse error **propagates** (NF3, commitward#7). It used to be swallowed into an empty +/// checkpoint set, which meant a malformed registry produced a clean, `status: "ok"` pass — +/// a security control reporting success precisely when it could not run. That is the +/// fail-*silent* direction, and it is the one failure mode a gate must never have. +/// +/// The caller turns this into an `error` envelope, which ADR-0052 defines as "do not trust +/// this result, fall back to your in-process path". The system still fails open overall — +/// commitward is not a blocking control — but it now does so audibly at both layers instead +/// of silently at one. +/// +/// An **absent** registry is still an empty set: supplying nothing is a configuration +/// choice, supplying something unparseable is a defect. fn load_inlined_registry(yaml: Option<&str>, label: &str) -> Result, String> { match yaml { Some(y) => { let tmp = write_temp_yaml(y, label)?; - Ok(load_checkpoints(&tmp.0).unwrap_or_default()) + load_checkpoints(&tmp.0).map_err(|e| format!("{label} registry failed to parse: {e}")) } None => Ok(vec![]), } diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index d6f0f1a..8f15bb4 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -158,7 +158,15 @@ fn ack_trailer_lifts_the_block_to_exit_1() { let d = &repo.dir; add_guarded_change(d); let msg = d.join("msg.txt"); - std::fs::write(&msg, "add danger\n\nHITL-ACK: danger-file smoke test\n").unwrap(); + // Two fires now, not one: `add_guarded_change` commits `.commitward/checkpoints.yaml`, + // and the compiled-in anchor watches the gate's own files (commitward#9). Both must be + // acked for the block to lift — which is the contract, "a *matching* ack per fire". + std::fs::write( + &msg, + "add danger\n\nHITL-ACK: danger-file smoke test\n\ + HITL-ACK: anchor-gate-integrity adopting a registry is a gate change\n", + ) + .unwrap(); let out = commitward( d, &[ @@ -179,3 +187,182 @@ fn ack_trailer_lifts_the_block_to_exit_1() { String::from_utf8_lossy(&out.stderr) ); } + +/// NF2, driven through the **real CLI** against the **shipped** registry (commitward#7). +/// +/// `default_registry.rs` proves the same scenario at the engine layer (`detect(&compiled, …)`). +/// That validates the logic but not the wrapper a consumer actually runs: argument parsing, +/// registry resolution, base-ref diffing and the exit-code contract all sit in `main::run`, and +/// none of them were on the NF2 path. Review on #8 asked for that seam to be closed. +/// +/// The scenario: an agent removes `destructive-ops` from the repo-local registry AND introduces a +/// dangerous recursive-force removal, in ONE commit. `destructive-ops` cannot fire — it no longer +/// exists — so the only thing between this and a clean gate is the registry's self-protection. +#[test] +fn nf2_registry_weakening_fires_through_the_real_cli() { + let (repo, base) = setup("nf2-cli"); + let d = &repo.dir; + + // Base commit: the shipped default registry, copied in as the repo-local one so this + // exercises the real file rather than a fixture written for the test. + let shipped = std::fs::read_to_string( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("checkpoints.yaml"), + ) + .expect("the shipped default registry must be readable"); + std::fs::create_dir_all(d.join(".commitward")).unwrap(); + std::fs::write(d.join(".commitward/checkpoints.yaml"), &shipped).unwrap(); + git(d, &["add", ".commitward/checkpoints.yaml"]); + assert!( + git(d, &["commit", "-m", "adopt the default registry"]) + .status + .success(), + "registry commit" + ); + let base_with_registry = rev_parse_head(d); + let _ = base; + + // The attack, in one commit: delete the guard, use what it guarded. + let weakened: String = shipped + .lines() + .scan(false, |skipping, line| { + if line.starts_with(" - name: ") { + *skipping = line.contains("destructive-ops"); + } + Some(if *skipping { None } else { Some(line) }) + }) + .flatten() + .collect::>() + .join("\n"); + assert!( + !weakened.contains("destructive-ops"), + "the test must actually remove the checkpoint it claims to" + ); + std::fs::write( + d.join(".commitward/checkpoints.yaml"), + format!("{weakened}\n"), + ) + .unwrap(); + std::fs::write(d.join("cleanup.sh"), "rm -rf / --no-preserve-root\n").unwrap(); + git(d, &["add", ".commitward/checkpoints.yaml", "cleanup.sh"]); + assert!( + git(d, &["commit", "-m", "tidy up"]).status.success(), + "attack commit" + ); + + let out = commitward(d, &["--base", &base_with_registry, "--format", "json"]); + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + + assert_eq!( + code, 2, + "removing a guard and using what it guarded, in one commit, must reach a human — \ + got exit {code}; stdout:\n{stdout}" + ); + assert!( + stdout.contains("gate-self-mod") || stdout.contains("checkpoint-removed"), + "the fire must name a self-protection checkpoint; stdout:\n{stdout}" + ); +} + +// ── commitward#9: the registry cannot be the sole protector of the registry ────── +// +// #8 shipped `gate-self-mod` and `checkpoint-removed` in the default registry. Both live +// *in* the registry, so deleting the registry deletes its own guard in the same act — and +// `checkpoint-removed` cannot fire when there is no base registry to compare against +// (#4). A single commit that removes the whole file therefore fired nothing at all: the +// guard was self-referential, and the PR's own `checkpoints.yaml` comments said so. +// +// The anchor is compiled into the binary, so there is no on-disk edit that removes it. + +/// The acceptance criterion from #9, driven through the shipped CLI on a real repo. +#[test] +fn anchor_fires_when_the_whole_registry_is_deleted_in_one_commit() { + let (repo, base) = setup("anchor-wipe"); + let d = &repo.dir; + let _ = base; + + // Base: a repo-local registry exists and guards something. + std::fs::create_dir_all(d.join(".commitward")).unwrap(); + std::fs::write(d.join(".commitward/checkpoints.yaml"), REGISTRY).unwrap(); + git(d, &["add", ".commitward/checkpoints.yaml"]); + assert!( + git(d, &["commit", "-m", "adopt a registry"]) + .status + .success(), + "registry commit" + ); + let base_with_registry = rev_parse_head(d); + + // The attack: delete the registry outright and use what it guarded, in one commit. + // Nothing on disk can fire afterwards — there is no registry left to fire from, and + // no global one either (--registry points at nothing). + assert!( + git(d, &["rm", "-q", ".commitward/checkpoints.yaml"]) + .status + .success(), + "git rm registry" + ); + std::fs::write(d.join("danger.sh"), "echo hi\n").unwrap(); + git(d, &["add", "danger.sh"]); + assert!( + git(d, &["commit", "-m", "simplify"]).status.success(), + "attack commit" + ); + + let out = commitward( + d, + &[ + "--base", + &base_with_registry, + "--registry", + "/nonexistent/global.yaml", + "--format", + "json", + ], + ); + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + + assert_eq!( + code, 2, + "deleting the registry that guards the registry must still reach a human — \ + got exit {code}; stdout:\n{stdout}" + ); + assert!( + stdout.contains("checkpoints.yaml"), + "the fire must name the registry file that was removed; stdout:\n{stdout}" + ); +} + +#[test] +fn anchor_does_not_fire_on_an_ordinary_commit() { + // Guard: an anchor that fires on everything is not a gate, it is a nuisance that + // teaches people to pass --format and ignore the output. Same repo shape as above, + // minus the registry edit. + let (repo, base) = setup("anchor-quiet"); + let d = &repo.dir; + std::fs::write(d.join("README.md"), "seed\nmore prose\n").unwrap(); + git(d, &["add", "README.md"]); + assert!( + git(d, &["commit", "-m", "docs"]).status.success(), + "ordinary commit" + ); + + let out = commitward( + d, + &[ + "--base", + &base, + "--registry", + "/nonexistent/global.yaml", + "--format", + "json", + ], + ); + let code = out.status.code().unwrap_or(-1); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + assert_eq!( + code, 0, + "an ordinary commit must still pass; stdout:\n{stdout}" + ); +} diff --git a/tests/corpus/conformance/detect-hitl/semantic/checkpoint-removed/expected.json b/tests/corpus/conformance/detect-hitl/semantic/checkpoint-removed/expected.json index b0c0fc9..387aa8c 100644 --- a/tests/corpus/conformance/detect-hitl/semantic/checkpoint-removed/expected.json +++ b/tests/corpus/conformance/detect-hitl/semantic/checkpoint-removed/expected.json @@ -1 +1 @@ -["checkpoint-removed", "gate-self-mod"] +["anchor-gate-integrity", "checkpoint-removed", "gate-self-mod"] diff --git a/tests/corpus/promise/checkpoints.yaml b/tests/corpus/promise/checkpoints.yaml index b60d05e..7448a16 100644 --- a/tests/corpus/promise/checkpoints.yaml +++ b/tests/corpus/promise/checkpoints.yaml @@ -6,6 +6,44 @@ checkpoints: - "(^|/)CLAUDE\\.md$" - "(^|/)AGENTS\\.md$" - "(^|/)rules/.*\\.md$" + - name: gate-self-mod + summary: "Modifying the gate itself — its registry, or the hook that invokes it" + # NF2 (commitward#7). Without this, an agent can delete a checkpoint and use the + # thing that checkpoint guarded in the SAME commit, and nothing fires: the deleted + # checkpoint cannot fire, and no other checkpoint in this file watches the registry. + # + # This entry is still removable — it lives in the file it guards. That leg is covered + # by `anchor-gate-integrity`, compiled into the binary and merged into every registry + # by `compile()` (commitward#9), which watches these same paths and cannot be deleted + # by editing YAML. Keep this one anyway: a repo that narrows or widens the on-disk + # version stays in control of its own policy, and two independent guards on the same + # act means neither is a single point of failure. + # + # These are the paths *commitward itself* resolves — the default registry beside the + # executable, the repo-local override (`main.rs`), and the installed commit-msg hook, + # which is the quieter way to disable the gate. A registry pointed at by + # COMMITWARD_REGISTRY cannot be matched by a static pattern; add its path here if you + # use that variable. + # + # `(^|/)checkpoints\.yaml$` deliberately matches at any depth, so a registry vendored + # under a subdirectory (or a test fixture registry) is guarded too. It fires — it does + # not block; a deliberate change is acknowledged with a HITL-ACK line. + paths: + - "(^|/)checkpoints\\.yaml$" + - "(^|/)\\.commitward/checkpoints\\.yaml$" + - "(^|/)\\.git-hooks/commit-msg$" + - "(^|/)\\.git/hooks/commit-msg$" + - "(^|/)install-hook\\.sh$" + - name: checkpoint-removed + summary: "A checkpoint was removed from a registry (the gate was weakened)" + # The semantic counterpart to gate-self-mod: that one fires on *touching* a registry, + # this one on a checkpoint present at the base ref being absent now. Two independent + # guards on the same act, so neither is a single point of failure. + # + # Requires the caller to supply base checkpoint names (`--base`, or the base registry + # in a `gate` request). Without them this cannot fire — which is why an absent base is + # now reported as a warning rather than passing silently (NF3). + semantic: checkpoint_removed - name: destructive-ops summary: "Destructive git/filesystem command entering a committed file" content: diff --git a/tests/default_registry.rs b/tests/default_registry.rs new file mode 100644 index 0000000..1b91345 --- /dev/null +++ b/tests/default_registry.rs @@ -0,0 +1,163 @@ +//! The **shipped** default registry, exercised as a consumer gets it. +//! +//! `corpus_detect_cases` in `src/lib.rs` covers the detection *engine* against a +//! fixture registry under `tests/corpus/`. That fixture carries `gate-self-mod` and +//! `checkpoint-removed`; the registry commitward actually ships did not. So the engine +//! was green while the product shipped with its self-protection off — the exact shape +//! of NF2 (commitward#7). +//! +//! Every test here loads `checkpoints.yaml` from the repo root: the file the Docker +//! image and the installers place beside the executable. + +use commitward::{compile, detect, load_checkpoints, Checkpoint, FileEntry, Fired}; +use std::collections::HashMap; +use std::path::PathBuf; + +fn shipped() -> Vec { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("checkpoints.yaml"); + load_checkpoints(&path).expect("the shipped default registry must load") +} + +fn modified(path: &str) -> FileEntry { + FileEntry { + status: 'M', + path: path.to_string(), + } +} + +fn fire(cps: Vec, files: &[FileEntry], base: Option<&[String]>) -> Vec { + let compiled = compile(cps).expect("the shipped registry must compile"); + let added: HashMap> = HashMap::new(); + let mut names: Vec = detect(&compiled, files, &added, base) + .iter() + .map(|f: &Fired| f.name.clone()) + .collect(); + names.sort(); + names +} + +fn fire_with_added( + cps: Vec, + files: &[FileEntry], + added: HashMap>, + base: Option<&[String]>, +) -> Vec { + let compiled = compile(cps).expect("the shipped registry must compile"); + let mut names: Vec = detect(&compiled, files, &added, base) + .iter() + .map(|f: &Fired| f.name.clone()) + .collect(); + names.sort(); + names +} + +fn names_of(cps: &[Checkpoint]) -> Vec { + cps.iter().map(|c| c.name.clone()).collect() +} + +#[test] +fn shipped_registry_guards_its_own_default_path() { + let fired = fire(shipped(), &[modified("checkpoints.yaml")], None); + assert!( + fired.contains(&"gate-self-mod".to_string()), + "editing the registry the gate reads must fire gate-self-mod; got {fired:?}" + ); +} + +#[test] +fn shipped_registry_guards_the_repo_local_override() { + // `.commitward/checkpoints.yaml` is where `main.rs` looks for the repo registry, so + // it is a second way to weaken the gate and must be guarded too. + let fired = fire(shipped(), &[modified(".commitward/checkpoints.yaml")], None); + assert!( + fired.contains(&"gate-self-mod".to_string()), + "the repo-local registry is equally load-bearing; got {fired:?}" + ); +} + +#[test] +fn shipped_registry_guards_the_installed_hook() { + // The commit-msg hook is what invokes the gate at all. Deleting it is a quieter way + // to disable the gate than editing any checkpoint. + let fired = fire(shipped(), &[modified(".git-hooks/commit-msg")], None); + assert!( + fired.contains(&"gate-self-mod".to_string()), + "the hook that invokes the gate must itself be guarded; got {fired:?}" + ); +} + +#[test] +fn shipped_registry_detects_a_removed_checkpoint() { + let base = names_of(&shipped()); + let mut weakened = shipped(); + weakened.retain(|c| c.name != "destructive-ops"); + let fired = fire( + weakened, + &[modified("checkpoints.yaml")], + Some(base.as_slice()), + ); + assert!( + fired.contains(&"checkpoint-removed".to_string()), + "removing a checkpoint from the registry must fire checkpoint-removed; got {fired:?}" + ); +} + +#[test] +fn nf2_deleting_the_guard_and_using_it_in_one_commit_does_not_pass_silently() { + // The headline NF2 scenario, end to end: the agent removes `destructive-ops` and + // introduces `rm -rf /` in the SAME commit. `destructive-ops` cannot fire — it no + // longer exists — so the only thing standing between this commit and a clean gate is + // the registry's self-protection. + let base = names_of(&shipped()); + let mut weakened = shipped(); + weakened.retain(|c| c.name != "destructive-ops"); + + let mut added: HashMap> = HashMap::new(); + added.insert( + "scripts/cleanup.sh".to_string(), + vec!["rm -rf / --no-preserve-root".to_string()], + ); + let files = vec![modified("checkpoints.yaml"), modified("scripts/cleanup.sh")]; + + let fired = fire_with_added(weakened, &files, added, Some(base.as_slice())); + assert!( + !fired.is_empty(), + "an agent that deletes the destructive-ops checkpoint and adds `rm -rf /` in one \ + commit must not sail through a silent gate — nothing fired" + ); + assert!( + fired.contains(&"checkpoint-removed".to_string()) + && fired.contains(&"gate-self-mod".to_string()), + "both guards should catch this independently, so neither is a single point of \ + failure; got {fired:?}" + ); +} + +#[test] +fn shipped_registry_stays_quiet_on_ordinary_work() { + // Guard: the tests above would all pass on a registry that fired on everything. A + // normal source edit must still produce a clean gate, or the gate becomes noise and + // gets switched off — which is the real-world failure mode for a checkpoint system. + let files = vec![modified("src/handler.rs"), modified("README.md")]; + let base = names_of(&shipped()); + let fired = fire(shipped(), &files, Some(base.as_slice())); + assert!( + fired.is_empty(), + "ordinary work must not fire anything; got {fired:?}" + ); +} + +#[test] +fn shipped_registry_still_catches_destructive_content() { + // Guard: adding the new checkpoints must not have disturbed the existing ones. + let mut added: HashMap> = HashMap::new(); + added.insert( + "scripts/cleanup.sh".to_string(), + vec!["rm -rf / --no-preserve-root".to_string()], + ); + let fired = fire_with_added(shipped(), &[modified("scripts/cleanup.sh")], added, None); + assert!( + fired.contains(&"destructive-ops".to_string()), + "the pre-existing destructive-ops checkpoint must still fire; got {fired:?}" + ); +} diff --git a/tests/gate_warnings.rs b/tests/gate_warnings.rs new file mode 100644 index 0000000..da44244 --- /dev/null +++ b/tests/gate_warnings.rs @@ -0,0 +1,145 @@ +//! NF3 (commitward#7): the `gate` subcommand must not report a clean pass for a check +//! it could not perform. +//! +//! `gate` is the containerized front door — the path a consuming harness invokes over +//! stdin/stdout — so a silent failure here is a silent failure in production, not just in +//! the CLI. Drives the real binary; no in-process shortcuts. + +use std::io::Write; +use std::process::{Command, Stdio}; + +const BIN: &str = env!("CARGO_BIN_EXE_commitward"); + +fn gate(request: &str) -> (i32, serde_json::Value) { + let mut child = Command::new(BIN) + .arg("gate") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn commitward gate"); + child + .stdin + .take() + .expect("stdin") + .write_all(request.as_bytes()) + .expect("write request"); + let out = child.wait_with_output().expect("wait"); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let json: serde_json::Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("gate must always emit one JSON envelope: {e}; got {stdout:?}")); + (out.status.code().unwrap_or(-1), json) +} + +const GOOD_REGISTRY: &str = r#" +version: "1" +checkpoints: + - name: guard-a + summary: Guards A + paths: + - "(^|/)a\\.txt$" +"#; + +#[test] +fn a_malformed_registry_is_an_error_envelope_not_a_clean_pass() { + // The registry is supplied and unparseable. Previously this was swallowed into an + // empty checkpoint set: status "ok", nothing fired, exit_class 0 — a security control + // reporting success exactly when it could not run. + let request = serde_json::json!({ + "diff": "", + "name_status": "M\ta.txt", + "commit_msg": "chore: something", + "global_registry_yaml": "checkpoints:\n - name: [unclosed\n summary: broken\n", + }) + .to_string(); + + let (code, env) = gate(&request); + assert_eq!( + env["status"], "error", + "a malformed registry must not yield a `status: ok` envelope; got {env}" + ); + assert_ne!(code, 0, "and must not exit 0"); + let msg = env["body"]["message"].as_str().unwrap_or_default(); + assert!( + msg.contains("global") && msg.contains("parse"), + "the message must name which registry failed and why; got {msg:?}" + ); +} + +#[test] +fn a_valid_registry_without_a_base_warns_that_checkpoint_removed_is_inactive() { + let request = serde_json::json!({ + "diff": "", + "name_status": "M\tsrc/lib.rs", + "commit_msg": "chore: something", + "global_registry_yaml": GOOD_REGISTRY, + }) + .to_string(); + + let (code, env) = gate(&request); + assert_eq!( + env["status"], "ok", + "a valid registry still evaluates: {env}" + ); + assert_eq!(code, 0); + let warnings = env["body"]["warnings"] + .as_array() + .expect("body.warnings must exist on every ok envelope") + .iter() + .map(|w| w.as_str().unwrap_or_default().to_string()) + .collect::>(); + assert!( + warnings.iter().any(|w| w.contains("checkpoint-removed")), + "with no base registry the checkpoint-removed guard cannot fire, and the caller \ + has no way to know that from `exit_class: 0` alone; got {warnings:?}" + ); +} + +#[test] +fn an_empty_checkpoint_set_warns_that_everything_passes() { + let request = serde_json::json!({ + "diff": "", + "name_status": "M\tsrc/lib.rs", + "commit_msg": "chore: something", + }) + .to_string(); + + let (_code, env) = gate(&request); + let warnings = env["body"]["warnings"].as_array().expect("warnings array"); + // The claim narrowed with commitward#9 — the compiled-in anchor still applies when no + // registry was supplied, so "every commit passes" would now be false. What must not + // change is that supplying nothing is reported as a configuration hole rather than + // read as a clean pass. + assert!( + warnings.iter().any(|w| { + let s = w.as_str().unwrap_or_default(); + s.contains("no checkpoints were supplied") && s.contains("passes") + }), + "no registry at all is the loudest silent pass there is; got {warnings:?}" + ); +} + +#[test] +fn a_fully_supplied_request_produces_no_warnings() { + // Guard: the tests above would pass on an implementation that warned unconditionally. + // A complete request must come back clean, or the warnings are noise and get ignored. + let request = serde_json::json!({ + "diff": "", + "name_status": "M\tsrc/lib.rs", + "commit_msg": "chore: something", + "global_registry_yaml": GOOD_REGISTRY, + "base_global_registry_yaml": GOOD_REGISTRY, + }) + .to_string(); + + let (code, env) = gate(&request); + assert_eq!(env["status"], "ok"); + assert_eq!(code, 0); + assert_eq!( + env["body"]["warnings"].as_array().map(|a| a.len()), + Some(0), + "a complete request must warn about nothing; got {}", + env["body"]["warnings"] + ); + assert_eq!(env["body"]["exit_class"], 0, "and still evaluate normally"); +}