ADR-181: Guard hooks: a blocking PreToolUse class, shipped deactivated¶
Context¶
Every hook this project ships injects context and exits zero. One departs from that: strip-session-link-pre.sh (ADR-162) exits 2 to refuse a commit that would publish a session link. It is the only hook that can stop a tool call, and nothing in the corpus names the class it belongs to.
The Cypress survey (docs/architecture/practice/ADR-603-cypress-survey-what-a-node-routed-seed-teaches-a-hook-disclosed-corpus.md, issue #465) proposed a second refusing hook, ported from a guard that blocks any shell command lacking an explicit timeout or a detached launch. That guard was written for harnesses that run foreground commands unbounded. This harness is different on three points:
- Every foreground command is already bounded. The Bash tool enforces a timeout, 120 seconds by default and 600 at most, and refuses a bare foreground
sleep. - Detached work has a first-class form.
run_in_background: trueon the Bash tool, and the Monitor tool for watching a condition. The hook seesrun_in_backgroundintool_input. - The permission system already gates Bash by command prefix, and the operator tunes that list per project.
What the harness leaves open is narrower: pattern kills (pkill, killall, kill by name) that can match the agent's own shell or an operator process; interactive-prone commands (sudo without -n, ssh without BatchMode, docker exec -it, package managers without their flag) that sit on a prompt until the timeout; log followers and attached containers that never return in the foreground; and pipe-to-shell.
The operator's read: Bash execution is already guarded enough, and another refusal wired in by default is unwanted.
Decision¶
Establish guard hooks as a named class with a contract. Ship check-bash-bound.py as a member, deactivated.
A guard hook:
- Refuses by exit 2 with the reason and the accepted form on stderr. The model receives the reason as the tool result and chooses again.
- Exits only 0 or 2. Every internal failure path exits 0 with one line on stderr. A bug in a guard degrades to no guard.
- Is wired without
|| truewhen it is wired at all. - Refuses a closed list named in the script, each entry with its repair. A guard has no semantic lane.
- Honors the harness's own forms:
run_in_backgroundand atimeoutprefix exempt the never-returns class.
check-bash-bound.py refuses the four classes above and lives at hooks/ways/check-bash-bound.py with its verdict test at tests/test-bash-bound.sh, which runs in the suite. It is not wired in settings.json. An operator who wants it adds one entry under hooks.PreToolUse for matcher Bash:
Bounded execution is guidance. The softwaredev/environment/bounded-execution way fires on the same command classes through the ordinary inject path and carries the discipline: background the never-returning command, give the interactive one its flag, kill by pid, download an installer before running it, and claim running only on a liveness signal.
Activating the guard by default needs its own ADR, and the bar is ADR-162's: an irreversible or disclosing outcome that injected guidance has been observed to fail to prevent.
Consequences¶
Positive¶
- Bash stays as permissive as the harness and the operator's permission list make it. No hook runs in front of every shell call unless the operator asks.
- The class has a name, a contract, and a tested reference member. A project that wants the refusal gets it with one settings line.
- The discipline still reaches the model at the moment it is about to run one of those commands.
Negative¶
- A pattern kill or a pipe-to-shell that the model decides on despite the guidance runs. The harness timeout and the permission prompt are the remaining stops.
- A deactivated script drifts. Its test runs in the suite, which keeps it working; whether its refused list still matches the harness is checked only when someone activates it.
Neutral¶
strip-session-link-pre.shis retroactively a member of the class. Its contract already matches.- The Makefile marks
.pyhooks executable alongside.sh, so the opt-in path works aftermake install. - The
code/security/guardsway, which says a guard that cannot block is decoration, describes controls a project ships in its own code. It does not argue for wiring this one.
Alternatives Considered¶
- Wire the guard by default (the first draft of this ADR). Rejected by the operator: Bash execution is already guarded enough.
- Withdraw the guard entirely. Rejected in favor of shipping it deactivated: the port and its forty-case test exist, and a project with a different risk posture can opt in without re-deriving them.
- Port the Cypress guard unchanged, refusing any command without an explicit bound. Rejected. The harness already bounds foreground commands; the rule would refuse ordinary builds and installs.
- Fold the guard into the
waysbinary's scan path. Rejected for now; the scan path is an inject path with a semantic lane, and a deactivated script is easier to read and to opt into.