Hook Chain

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.

A four-step stone staircase. At the bottom a gatekeeper robot pours cards through a sieve at a turnstile, and most pass straight through. On the second step a referee robot holds two probability dials. On the third a larger robot reads a scroll. At the top a person at a desk with a lamp signs a card. A dotted arrow shows one card climbing only to the second step.
Each command climbs only as high as it needs to. Most never leave the bottom step.

The tiers, mapped onto a mod

TierIn a guard modCost per commandDecides
Rules (managed)Permission deny rules and managed PreToolUse hooks, before any mod~0The obvious spellings, enforced even if every mod is unloaded
CodeRegex 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 doubtHosted 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 textHaiku 5.5 p50 2–3 s, and only 33% identical on re-askRarely 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 dialogSeconds to minutesAnything 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

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

What to remember

  1. A tool.call guard is the cascade's fastest tier. Keep most decisions in code.
  2. Call a decision model only for the ambiguous remainder, and act on it only when it's confident and calm.
  3. Store every human ruling, so the same command is decided in code next time.
  4. Fail closed at every seam: model down, hook throws, nobody to ask, endpoint hangs.
  5. Back the worst cases with managed rules. Mods can be unloaded and rules can't.