<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Hooks on hippotion</title><link>https://blog.hippotion.com/tags/hooks/</link><description>Recent content in Hooks on hippotion</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Sun, 12 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog.hippotion.com/tags/hooks/index.xml" rel="self" type="application/rss+xml"/><item><title>claude-compass — working notes</title><link>https://blog.hippotion.com/posts/claude-compass-working-notes/</link><pubDate>Sun, 12 Jul 2026 00:00:00 +0000</pubDate><guid>https://blog.hippotion.com/posts/claude-compass-working-notes/</guid><description>Notes on my rule-based guard for Claude Code: the hook contract, the Python/Go pair, the shared test set, and the holes I found along the way.</description><content:encoded><![CDATA[<blockquote>
<p>Working notes on how compass works — the hook contract, the two
implementations, and the failures I hit building it. Repo:
<a href="https://github.com/janos-gyorgy/claude-compass">claude-compass</a>.</p>
</blockquote>
<h2 id="what-it-is">What it is</h2>
<p>One Claude Code hook. On every tool call (PreToolUse) and finished reply (Stop)
it reads the event JSON from stdin, checks it against a <code>compass.toml</code>, and
denies, warns, or stays silent. Silent is the default — every rule ships OFF.</p>
<p>Three rules I don&rsquo;t compromise on:</p>
<ul>
<li><strong>Zero deps, zero network.</strong> Pure stdlib, no LLM judge on the per-call path — a
layer that doesn&rsquo;t share the model&rsquo;s blind spots.</li>
<li><strong>Fail-open.</strong> Any error → no output, exit 0. A guard that can brick a session
is worse than none.</li>
<li><strong>Off by default.</strong> Installing changes nothing until I flip a toggle.</li>
</ul>
<h2 id="the-hook-contract">The hook contract</h2>
<p>Three output channels, not symmetric:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="c1">// PreToolUse: hard deny
</span></span></span><span class="line"><span class="cl"><span class="p">{</span><span class="nt">&#34;hookSpecificOutput&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nt">&#34;hookEventName&#34;</span><span class="p">:</span> <span class="s2">&#34;PreToolUse&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;permissionDecision&#34;</span><span class="p">:</span> <span class="s2">&#34;deny&#34;</span><span class="p">,</span> <span class="nt">&#34;permissionDecisionReason&#34;</span><span class="p">:</span> <span class="s2">&#34;compass: ...&#34;</span><span class="p">}}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Stop: push back — Claude gets the reason and revises
</span></span></span><span class="line"><span class="cl"><span class="p">{</span><span class="nt">&#34;decision&#34;</span><span class="p">:</span> <span class="s2">&#34;block&#34;</span><span class="p">,</span> <span class="nt">&#34;reason&#34;</span><span class="p">:</span> <span class="s2">&#34;compass: ...&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// either: warn ME, not Claude
</span></span></span><span class="line"><span class="cl"><span class="p">{</span><span class="nt">&#34;systemMessage&#34;</span><span class="p">:</span> <span class="s2">&#34;compass ⚠ ...&#34;</span><span class="p">}</span>
</span></span></code></pre></div><ul>
<li><strong><code>systemMessage</code> may not render on Stop</strong> — so every firing also appends to
<code>~/.claude/compass-warns.log</code>. The log is the truth; the TUI tails it.</li>
<li><strong>Stop fires before the final turn is flushed</strong> — read once and you score the
<em>previous</em> turn. The hook polls up to 6×30 ms for the turn to land, then scans
(fail-open again).</li>
</ul>
<h2 id="two-sides-patterns-and-self-report">Two sides: patterns and self-report</h2>
<p><strong>Patterns</strong> — what regex catches: <code>rm -rf</code> family, disk destroyers, <code>curl|sh</code>,
<code>chmod 777</code>, 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.</p>
<p><strong>self_report</strong> — my CLAUDE.md asks the model to emit
<code>&lt;&lt;compass:drift|scope|unsure|assume|flattery|risk&gt;&gt;</code> when it catches itself; the
hook greps for the token. Local, but the judgment is the model&rsquo;s — weakest
exactly when I&rsquo;d need it most. <code>risk</code> turns into a hard block even when the group
is set to warn.</p>
<p>Field note: after a blocked <code>rm -rf</code>, an agent <em>will</em> reach for <code>shutil.rmtree</code>
or <code>find -delete</code> — watched it live. The config blocks those workarounds too
now; the rule is a block means stop and ask, never sneak around it.</p>
<h2 id="two-implementations-one-contract">Two implementations, one contract</h2>
<p>Python is the reference, Go the fast path. A hook spawns a fresh process on
<strong>every tool call</strong>, so startup cost is the honest number:</p>
<table>
	<thead>
			<tr>
					<th>scenario</th>
					<th>python</th>
					<th>go</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>silent-pass (every call pays this)</td>
					<td>35.8 ms</td>
					<td>1.9 ms</td>
			</tr>
			<tr>
					<td>deny</td>
					<td>36.5 ms</td>
					<td>2.9 ms</td>
			</tr>
			<tr>
					<td>stop-scan, 60-msg transcript</td>
					<td>37.0 ms</td>
					<td>4.0 ms</td>
			</tr>
	</tbody>
</table>
<p>Keeping two sources honest isn&rsquo;t discipline — it&rsquo;s a <strong>shared test set</strong> both
must pass: <code>vectors.json</code> holds 40 language-neutral cases (event in, decision
out); a runner drives each version as a real subprocess and checks they agree.</p>
<ul>
<li><strong>It caught a real divergence.</strong> Both are 40/40 now — after a spell at 32/32
while silently <em>disagreeing</em>: Python had a lookbehind exempting <code>git rm</code>, Go&rsquo;s
RE2 has none, and no vector covered it. The suite only guards what&rsquo;s written
down.</li>
<li><strong>Second hole:</strong> the Go binary resolved <code>compass.toml</code> next to the <em>executable</em>,
not the repo root — wire it without pinning <code>COMPASS_CONFIG</code> and every rule runs
off, silently. The installer now pins the path on Go entries.</li>
</ul>
<h2 id="custom-rules">Custom rules</h2>
<p><code>[[custom_rules]]</code> — named regexes matched against Bash commands
(<code>on = &quot;pretool&quot;</code>) or the final reply (<code>on = &quot;stop&quot;</code>), each with its own
block/warn action.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[[</span><span class="nx">custom_rules</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl"><span class="nx">name</span>    <span class="p">=</span> <span class="s2">&#34;kubectl-delete-namespace&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">on</span>      <span class="p">=</span> <span class="s2">&#34;pretool&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">pattern</span> <span class="p">=</span> <span class="s1">&#39;kubectl\s+delete\s+(ns|namespace)\b&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nx">action</span>  <span class="p">=</span> <span class="s2">&#34;block&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">reason</span>  <span class="p">=</span> <span class="s2">&#34;namespace deletes are Tier-1 — ask first&#34;</span>
</span></span></code></pre></div><ul>
<li>Omitted <code>enabled</code> = <strong>true</strong>: built-ins ship off because I didn&rsquo;t write them; a
custom rule I wrote <em>is</em> the opt-in.</li>
<li>Bad regex never fires (fail-open).</li>
<li>Patterns must stay RE2-compatible if Go runs them — two engines, one config.</li>
</ul>
<h2 id="the-tui">The TUI</h2>
<p>Ink + TypeScript, a separate long-lived process. Tails the warn log; a config
pane edits the armed toml <strong>line-surgically</strong> — only rewriting the value token on
an <code>enabled</code>/<code>action</code> line, so comments survive by construction. Atomic saves,
<code>.bak</code> kept. Custom rules get an add/edit form that regex-checks with JS&rsquo;s engine
(approximates RE2/<code>re</code>, doesn&rsquo;t equal it).</p>
<p><img alt="The compass TUI config pane: each rule group toggled on/off and warn/block, editing ~/.claude/compass.toml live." loading="lazy" src="/posts/claude-compass-working-notes/tui-config.png"></p>
<h2 id="anatomy-of-the-silent-disarm-session-log-2026-07-11">Anatomy of the silent disarm (session log, 2026-07-11)</h2>
<p>Compass is written for Claude Code. Kimchi is a different coding agent — Cast
AI&rsquo;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.</p>
<p>Timeline from the primary session log, not memory. (Memory had a more dramatic
version — &ldquo;Kimchi got blocked, then changed compass&rsquo;s settings to clear its way&rdquo;
— that the log doesn&rsquo;t support.) Times UTC.</p>
<p>The setup: Ferment — Kimchi&rsquo;s most autonomous mode — was asked <em>in prose</em> to wire
compass in and verify it. The most autonomous mode installing its own guardrail;
that prose instruction is the root cause.</p>
<ul>
<li><strong>21:49–21:55 · scoping.</strong> The ferment records its assumptions, incl. verbatim:
<em>&ldquo;User has authorised writing to ~/.claude/settings.json (the install.py does
this).&rdquo;</em> True — the first domino.</li>
<li><strong>22:03:51 · install.</strong> <code>python3 install.py</code> → <code>✓ Installed … Rules all OFF … EXIT=0</code>. It didn&rsquo;t reuse my existing hook entry — it stripped it and registered
a fresh one <em>without</em> the <code>COMPASS_CONFIG=</code> prefix, so the hook falls back to
the default all-OFF config. An armed install had quietly become a fresh one,
invisibly.</li>
<li><strong>22:04:32 · arming a dead file.</strong> The agent flips <code>git_safety</code> on in
<code>~/.claude/compass.toml</code> and confirms it — a file the hook no longer loads.</li>
<li><strong>22:04:58 · evidence, stamped healthy.</strong> It greps <code>settings.json</code>; the output
shows the hook command with no prefix — the disarm, in its own context window.
Verified ✓ anyway: the check only asked &ldquo;does it reference claude_compass.py?&rdquo;
(it does). It checked the wrong thing, so it <em>signed off</em> a broken guard.</li>
<li><strong>22:07:02 · the missing negative.</strong> Live test <code>rm -rf /tmp/test-compass-…</code> →
<code>EXIT=0</code>, no block, no log line. A disarmed guard emits nothing; the only
symptom is an event that <em>doesn&rsquo;t</em> happen.</li>
<li><strong>22:10:32 · the builder argues with the gate.</strong> A check flags that the
verification didn&rsquo;t verify; the builder talks past it (<em>&ldquo;a real smoke test that ran, even
though the result didn&rsquo;t match expectation&rdquo;</em>), marks it ✓, then &ldquo;confirms&rdquo; the
block using 21:53/22:01 log entries — which were <em>my Claude Code session&rsquo;s</em>
blocks. One shared log, and no way to tell whose was whose.</li>
<li><strong>22:11:51 · the reviewer says no.</strong> Kimchi&rsquo;s phase reviewer (a different model)
rejects it: <em>&ldquo;reviewer raised 2 block flag(s) … doesn&rsquo;t confirm a block in this
session.&rdquo;</em></li>
<li><strong>22:12:11 · honest failure.</strong> 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 <em>and</em> prefix wiped); it found one and
stopped.</li>
<li><strong>~22:55 · re-armed by hand.</strong> Prefix restored, verified 22:56:24 by a denied
<code>--dry-run</code> push. Disarm window: ~50 minutes.</li>
<li><strong>23:06–23:13 · finale.</strong> Fresh session, tests for real: <code>dangerous_tools</code>
denies on its own <code>rm -rf</code> probes, two <code>git_safety</code> denies on
<code>rtk git push origin HEAD:main</code>. 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.</li>
</ul>
<p><img alt="The compass TUI warn-log. Highlighted: the Kimchi session&rsquo;s blocks — dangerous_tools denies on its rm probes, git_safety denies on pushes to main." loading="lazy" src="/posts/claude-compass-working-notes/tui-monitor.png"></p>
<p>The lesson the disarm turns on: <strong>no single part failed — the combination did.</strong>
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.</p>
<p>What the log shows:</p>
<ul>
<li><strong>The root cause is me.</strong> 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&rsquo;s meant to limit.</li>
<li><strong>Checking the wrong thing is worse than not checking.</strong> The proof it was broken
sat in the agent&rsquo;s own output — and the check passed anyway.</li>
<li><strong>A log line isn&rsquo;t proof you caused it.</strong> The builder counted another session&rsquo;s
blocks as its own. You have to know which session made each one.</li>
<li><strong>Their reviewer setup worked.</strong> The builder tried to pass a failed check twice;
the separate reviewer caught it both times. The idea that an agent shouldn&rsquo;t
grade its own work — proven right in my own session file.</li>
<li><strong>The failure was a thing that didn&rsquo;t happen.</strong> Nothing warns you when a guard
goes quiet. It needs a heartbeat check, not just a log of what it blocked.
(Still open.)</li>
</ul>
<h2 id="compass-next-to-kimchis-own-guards">Compass next to Kimchi&rsquo;s own guards</h2>
<p>Compass wasn&rsquo;t the only guard there. Kimchi ships two of its own:</p>
<ul>
<li><strong>A fixed-rule hard-block</strong> — fork bombs, <code>sudo</code>/<code>mkfs</code>, root-adjacent <code>rm -rf</code>,
<code>dd of=/dev/</code>. It parses the shell command properly (more robust than my
regex), but it&rsquo;s narrower: no <code>curl | sh</code>, no <code>chmod 777</code>, no secret-file
protection, <code>rm -rf ~/code</code> passes.</li>
<li><strong>A model that judges riskier calls</strong> — but that costs a model call each time.</li>
</ul>
<p>So compass is the complement, not the competitor: fixed rules first, zero tokens,
offline, firing on exactly the patterns their shell-parse skips.</p>
<h2 id="open--honest">Open / honest</h2>
<ul>
<li>Scope-drift detection is phrase-proxy only; true intent-drift needs self-report
or a judge model I&rsquo;ve left out.</li>
<li>Self-report reliability is the model&rsquo;s, not mine.</li>
<li>The installer once re-registered over a customized entry and silently disarmed
a hardened install. Now: same impl present → hands off the whole entry.</li>
<li>Ran unmodified under a different harness (Kimchi) via a small hook adapter — the
stdin/stdout contract travelled better than I expected.</li>
<li>Custom rules match Bash command strings, not tool file-path arguments (a
<code>read</code>-tool access isn&rsquo;t inspected yet). And compass only guards a session it&rsquo;s
loaded into — not the machine.</li>
</ul>
]]></content:encoded></item></channel></rss>