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 ⚠ ..."}
  • systemMessage may 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:

scenariopythongo
silent-pass (every call pays this)35.8 ms1.9 ms
deny36.5 ms2.9 ms
stop-scan, 60-msg transcript37.0 ms4.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.toml next to the executable, not the repo root — wire it without pinning COMPASS_CONFIG and 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).

The compass TUI config pane: each rule group toggled on/off and warn/block, editing ~/.claude/compass.toml live.

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 the COMPASS_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_safety on in ~/.claude/compass.toml and 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-run push. Disarm window: ~50 minutes.
  • 23:06–23:13 · finale. Fresh session, tests for real: dangerous_tools denies on its own rm -rf probes, two git_safety denies on rtk 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 compass TUI warn-log. Highlighted: the Kimchi session’s blocks — dangerous_tools denies on its rm probes, git_safety denies on pushes to main.

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-adjacent rm -rf, dd of=/dev/. It parses the shell command properly (more robust than my regex), but it’s narrower: no curl | sh, no chmod 777, no secret-file protection, rm -rf ~/code passes.
  • 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.