Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand All @@ -28,6 +68,10 @@ commitward [OPTIONS]
| `--commit-msg-file <path>` | — | file holding the commit message to scan for `HITL-ACK:` trailers |
| `--registry <path>` | `$COMMITWARD_REGISTRY`, else `checkpoints.yaml` beside the binary | global checkpoint baseline |
| `--repo-registry <path>` | `.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\|json\|markdown>` | `text` | output format |
| `-h`, `--help` | — | usage |

Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
38 changes: 38 additions & 0 deletions checkpoints.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
57 changes: 54 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,59 @@ pub fn merge(global: Vec<Checkpoint>, repo: Vec<Checkpoint>) -> Vec<Checkpoint>
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<Checkpoint> {
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<Checkpoint>) -> Result<Vec<CompiledCheckpoint>, CheckpointError> {
cps.into_iter().map(compile_one).collect()
merge(cps, anchor_checkpoints())
.into_iter()
.map(compile_one)
.collect()
}

fn compile_one(cp: Checkpoint) -> Result<CompiledCheckpoint, CheckpointError> {
Expand Down Expand Up @@ -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(),
Expand Down
44 changes: 41 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ fn gate_envelope(input: &str) -> Result<String, String> {
// 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}"))?;

Expand Down Expand Up @@ -146,22 +150,56 @@ fn gate_envelope(input: &str) -> Result<String, String> {
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<String> = 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<Vec<Checkpoint>, 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![]),
}
Expand Down
Loading
Loading