# Claude Code Hooks vs ACP: When a 50-Line Hook Is Enough

You can build agent control yourself with a PreToolUse hook — and sometimes you should. What the DIY version takes, where it rots, and an honest line for when to stop maintaining your own.

The best objection to what we build is: "I can do this with a Claude Code
hook in an afternoon." True. Here's the afternoon version, working:

```json
// ~/.claude/settings.json
{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [
  { "type": "command", "command": "node ~/hooks/deny.mjs" } ] } ] } }
```

```js
// ~/hooks/deny.mjs — deny two catastrophes, allow the rest
const input = JSON.parse(require("fs").readFileSync(0, "utf8"));
const cmd = input.tool_input?.command ?? "";
if (/rm\s+-rf\s+[~/]/.test(cmd) || /push\s+--force.*main/.test(cmd)) {
  console.log(JSON.stringify({ hookSpecificOutput: {
    hookEventName: "PreToolUse", permissionDecision: "deny",
    permissionDecisionReason: "blocked by local hook" } }));
}
```

This is genuinely good. It's deterministic, it runs outside the model, it
survives `--dangerously-skip-permissions`, and it took twenty minutes. If
you run one harness on one machine, you could stop reading here — honestly.

## Where it rots

We know because our production version is the same idea with two years of
scar tissue. The rot arrives in a fixed order:

**Respelling.** The regex above misses `rm -r -f ~/`, `rm --recursive
--force ~`, `bash -c "rm -rf ~"`, and the catastrophe hidden after `&&`.
Every string you add teaches you why [deny lists get bypassed](/blog/claude-code-deny-list-bypass);
the real fix is tokenizing every segment of the command line and recursing
into `bash -c` — at which point your afternoon script is a parser with test
cases.

**The second harness.** The day Codex shows up, your hook needs Codex's
dialect — different config file, plus a one-time `/hooks` trust review
without which Codex lists your hook and silently never runs it. Cursor is a
third dialect. The rules drift apart precisely because each copy is cheap
to edit and nothing checks parity. Hand-maintained, per-integration
enforcement rots — that's not an opinion about your discipline, it's what
N×M maintenance does.

**Ask, not just deny.** Deny-or-allow is easy. The useful middle — "`git
push` asks first, `curl` to unknown hosts asks first" — needs the ask wire
format per harness (and Codex's parser acts on deny only, so ask has to
degrade correctly there).

**The log.** The first time something goes wrong you'll want [one file that
says what ran and why it was allowed](/blog/audit-what-your-coding-agent-runs)
— which means every decision, including the allows, from every harness, in
one format. Bolting that on third is how ours became one dispatcher instead
of three scripts.

**Updates.** Hook APIs move. Harnesses add tools mid-flight. Your script
gets the maintenance budget of any other internal tool: whatever's left.

## The line

Build it when: one harness, one machine, rules you can count on one hand,
and you'll actually maintain the parser. Genuinely fine.

Stop building it when any of these arrive: a second harness, a teammate who
needs the same rules, an ask-tier, or the day you need the audit answer
fast.

Our local runtime is the maintained version of the exact script above — one
hook dispatcher across Claude Code, Codex, and Cursor, a
[a few hundred lines, pure decision engine](https://github.com/agentic-control-plane/acp-install/blob/main/decide.mjs)
you can read in one sitting, a floor that's respelling-proof, `allow` /
`ask` / `deny` in one policy file, every decision logged. One command, no
account, MIT, on-device:

```bash
curl -sf https://agenticcontrolplane.com/install.sh | bash -s -- --local
```

Keep your hook in the repo anyway. Reading what will say no to your agent
is a habit worth having — [ours is here](https://github.com/agentic-control-plane/acp-install),
and the [60-second recording](/blog/control-your-coding-agent-in-one-command)
is what it looks like running.


