Skip to content

Commit 5f86d66

Browse files
feat(hooks): add the agent gate and the git pre-commit hook (#136)
* feat(hooks): add the agent gate and the git pre-commit hook `cargo fallow hooks install --target agent` installs a PreToolUse gate for Claude Code and Codex. The gate blocks a `git commit` or a `git push` of the agent when `cargo fallow check` finds a new finding in the changed files. A finding that the base commit already has does not block: the gate checks the base commit too and gives its findings as a baseline. The result of the base commit stays in `.fallow/` as a cache. The bash script only finds the binary and keeps the gate open on an error. The hidden `hooks gate-runtime` command finds the git write, runs the check offline, writes `.fallow/cargo-gate-report.json` without following a link, and prints one line for each new finding. The script blocks only on exit code 1 together with the block line on stdout, so a usage error or a panic cannot block. `--target git` installs the same commit check as a pre-commit hook in the hooks folder of git. With hk, lefthook or pre-commit, the command prints the step to add. `hooks uninstall` keeps a script that someone edited, unless `--force`. `hooks status` shows each surface. The JSON documents `hooks` and `hooks-status` are in the schema. Signed-off-by: Bart Waardenburg <bart@waardenburg.dev> * fix(hooks): check nested workspaces and refuse unsafe paths in the gate The base export now starts at the git top level with a temporary index, so a workspace in a subfolder is in the export and export-ignore cannot leave out a file. The cache moves to the git folder. A path to git counts as git. A folder of another repository is not checked. The scripts single-quote the recorded manifest path. The install refuses a link in the harness folders and a settings file of another shape, and keeps the key order. The prefilter uses tr, and a probe that fails keeps the gate open. Signed-off-by: Bart Waardenburg <bart@waardenburg.dev> * test(hooks): build the edited script text with push_str Signed-off-by: Bart Waardenburg <bart@waardenburg.dev> --------- Signed-off-by: Bart Waardenburg <bart@waardenburg.dev>
1 parent b8ef4a6 commit 5f86d66

24 files changed

Lines changed: 3924 additions & 14 deletions

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ keywords = ["cargo", "cargo-subcommand", "static-analysis", "mutation-testing"]
1313
categories = ["development-tools::cargo-plugins"]
1414
# The package holds the source, the manifest, the lock file, the license, the
1515
# README and the CHANGELOG only. scripts/check-package.mjs checks the list.
16-
include = ["/src/**/*.rs", "/LICENSE", "/README.md", "/CHANGELOG.md"]
16+
include = ["/src/**/*.rs", "/src/hooks/gate.sh", "/LICENSE", "/README.md", "/CHANGELOG.md"]
1717

1818
# The fixtures and the fuzz crate have their own `[workspace]` tables. This
1919
# table keeps their manifests out of the root workspace.

‎README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,18 @@ writes one JSON document to stdout:
302302
For grep, use `--format compact`. It writes one `path:line:col: rule: message`
303303
line for each finding. [Output formats](docs/output.md) describes all formats.
304304

305+
To stop a coding agent before it commits a new finding, install the gate:
306+
307+
```bash
308+
cargo fallow hooks install --target agent
309+
```
310+
311+
The gate runs before each `git commit` and `git push` of Claude Code or
312+
Codex. It blocks when `cargo fallow check` finds a new finding in the changed
313+
files, and it lists each finding. A finding that the base commit already has
314+
does not block. `--target git` installs the same check as a git pre-commit
315+
hook. [Hooks](docs/hooks.md) has the details.
316+
305317
## Documentation
306318

307319
| Page | Contents |
@@ -317,6 +329,7 @@ line for each finding. [Output formats](docs/output.md) describes all formats.
317329
| [Mutation testing](docs/mutation-testing.md) | `mutants`: tiers, status, test selection and safety |
318330
| [Trace](docs/trace.md) | `--trace` of `visibility`, `deps` and `dupes`, to see why a check reports one item or does not |
319331
| [Output formats](docs/output.md) | Each format, the JSON Schema, `explain` and the exit codes |
332+
| [Hooks](docs/hooks.md) | The agent gate of Claude Code and Codex, and the git pre-commit hook |
320333
| [GitHub Action](action/README.md) | Inputs, outputs and install methods of the action |
321334

322335
## Contributing

‎changelog.d/feat-agent-gate.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
### Added
2+
3+
- `cargo fallow hooks install --target agent` installs a gate for Claude Code
4+
and Codex. The gate blocks a `git commit` or a `git push` of the agent when
5+
`cargo fallow check` finds a new finding in the changed files, and it lists
6+
each finding. A finding that the base commit already has does not block. An
7+
error of the tool does not block.
8+
- `cargo fallow hooks install --target git` installs the same check as a git
9+
pre-commit hook. `hooks uninstall` removes the files, and `hooks status`
10+
shows them. `--format json` gives the `hooks` and `hooks-status` documents.

‎docs/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ for each command.
1818
| [Trace](trace.md) | `--trace` of `visibility`, `deps` and `dupes`, to see why a check reports one item or does not |
1919
| [Mutation testing](mutation-testing.md) | `mutants`: tiers, status, test selection and safety |
2020
| [Output formats](output.md) | Each format, the JSON Schema, `explain` and the exit codes |
21+
| [Hooks](hooks.md) | The agent gate of Claude Code and Codex, and the git pre-commit hook |
2122
| [GitHub Action](../action/README.md) | Inputs, outputs and install methods of the action |
2223
| [GitLab CI](gitlab.md) | The template: code quality report and merge request note |
2324

‎docs/hooks.md‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
# Hooks: the agent gate and the git pre-commit hook
2+
3+
`cargo fallow hooks` installs a check that runs before a commit. The check
4+
blocks a commit only when `cargo fallow check` finds a new finding in the
5+
changed files. A finding that the base commit already has does not block. An
6+
error of the tool does not block.
7+
8+
```bash
9+
cargo fallow hooks install --target agent # Claude Code and Codex
10+
cargo fallow hooks install --target git # the git pre-commit hook
11+
cargo fallow hooks status
12+
cargo fallow hooks uninstall --target agent
13+
```
14+
15+
| Flag | Meaning |
16+
| --- | --- |
17+
| `--target agent\|git` | The surface. `agent` is the gate of Claude Code and Codex. `git` is the pre-commit hook. |
18+
| `--agent claude\|codex` | Only this harness of the agent target. The default is each harness whose folder (`.claude/` or `.codex/`) exists, else Claude Code. |
19+
| `--manifest-path <PATH>` | The `Cargo.toml` of the workspace. |
20+
| `--dry-run` | Show the changes, and write nothing. |
21+
| `--force` | Replace or remove a script that someone edited, or a hook that cargo-fallow did not write. |
22+
| `--format human\|json` | The output format. The JSON documents are `hooks` and `hooks-status`. |
23+
24+
The commands write to the git top level of the workspace. When the workspace
25+
is in a subfolder, the scripts record the path of its `Cargo.toml`.
26+
27+
## The agent gate
28+
29+
The gate is a `PreToolUse` hook on the shell tool of the harness:
30+
31+
| Harness | Configuration | Script |
32+
| --- | --- | --- |
33+
| Claude Code | `.claude/settings.json`, matcher `Bash`, timeout 300 seconds | `.claude/hooks/cargo-fallow-gate.sh` |
34+
| Codex | `.codex/hooks.json`, matcher `^Bash$` | `.codex/hooks/cargo-fallow-gate.sh` |
35+
36+
The install keeps each other key and each other handler in the configuration.
37+
The handler of the fallow gate for JavaScript (`fallow-gate.sh`) stays, so the
38+
two gates can run in one repository.
39+
40+
For each shell command of the agent, the gate does these steps:
41+
42+
1. It skips each command that is not a `git commit` or a `git push`. A
43+
command without the words `git` and `commit` or `push` starts no process.
44+
2. It finds the workspace of the session folder, and of each folder that the
45+
command names (`cd`, `git -C`, `--work-tree`, `--git-dir`). A named folder
46+
counts only when it is in the same repository: in the work tree of the
47+
session, or in a linked worktree. The check runs `cargo metadata` in the
48+
folder, so a folder of another repository is not checked.
49+
3. It runs `cargo fallow check --fail-on-issues --offline` on the files that
50+
changed since the base:
51+
- A commit compares with `HEAD`. Staged, unstaged and untracked changes
52+
count.
53+
- A push compares with the upstream of the branch, else with
54+
`origin/HEAD`, `origin/main`, `origin/master`, `main` or `master`. A push
55+
also runs the `file-growth` gate of `size` (`--base`).
56+
4. It runs the same check on the base commit, and leaves out each finding
57+
that the base commit has. The result of the base commit stays as a cache
58+
in the git folder (`.git/cargo-fallow-gate/`), so a retry after a block
59+
runs one check. A commit cannot add a file there.
60+
5. When an enforced gate fails, it blocks the command (exit code 2), and
61+
prints one line for each new finding, the failed gates and the path of the
62+
report.
63+
64+
```text
65+
cargo-fallow-gate: blocked by cargo-fallow 0.2.0 in /work/ripgrep
66+
cargo-fallow-gate: failed gates: error-severity-findings
67+
cargo-fallow-gate: 1 finding(s) in the changed files caused the block:
68+
deps/unused-dependency crates/printer/Cargo.toml:22 Remove `globset` from [dependencies].
69+
cargo-fallow-gate: full report: /work/ripgrep/.fallow/cargo-gate-report.json
70+
cargo-fallow-gate: fix the findings and try again. `cargo fallow explain <rule>` explains a rule, and `cargo fallow fix --dry-run --format json` lists the fixable actions.
71+
```
72+
73+
The gate keeps the commit open, with one line on stderr, in these cases:
74+
75+
- The binary is not found (`CARGO_FALLOW_GATE_BIN`, `PATH`, then
76+
`${CARGO_HOME:-~/.cargo}/bin`).
77+
- The payload is not valid JSON.
78+
- `cargo metadata` fails, for example because `--offline` needs a download.
79+
- The repository has no commit, or a push has no base.
80+
- The runtime crashes or stops on a signal.
81+
82+
The gate needs bash. On Windows, use Git Bash or WSL.
83+
84+
On a workspace of about 1800 `.rs` files, a release build took 34 seconds for
85+
the first check of a commit (the base commit and the work tree), and 17
86+
seconds for a retry with the cache. The Claude Code handler has a timeout of
87+
300 seconds. A timeout keeps the commit open.
88+
89+
### Settings
90+
91+
| Variable | Meaning |
92+
| --- | --- |
93+
| `CARGO_FALLOW_GATE_BIN` | The `cargo-fallow` binary to run. |
94+
| `CARGO_FALLOW_GATE_MIN_VERSION` | The lowest version that the gate accepts. The default is the version that installed the gate. Below the floor, the gate blocks and prints the upgrade command. An empty value turns the floor off. |
95+
| `CARGO_FALLOW_GATE_REPORT` | The path of the JSON report. The default is `.fallow/cargo-gate-report.json` in the workspace root. A relative path is relative to the workspace root. |
96+
| `CARGO_FALLOW_GATE_DEBUG=1` | Also log each command that the gate skips. |
97+
98+
The report is the JSON report of `check`. The gate never writes through a
99+
symbolic link: when the report path or one of its folders is a link, the gate
100+
writes the report to a temporary file and names that file. The gate writes
101+
`.fallow/.gitignore` with `*`, so the report does not make the work tree
102+
dirty.
103+
104+
### Edited scripts
105+
106+
Each generated script holds a checksum. `hooks install` replaces a script
107+
whose checksum matches. A script that someone edited stays: `install` exits 2,
108+
and `uninstall` keeps it. Give `--force` to replace or remove it.
109+
110+
## The git pre-commit hook
111+
112+
`--target git` writes the `pre-commit` hook in the hooks folder that git uses
113+
(`git rev-parse --git-path hooks`), so `core.hooksPath` and worktrees work.
114+
With a `.husky/` folder, the hook is `.husky/pre-commit`. When `hk.pkl`,
115+
`lefthook.yml` or `.pre-commit-config.yaml` exists, the command writes no file
116+
and prints the command to add to the pre-commit step of that tool.
117+
118+
The hook runs the same commit check as the agent gate. It blocks only on new
119+
findings, and an error of the tool does not block. Skip it for one commit with
120+
`git commit --no-verify`.
121+
122+
## Status
123+
124+
`cargo fallow hooks status` gives the state of `claude`, `codex` and `git`:
125+
126+
| State | Meaning |
127+
| --- | --- |
128+
| `installed` | The handler and the generated script are in place. |
129+
| `partial` | The handler or the script is missing. |
130+
| `edited` | The script is not the generated one. |
131+
| `managed` | A hook manager runs the git hooks. |
132+
| `absent` | Nothing is installed. |

0 commit comments

Comments
 (0)