Architecture Where Hook Chain meets Decision Tier
Mods meet the cascade
Our sister site, Decision Tier, found that two quite different projects converged on the same shape. Deterministic code handles the rules you can write down. A decision model handles bounded judgments. An LLM handles open questions, and a person handles stakes and preferences. A tool.call guard is a natural home for that cascade, because it sits in front of every action Claude takes. This page builds one and tests it.
The tiers, mapped onto a mod
| Tier | In a guard mod | Cost per command | Decides |
|---|---|---|---|
| Rules (managed) | Permission deny rules and managed PreToolUse hooks, before any mod | ~0 | The obvious spellings, enforced even if every mod is unloaded |
| Code | Regex allow and deny lists, realPath checks, remembered rulings from $.store | ~40 µs per hook, ~90 µs per store read (measured) | Everything you can write down. Most commands end here. |
| Decision model | $.http.fetch to a typed-judgment endpoint: "how likely is this command to destroy data the person didn't ask to destroy?", plus the model's own doubt | Hosted Jev p50 0.59 s, p95 2.09 s. OpenAI Decisions p50 0.30 s, p95 0.76 s. (Decision Tier's measurements.) | The ambiguous remainder: find … -delete, mv over a file, a > redirect |
| LLM | $.model.complete, or simply Claude itself reading the refusal text | Haiku 5.5 p50 2–3 s, and only 33% identical on re-ask | Rarely worth it inside a guard. A refusal with a clear reason lets Claude find another way. |
| Person | $.ui.ask, which holds the call in Claude Code's own question dialog | Seconds to minutes | Anything doubtful, and anything the model thinks is destructive |
Decision Tier's rule is that guards are "safety only: stop or protect, never decide", and belong in the fastest tier. Mods add a corollary: a guard must also fail closed by construction, because a mod hook that throws or overruns is skipped and the call goes ahead. Field tests confirmed both halves on 2.1.296.
The guard
This is cascade-guard, a complete mod in experiments/cascade-guard. It passes claude plugin validate, and its five tests pass in claude plugin test on 2.1.296. The decision model sits behind a URL you configure, and its API is a stand-in: a POST returning { probability, doubt }. Jev's or the Decisions API's real request shape is different. Decision Tier shows how to phrase the question.
export const register: Register = (on, options) => {
on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
const command = e.command.trim()
// Code: rules you can write down.
if (ALWAYS_DENY.some(re => re.test(command))) return { deny: `${$.plugin.name}: destructive by rule; ask the person to run it.` }
if (ALWAYS_ALLOW.test(command) || !SUSPICIOUS.test(command)) return next(e)
// Code again: a person already ruled on this exact command.
const ruling = await remembered($, command)
if (ruling === 'allow') return next(e)
if (ruling === 'deny') return { deny: `${$.plugin.name}: the person refused this command before.` }
// Decision model: act only when it is confident and not in doubt.
const v = await judge($, String(options.judgeUrl ?? ''), command)
if (v && v.probability < 0.1 && v.doubt < 0.5) return next(e)
if (v && v.doubt >= 0.5 && v.doubt <= 0.7) $.ui.log(`watch band, doubt ${v.doubt.toFixed(2)} on: ${command}`, { to: 'debug' })
// Person: no answer, a likely-destructive answer, or a doubtful one all end here.
const answer = await $.ui.ask(`Run this command? ${command} (${why(v)})`, ['Allow', 'Deny'])
const decided = answer === 'Allow' ? 'allow' : 'deny'
await $.store.set(`ruling:${command}`, decided) // the fix flows down
return decided === 'allow' ? next(e) : { deny: `${$.plugin.name}: the person refused this command.` }
}).catch(($, e, next) => (next.called ? next(e) : { deny: `cascade-guard failed (${next.error.kind}); refusing to be safe.` }))
}
$ claude plugin validate experiments/cascade-guard
❯ ./register.ts hooks: tool.call{tool=Bash}
❯ ./register.ts gating hook with .catch: tool.call{tool=Bash}
❯ ./register.ts calls: $.http.fetch (via judge), $.store.get (via remembered), $.store.set, $.ui.ask, $.ui.log
✔ Validation passed
$ claude plugin test experiments/cascade-guard
(pass) tier 1: rules in code decide without a model or a person
(pass) tier 2: a confident "safe" from the decision model passes with no person
(pass) tier 4: a likely-destructive verdict goes to the person, and the ruling is stored
(pass) a remembered ruling stays in code next time
(pass) no decision model answer fails closed to the person
5 pass 0 fail
Design choices, and why
Code first, and most commands never leave it
The guard passes everything that doesn't look suspicious without calling anyone. npm test costs one hook and a regex, about 40 µs. Decision Tier's first lesson applies directly: put each decision in the cheapest tier that can make it correctly. A decision model on every ls would add half a second to every step of every turn for no benefit.
Every escalation ends in a lower-tier fix
When the person rules, the ruling goes into $.store, keyed by the exact command. Next time, that command is decided in code. This is Decision Tier's "fix the cause in a lower tier", in its simplest form. A fuller version would let Claude, during a quiet moment, propose regex rules from the stored rulings for the person to approve. Then whole families of commands move down a tier.
Act on the model only when it's confident and calm
The guard lets a command through on the model's word only when the probability of harm is under 0.1 and doubt is under 0.5. Anything else goes to the person. Decision Tier found that high doubt (above about 0.75) was right every time it fired. The bugs sat in the 0.5–0.7 band, so the guard logs that band to the debug log for review instead of ignoring it. The thresholds are a starting point. Set your own from measured data, per model, the way Decision Tier 301 describes.
Fail closed at every seam
- The decision model is down or answers nonsense:
judgereturnsundefinedand the person is asked. Tested. - The hook throws, for example on malformed JSON:
.catchrefuses, becausenextwasn't called yet. - There's no one to ask:
$.ui.askrejects inclaude -pand when dismissed. The rejection reaches.catch, which refuses. In headless runs the guard therefore refuses every ambiguous command, which is the right default for a guard. - The decision model hangs: waiting inside
$.http.fetchdoesn't count against the hook's 10-second budget, so a hung endpoint holds the call.HttpInithas no timeout field in 2.1.296. In production, race the fetch against a short$.clock.sleep(which does count, so keep it to a few seconds) and treat the loss as "no answer". - Every mod is unloaded after a worker crash loop: only managed rules remain. Keep the worst spellings in a
denyrule, so the bottom step holds even with the guard gone.
Why not just ask Claude?
Claude reads the refusal text. A deny that says why, such as "destructive by rule; ask the person to run it", usually leads Claude to explain and hand the command over, or to find a safer route. That is the LLM tier working at no extra cost. A $.model.complete call inside the guard would add seconds per command. Decision Tier also measured Haiku giving the same answer to the same question only a third of the time, which is a poor property for a gate.
Two places a decision model doesn't belong
- As an MCP tool Claude chooses to call. Then the expensive tier decides when to consult the cheap one, which is backwards for a guard. It can make sense for advice, not for safety.
- In a skill. A skill is instructions Claude reads, not a judge. It can teach Claude what the thresholds mean, and that's the right job for it. In Decision Tier's words: mods and hooks call decision models; skills and CLAUDE.md teach Claude when to trust them.
What to remember
- A
tool.callguard is the cascade's fastest tier. Keep most decisions in code. - Call a decision model only for the ambiguous remainder, and act on it only when it's confident and calm.
- Store every human ruling, so the same command is decided in code next time.
- Fail closed at every seam: model down, hook throws, nobody to ask, endpoint hangs.
- Back the worst cases with managed rules. Mods can be unloaded and rules can't.