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
24 changes: 18 additions & 6 deletions .serena/memories/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,25 @@ repo config > default`, declared as data in `SETTINGS` (per-key env var/flag),
command-executing rule rather than silently skipping it. Each rule pins a
required `severity` (`RuleSeverity`, no implicit fallback) and a separate
`scope` (`RuleScope`, pinned default `tree`) — two axes that never conflate
(CLOUD-61); `any_blocking` is where severity meets the exit contract.
(CLOUD-61); `any_blocking` is where severity meets the exit contract. `scope`
is also the surface router (CLOUD-48): `tree` rules are the tree engine's,
`mediated_call` rules are `hook`'s, and `RuleKind::scopes` refuses a pairing no
surface would evaluate. Per-kind column agreement is a **census**
(`Rule::columns` × `requires`/`permits`), not a per-kind match — the match
named fields, so a new column landed in no arm and every kind accepted 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. What varies per harness is the _channel_ a deny
travels over, never the number: the exit-code adapter denies with `2`, the
claude-code adapter with a `permissionDecision` document and exit `0`.
wrapper-lookthrough command parser, and the matcher. **The policy is config,
not code** (CLOUD-48): `Policy` is the `mediated_call`-scoped rows of the
_resolved_ config, so a `batten.local.toml` that adds a row is applied and
`--config-from` is inherited. The parser is quote-aware (CLOUD-269) — a quoted
operand survives as a word, which is what a path gate needs and what the old
`QUOTED` sentinel destroyed. Harness adapters decode/encode at the edges; the
core is harness-blind and fail-open. What varies per harness is the _channel_ a
deny travels over, never the number: the exit-code adapter denies with `2`, the
claude-code adapter with a `permissionDecision` document and exit `0`;
`Harness::ALL` is what keeps CLOUD-40's channel matrix total. Absent authority
is the empty policy (allow, silently); an authority that exists and cannot be
read is exit `1`, loud, never a deny.
- `markers.rs` — counted suppression markers (CLOUD-36): how many times policy
was waved through, and where. Tokens are config, never crate constants (rule
1); hits are pointer-only (`path:line` + marker id, rule 4) and `counts`
Expand Down
25 changes: 25 additions & 0 deletions batten.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,31 @@ pattern = "<<<<<<< "
severity = "deny"
scope = "tree"

# A shape rule refuses a banned COMMAND rather than a banned file, so it is
# adjudicated by `batten hook` against one mediated tool call at a time and is
# never evaluated by `check`. That is what `scope` selects: "tree" is the working
# tree, "mediated_call" is the single command being mediated.
#
# `pattern` is the command line as you would say it aloud. The first word is the
# program, matched *after* wrapper look-through — so this row also refuses
# `mise exec -- terraform apply` and `env TF_LOG=debug terraform apply`, which is
# the whole reason the look-through exists. The remaining words are the adjacent
# non-flag words that must follow, so `terraform apply -auto-approve` matches and
# `terraform plan apply-notes` does not.
#
# `reason` is REQUIRED here, unlike on a file rule. A file finding is a
# `path:line` a reader can open; a mediated deny is all the caller gets back, so
# a refusal that named only its id would leave nothing to act on.
[[rule]]
id = "no-unreviewed-apply"
kind = "shape"
scope = "mediated_call"
severity = "deny"
pattern = "terraform apply"
reason = "apply runs from the pipeline, not a workstation — open a change request"
# Optional. Appended to the refusal so a caller can read the policy itself.
policy_url = "https://example.invalid/handbook/infrastructure"

# The governing config surface: the files whose bytes `batten config epoch`
# hashes into one attributable value, so two records carrying the same epoch
# were produced under provably the same rules.
Expand Down
75 changes: 73 additions & 2 deletions batten.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,81 @@ version = 1
# is what actually forces that — an older build fails on the unknown key — and
# this floor states the intent so the refusal names the version rather than the
# key.
min_batten_version = "0.0.30"
min_batten_version = "0.0.42"

# Batten gates this repository with its own engine — consumer #1 in practice,
# not just in principle. Each rule below runs under `batten check`.
# not just in principle. Rules below whose `scope` is "tree" run under `batten
# check`; rules whose `scope` is "mediated_call" are adjudicated by `batten hook`
# against one command at a time and are never evaluated by `check` (CLOUD-48).

# ---------------------------------------------------------------------------
# The `gh` lifecycle, as config rather than Rust (CLOUD-48).
#
# These four rows were a hardcoded `match` inside `crates/batten/src/hook.rs`.
# Moving them here is the point of the change twice over: the engine stops
# carrying one consumer's task names (non-negotiable rule 1 — `git grep -Fn
# "mise run land" crates/` now finds nothing outside a test fixture), and a
# reviewer can read exactly what will be refused without reading Rust (§9).
#
# `pattern` for a shape rule is the banned command line as you would say it: the
# first word is the effective program — matched AFTER wrapper look-through, so
# `mise exec -- gh pr merge` and `env GH_PAGER= gh pr merge` both match — and the
# rest are the adjacent non-flag words that must follow it.
#
# `reason` is required on a shape rule, unlike on a file rule: a mediated deny
# reaches a model as the entire explanation, so a refusal that named only its id
# would be un-actionable (CLOUD-122). The crate appends the bypass hatch.
[[rule]]
id = "gh-pr-merge"
kind = "shape"
scope = "mediated_call"
severity = "deny"
pattern = "gh pr merge"
reason = """
`gh pr merge` (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."""

# The one row with a second condition. Only a comment carrying the landing
# directive is the lifecycle; an ordinary `gh pr comment` is not. The directive
# lives inside a quoted `--body`, so it is not one of the words `pattern`
# matches — `contains` looks at the segment as written, which is the only reason
# this key exists.
[[rule]]
id = "gh-pr-comment-fast-forward"
kind = "shape"
scope = "mediated_call"
severity = "deny"
pattern = "gh pr comment"
contains = "fast-forward"
reason = """
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."""

[[rule]]
id = "gh-pr-checks"
kind = "shape"
scope = "mediated_call"
severity = "deny"
pattern = "gh pr checks"
reason = """
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.)"""

[[rule]]
id = "gh-run-watch"
kind = "shape"
scope = "mediated_call"
severity = "deny"
pattern = "gh run watch"
reason = """
Hand-watching CI is the ad-hoc shape `mise run ci-wait` replaces. Background \
it rather than holding a foreground poll open."""

# A conflict marker reaching a source file means a botched merge landed. The
# shape is unambiguous and no compiler catches it in every file type, which is
Expand Down
13 changes: 9 additions & 4 deletions crates/batten/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,13 @@ fn parse_ungated(text: &str, source: &str) -> Result<Config> {
// matches every line of every file — still loaded clean. The completeness
// test below is what stops the next table arriving orphaned the same way.
crate::markers::validate(&config.markers)?;
// And the rule table, which used to be validated only by the runner that
// happened to evaluate it (CLOUD-48). That was defensible while the tree
// engine was the only runner; `batten hook` is now a second one, and a
// malformed `mediated_call` row validated only by `check` is a policy row
// that loads, matches nothing at the mediation channel, and reads as
// coverage. `run_rule` still calls `Rule::validate` as defence in depth.
crate::rules::validate(&config.rules)?;
Ok(config)
}

Expand Down Expand Up @@ -371,15 +378,13 @@ mod tests {
const VALIDATED_AT_LOAD: &[(&str, &str)] = &[
("verbs", "crate::verbs::validate("),
("markers", "crate::markers::validate("),
("rules", "crate::rules::validate("),
];

/// Tables proven well formed somewhere else, each with the reason. Listing
/// an exemption is the point: a reader sees the justification rather than
/// an absence, which is what an orphaned validator looks like.
const VALIDATED_BY_ITS_RUNNER: &[(&str, &str)] = &[(
"rules",
"Rule::validate fires in rules::run_rule when the rule is evaluated, not at load",
)];
const VALIDATED_BY_ITS_RUNNER: &[(&str, &str)] = &[];

#[test]
fn every_typed_config_table_has_a_validation_call_site() {
Expand Down
Loading