sara. PT

Writing

A rule in CLAUDE.md is a suggestion: a PreToolUse hook is not

· agent-harness-cookbook· Claude Code· hooks· shell· testing

A rule written in CLAUDE.md is a suggestion. The agent reads it, agrees, and sometimes does something different anyway — not out of malice, just because the prohibition only exists as text in a context window that’s full, in the middle of a long task, competing with everything else the model is holding onto. What turns policy into a guarantee is something that runs before the command executes and doesn’t depend on anyone remembering anything. That’s what a PreToolUse hook is for.

gate-shell is the first example in a small cookbook I’m building on governing agent-generated code — runnable code, not slideware, because a gate you can’t paste into a terminal and watch block something isn’t a gate you can trust.

The contract

Claude Code pipes JSON on stdin — { tool_name, tool_input } — before it runs a Bash tool call. The hook exits 0 to allow, or 2 with a message on stderr to deny. That message goes back to the agent, so it can’t just say “no.” A denial with no path forward is what makes an agent retry the same blocked command three times and then improvise something worse.

export function evaluate(command) {
  // ...
  for (const rule of RULES) {
    if (rule.pattern.test(normalized)) {
      return { decision: "deny", ruleId: rule.id, reason: rule.reason, hint: rule.hint };
    }
  }
  return { decision: "allow" };
}

Seven rules, each one a thesis about what should never happen without a human in the loop: recursive removal from / or ~, force push, a direct write to main, a remote script piped straight into sh, destructive DDL, chmod 777, a write to a .env file. Every rule carries a reason for the log and a hint for the agent — and the hint is the one that matters more, because it’s the difference between the agent trying again the right way and burning the rest of its budget stuck.

The one allowlist entry — git log — exists because every allowlist is debt. Each entry is a hole punched in the gate on purpose, so it needs a written reason, not just a pattern that happened to be convenient.

Fails open, not closed

If the input isn’t valid JSON, the hook allows and writes a note to stderr instead of blocking. That’s a deliberate trade: a gate that fails closed on its own bug locks up the whole session over nothing; a gate that fails open loses one check but leaves a trace. The second costs less, as long as the failure is actually visible instead of silent — which turned out to matter more than I expected, for a reason that had nothing to do with the gate logic itself.

The bug the README caught

The example’s README shows the hook working from the command line:

echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' \
  | node gate-shell.mjs; echo "exit=$?"
# [SH002] Command blocked: force push.
# exit=2

Copy-pasting your own README before publishing it is not optional, and it’s a good thing I did: on my machine it printed nothing and exited 0. The force push was allowed.

evaluate() itself was correct — I checked it directly, it returned deny every time. The bug was one line below it, the standard Node idiom for “was this file run directly, or only imported”:

if (import.meta.url === `file://${process.argv[1]}`) {
  main();
}

My repository lives under a path with a space in it — MacBook Pro II. import.meta.url percent-encodes that space as %20. The manual file://${process.argv[1]} concatenation doesn’t encode anything. The two strings never matched, main() never ran, and the script exited cleanly with nothing having happened at all — no error, no stderr, no signal that anything was wrong. It only looked like a gate. The fix is pathToFileURL(process.argv[1]).href, which does the encoding correctly:

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  main();
}

The unsettling part isn’t the bug — it’s a one-line idiom mistake anyone copies from Stack Overflow. It’s that node --test never caught it, because the test suite calls evaluate() directly and never exercises the CLI entry point at all. A green suite and a working gate are not the same claim, and the only reason I caught the gap here was that the README made a promise specific enough to fail against — “run this, see exit 2.” Vague documentation doesn’t get to be wrong.

Tests split by what they’re for

blocks  — the gate does what it promises
allows  — the gate doesn't get in the way of normal work
evasion — attempts to get around it

The middle group is deliberately the largest of the three. A gate that blocks correctly but also blocks rm -rf ./build or a normal git commit -m "fix" gets disabled by the second week, and after that the protection is zero regardless of how clever the regex was. A false positive costs more than a false negative here, because a false negative is still a gap — a false positive is a reason to turn the whole thing off.

The evasion group is two tests marked skip, not deleted:

test("evasion via environment variable is not detected", { skip: "known limitation" }, () => {
  assert.equal(evaluate("R=rm; $R -rf /").decision, "deny");
});

R=rm; $R -rf / gets past every pattern here, and so does a base64-encoded payload piped into sh. Recording that as a skipped test, in the same file that proves the seven rules that do work, is more honest than a limitations section in a README nobody reads before they trust the tool. Detection here is text-pattern matching, not a shell parser — it stops a careless command, not an adversarial one.

What transfers

The seven rules are specific to my own workflow. Yours will name different commands. What transfers is the shape: a rule with an id, a reason, and a hint; the decision isolated in a pure function so it can be tested without simulating an agent’s runtime at all; and a test suite that treats “what this doesn’t catch” as a first-class, checked-in fact rather than an aspiration.