A Bash guard in tiers: rules in code, then a decision model, then the person; every ruling is remembered so the next one stays in code

Read it on the web: robgruhl.github.io/claude-mod-learnings
Claude Code mods are TypeScript or JavaScript plugins that run inside Claude Code and hook its own event chain: tool calls, prompts, model requests, subagents and what the interface draws. They shipped on 1 October 2026 in v2.1.287. This repo teaches them from an explain-like-I'm-five version up to policy engineering, backs the claims with experiments we ran, and includes a facilitated tutorial you run in Claude Code.
It's the sister site of Decision Tier (repo), a field guide to decision models. The two meet on Mods meet the cascade. This is an independent study, not an Anthropic project. Everything was checked against Claude Code 2.1.296 on 2026-10-12. The API is early access, so trust the declarations your own build writes over anything here.
| Path | What it is |
|---|---|
site/pages/ | The page fragments: ELI5, 101, 201, 301, four architecture pages, three evidence pages and the tutorial page |
site/build.py, site/template.html | Standard-library build that wraps each fragment and writes docs/, which GitHub Pages serves. Run python3 site/build.py. |
experiments/ | Small mods that each test one claim, run with claude plugin test (and one live headless probe). Results in experiments/RESULTS.md. Also cascade-guard, a tiered Bash guard. |
tutorial/ | The Mod Skeleton, a facilitated tutorial. cd tutorial && claude and Claude becomes your facilitator: one module at a time, predict before each check, every check run for real, one commit per module. Modelled on walkingskeleton.org. |
tutorial/.reference/skeleton-guard/ | The tutorial's finished mod, validated and tested on 2.1.296 |
hooks/hooks.json names one module exporting register(on, options). Every hook is ($, e, next).return next(e)), rewrite (next({ ...e, … })) or answer without calling next, so nothing beneath it runs..catch to fail closed. We confirmed both cases on 2.1.296.claude plugin validate on a mod before you install it and read its calls: line.$, which is why the loader can list a mod's calls. It refuses a module that hides $ in a destructure, an alias or a helper it can't follow.$.state until /clear, and $.store across sessions. session.start doesn't fire again after /clear, so reload from the store in classic.SessionStart.claude plugin test your test's hooks sit beneath the mod and stand in for Claude Code, so answer every event.tool.call guard doesn't see @-mentioned files. Hook prompt.mention too.setTimeout and setInterval exist in the hooks runtime, though the docs say there are no timer globals. Waiting on one counts against a hook's budget..catch as kind: 'throw'. If the handler also answers in the wrong shape, the guard fails open anyway.claude- prefix: anthropic-, anthropics- and cc-plugin- prefixes, plus exact names like claude-code.Details and outputs are in experiments/RESULTS.md.
Text and illustrations © Rob Gruhl, CC BY 4.0. Code (the build, experiments, tutorial reference mod and code samples) is MIT; see LICENSE. Illustrations were made with gpt-image-2, and the site was built with Claude Code.
hooks/register.ts 48 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Tier 1, code: rules you can write down. Microseconds.
4const ALWAYS_DENY = [/\brm\s+-[a-z]*r[a-z]*f\b|\brm\s+-[a-z]*f[a-z]*r\b/, /\bgit\s+push\b.*(--force\b|\s-f\b)/, /\bgit\s+reset\s+--hard\b/]
5const ALWAYS_ALLOW = /^(ls|pwd|cat|head|tail|wc|git (status|diff|log))(\s|$)/
6// Words that make a command worth a second look. Everything else passes in code.
7const SUSPICIOUS = /\b(rm|mv|dd|truncate|chmod|chown|kill|drop|delete|purge|--force|-delete)\b|>\s*\S/
8
9type Verdict = { probability: number; doubt: number } | undefined
10type Ruling = 'allow' | 'deny'
11
12// Tier 2, a decision model: one typed judgment with a probability and its own doubt.
13async function judge($: EngineInterface, url: string, command: string): Promise<Verdict> {
14 if (!url) return undefined
15 const res = await $.http.fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ command }) })
16 if (!res.ok) return undefined
17 const v = JSON.parse(res.text) as { probability?: unknown; doubt?: unknown }
18 return typeof v.probability === 'number' && typeof v.doubt === 'number' ? { probability: v.probability, doubt: v.doubt } : undefined
19}
20
21// Every ruling a person makes is stored, so the same command never climbs the stairs twice.
22async function remembered($: EngineInterface, command: string): Promise<Ruling | undefined> {
23 return (await $.store.get(`ruling:${command}`)) as Ruling | undefined
24}
25
26export const register: Register = (on, options) => {
27 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
28 const command = e.command.trim()
29 if (ALWAYS_DENY.some(re => re.test(command))) return { deny: `${$.plugin.name}: destructive by rule; ask the person to run it.` }
30 if (ALWAYS_ALLOW.test(command) || !SUSPICIOUS.test(command)) return next(e)
31
32 const ruling = await remembered($, command)
33 if (ruling === 'allow') return next(e)
34 if (ruling === 'deny') return { deny: `${$.plugin.name}: the person refused this command before.` }
35
36 const v = await judge($, String(options.judgeUrl ?? ''), command)
37 if (v && v.probability < 0.1 && v.doubt < 0.5) return next(e)
38 if (v && v.doubt >= 0.5 && v.doubt <= 0.7) $.ui.log(`${$.plugin.name}: watch band, doubt ${v.doubt.toFixed(2)} on: ${command}`, { to: 'debug' })
39
40 // Tier 4, the person. No model answer, a likely-destructive one, or a doubtful one all end here.
41 const why = v ? `decision model: ${Math.round(v.probability * 100)}% destructive, doubt ${v.doubt.toFixed(2)}` : 'no decision model answer'
42 const answer = await $.ui.ask(`Run this command? ${command} (${why})`, ['Allow', 'Deny'])
43 const decided: Ruling = answer === 'Allow' ? 'allow' : 'deny'
44 await $.store.set(`ruling:${command}`, decided)
45 return decided === 'allow' ? next(e) : { deny: `${$.plugin.name}: the person refused this command.` }
46 }).catch(($, e, next) => (next.called ? next(e) : { deny: `cascade-guard failed (${next.error.kind}); refusing to be safe.` }))
47}
48