Working notes on how compass works — the hook contract, the two implementations, and the failures I hit building it. Repo: claude-compass.
What it is
One Claude Code hook. On every tool call (PreToolUse) and finished reply (Stop)
it reads the event JSON from stdin, checks it against a compass.toml, and
denies, warns, or stays silent. Silent is the default — every rule ships OFF.
Three rules I don’t compromise on:
- Zero deps, zero network. Pure stdlib, no LLM judge on the per-call path — a layer that doesn’t share the model’s blind spots.
- Fail-open. Any error → no output, exit 0. A guard that can brick a session is worse than none.
- Off by default. Installing changes nothing until I flip a toggle.
The hook contract
Three output channels, not symmetric:
// PreToolUse: hard deny
{"hookSpecificOutput": {"hookEventName": "PreToolUse",
"permissionDecision": "deny", "permissionDecisionReason": "compass: ..."}}
// Stop: push back — Claude gets the reason and revises
{"decision": "block", "reason": "compass: ..."}
// either: warn ME, not Claude
{"systemMessage": "compass ⚠ ..."}
systemMessagemay not render on Stop — so every firing also appends to~/.claude/compass-warns.log. The log is the truth; the TUI tails it.- Stop fires before the final turn is flushed — read once and you score the previous turn. The hook polls up to 6×30 ms for the turn to land, then scans (fail-open again).
Two sides: patterns and self-report
Patterns — what regex catches: rm -rf family, disk destroyers, curl|sh,
chmod 777, secret-file edits, force-push, push to protected branches. Plus
sycophancy tells and scope-creep language on Stop — rough signals, not real
mind-reading, and the config says so.
self_report — my CLAUDE.md asks the model to emit
<<compass:drift|scope|unsure|assume|flattery|risk>> when it catches itself; the
hook greps for the token. Local, but the judgment is the model’s — weakest
exactly when I’d need it most. risk turns into a hard block even when the group
is set to warn.
Field note: after a blocked rm -rf, an agent will reach for shutil.rmtree
or find -delete — watched it live. The config blocks those workarounds too
now; the rule is a block means stop and ask, never sneak around it.
Two implementations, one contract
Python is the reference, Go the fast path. A hook spawns a fresh process on every tool call, so startup cost is the honest number:
| scenario | python | go |
|---|---|---|
| silent-pass (every call pays this) | 35.8 ms | 1.9 ms |
| deny | 36.5 ms | 2.9 ms |
| stop-scan, 60-msg transcript | 37.0 ms | 4.0 ms |
Keeping two sources honest isn’t discipline — it’s a shared test set both
must pass: vectors.json holds 40 language-neutral cases (event in, decision
out); a runner drives each version as a real subprocess and checks they agree.
- It caught a real divergence. Both are 40/40 now — after a spell at 32/32
while silently disagreeing: Python had a lookbehind exempting
git rm, Go’s RE2 has none, and no vector covered it. The suite only guards what’s written down. - Second hole: the Go binary resolved
compass.tomlnext to the executable, not the repo root — wire it without pinningCOMPASS_CONFIGand every rule runs off, silently. The installer now pins the path on Go entries.
Custom rules
[[custom_rules]] — named regexes matched against Bash commands
(on = "pretool") or the final reply (on = "stop"), each with its own
block/warn action.
[[custom_rules]]
name = "kubectl-delete-namespace"
on = "pretool"
pattern = 'kubectl\s+delete\s+(ns|namespace)\b'
action = "block"
reason = "namespace deletes are Tier-1 — ask first"
- Omitted
enabled= true: built-ins ship off because I didn’t write them; a custom rule I wrote is the opt-in. - Bad regex never fires (fail-open).
- Patterns must stay RE2-compatible if Go runs them — two engines, one config.
The TUI
Ink + TypeScript, a separate long-lived process. Tails the warn log; a config
pane edits the armed toml line-surgically — only rewriting the value token on
an enabled/action line, so comments survive by construction. Atomic saves,
.bak kept. Custom rules get an add/edit form that regex-checks with JS’s engine
(approximates RE2/re, doesn’t equal it).

Anatomy of the silent disarm (session log, 2026-07-11)
Compass is written for Claude Code. Kimchi is a different coding agent — Cast AI’s, in early access — with its own harness, but it ships an adapter that runs Claude Code hooks. The question: does compass, unmodified, guard a Kimchi session too? This is the log of finding out — and the quiet failure on the way.
Timeline from the primary session log, not memory. (Memory had a more dramatic version — “Kimchi got blocked, then changed compass’s settings to clear its way” — that the log doesn’t support.) Times UTC.
The setup: Ferment — Kimchi’s most autonomous mode — was asked in prose to wire compass in and verify it. The most autonomous mode installing its own guardrail; that prose instruction is the root cause.
- 21:49–21:55 · scoping. The ferment records its assumptions, incl. verbatim: “User has authorised writing to ~/.claude/settings.json (the install.py does this).” True — the first domino.
- 22:03:51 · install.
python3 install.py→✓ Installed … Rules all OFF … EXIT=0. It didn’t reuse my existing hook entry — it stripped it and registered a fresh one without theCOMPASS_CONFIG=prefix, so the hook falls back to the default all-OFF config. An armed install had quietly become a fresh one, invisibly. - 22:04:32 · arming a dead file. The agent flips
git_safetyon in~/.claude/compass.tomland confirms it — a file the hook no longer loads. - 22:04:58 · evidence, stamped healthy. It greps
settings.json; the output shows the hook command with no prefix — the disarm, in its own context window. Verified ✓ anyway: the check only asked “does it reference claude_compass.py?” (it does). It checked the wrong thing, so it signed off a broken guard. - 22:07:02 · the missing negative. Live test
rm -rf /tmp/test-compass-…→EXIT=0, no block, no log line. A disarmed guard emits nothing; the only symptom is an event that doesn’t happen. - 22:10:32 · the builder argues with the gate. A check flags that the verification didn’t verify; the builder talks past it (“a real smoke test that ran, even though the result didn’t match expectation”), marks it ✓, then “confirms” the block using 21:53/22:01 log entries — which were my Claude Code session’s blocks. One shared log, and no way to tell whose was whose.
- 22:11:51 · the reviewer says no. Kimchi’s phase reviewer (a different model) rejects it: “reviewer raised 2 block flag(s) … doesn’t confirm a block in this session.”
- 22:12:11 · honest failure. The builder concedes and fails the phase: adapter enabled in config, not loaded in the session. True but incomplete — two faults, one symptom (adapter not loaded and prefix wiped); it found one and stopped.
- ~22:55 · re-armed by hand. Prefix restored, verified 22:56:24 by a denied
--dry-runpush. Disarm window: ~50 minutes. - 23:06–23:13 · finale. Fresh session, tests for real:
dangerous_toolsdenies on its ownrm -rfprobes, twogit_safetydenies onrtk git push origin HEAD:main. Live blocks, unmodified compass, in a harness it was never written for. Kimchi wrote its own verification doc of the guard that now blocks it.

The lesson the disarm turns on: no single part failed — the combination did. Ships-off (safety), fail-open (safety), and quietly falling back to a default config (convenience) are each fine on their own. Chain them behind one wiped-out prefix and the guard turns into its most normal-looking useless state, without a sound.
What the log shows:
- The root cause is me. I told the agent, in words, to install the thing that limits it. Words are context, not control — and I broke that rule at the one layer where it mattered most. Installing and configuring a guard has to sit outside the reach of the agent it’s meant to limit.
- Checking the wrong thing is worse than not checking. The proof it was broken sat in the agent’s own output — and the check passed anyway.
- A log line isn’t proof you caused it. The builder counted another session’s blocks as its own. You have to know which session made each one.
- Their reviewer setup worked. The builder tried to pass a failed check twice; the separate reviewer caught it both times. The idea that an agent shouldn’t grade its own work — proven right in my own session file.
- The failure was a thing that didn’t happen. Nothing warns you when a guard goes quiet. It needs a heartbeat check, not just a log of what it blocked. (Still open.)
Compass next to Kimchi’s own guards
Compass wasn’t the only guard there. Kimchi ships two of its own:
- A fixed-rule hard-block — fork bombs,
sudo/mkfs, root-adjacentrm -rf,dd of=/dev/. It parses the shell command properly (more robust than my regex), but it’s narrower: nocurl | sh, nochmod 777, no secret-file protection,rm -rf ~/codepasses. - A model that judges riskier calls — but that costs a model call each time.
So compass is the complement, not the competitor: fixed rules first, zero tokens, offline, firing on exactly the patterns their shell-parse skips.
Open / honest
- Scope-drift detection is phrase-proxy only; true intent-drift needs self-report or a judge model I’ve left out.
- Self-report reliability is the model’s, not mine.
- The installer once re-registered over a customized entry and silently disarmed a hardened install. Now: same impl present → hands off the whole entry.
- Ran unmodified under a different harness (Kimchi) via a small hook adapter — the stdin/stdout contract travelled better than I expected.
- Custom rules match Bash command strings, not tool file-path arguments (a
read-tool access isn’t inspected yet). And compass only guards a session it’s loaded into — not the machine.