SLOPSHOPPER

blitz-guard

Optional Claude Mod for blitz: keeps blitz agents off haiku and out of agent teams, keeps blitz critics read-only by agent id, and shows plan progress in the…

newguardtoaststatusagents
★ 2v0.2.0MITupdated 2026-10-13lasswellt/blitz-cc/mods/blitz-guard
A shopper browsing a rack in a slop shop
README

blitz-guard

Optional Claude Mod for the blitz plugin. It adds checks that a shell hook cannot make, on top of blitz's bash hooks, which stay the baseline.

EventWhat it does
agent.spawnRefuses a blitz:* agent that would start as an agent-team teammate (it would lose its skills preload and could not be resumed). Moves a blitz:* agent named on haiku to its routing default (agents.reference.md §11); a workflow agent, whose spawn cannot be rewritten, is refused and the skill falls back to its Agent() path.
tool.checkInside a blitz:critic, blitz:research-critic or blitz:design-critic loop (matched by agentId), refuses Write/Edit/MultiEdit/NotebookEdit and shell that writes: git writes, rm/mv/cp, in-place edits, package installs, redirection outside /tmp and /dev/null, `tasks.sh initaddset, find -delete/-exec rm, xargs into a writer, and interpreter one-liners that write files. sh -c/bash -c payloads are checked recursively; eval, source and commands taken from a variable are refused outright because their text does not show what runs. tasks.sh verify` and read-only commands pass.
tool.call on AgentRaises a blitz:dev, blitz:test-writer or blitz:critic call made below medium effort to medium (the Agent tool's effort is rewritable here; agent.spawn has no effort field). An absent effort is left to the agent file's effort:. Workflow agents do not pass through this hook.
turn.completeAppends each subagent run's final token counts to .cc-sessions/agent-usage.mod.jsonl. blitz's SubagentStop hook prices runs from their transcripts, whose output counts are streaming snapshots; the dashboard uses these final counts where they exist.
status lineblitz <slug> 3/7 · T-004 running · 1 blocked from the newest unfinished docs/plans/*/tasks.json, refreshed after each scripts/tasks.sh call.

Install

/plugin install blitz-guard@blitz

Requires Claude Code 2.1.287 or later (mods); blitz itself requires 2.1.296.

What it is not

  • Not a boundary. A mod is unsandboxed JavaScript running with your privileges, and --bare, --safe-mode and disableAllHooks switch it off. The shell checks match command text: they unwrap sh -c and refuse eval and variable-led commands, and refuse running files from temp directories, but the commands a plan itself declares (a task's verify[] through tasks.sh, npm run build) can still write, by design. blitz's bash hooks, the permission rules, auto mode and the sandbox remain the layers that hold.
  • Effort: a floor, not a router. It only raises a too-low effort on the Agent call. Workflow agents are not covered (their agent() options cannot be rewritten), and the floor relies on Claude Code honouring a rewritten effort, which the plugin test confirms reaches the engine but no live run has confirmed takes effect.
  • Fail-open outside critics. If a hook throws, spawns and non-critic tool calls proceed as without the mod; a failed check inside a known critic loop refuses.

Develop

claude plugin validate mods/blitz-guard
claude plugin test mods/blitz-guard
claude --plugin-dir ./mods/blitz-guard    # one session, hot-reloads on save

hooks/policy.ts holds every rule as a pure function; hooks/register.ts wires them to events.

Source 2 files
hooks/register.ts 122 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { READ_ONLY_AGENTS, effortFloor, planProgress, readOnlyVerdict, spawnVerdict, statusText, usageLine } from './policy.ts'
3
4// blitz-guard: an optional layer over blitz's bash hooks (hooks/hooks.json in
5// the blitz plugin). Those hooks stay the baseline: a mod is unsandboxed JS the
6// person must trust, needs Claude Code >=2.1.287, and is switched off by
7// --bare, --safe-mode and disableAllHooks. This module adds what a shell hook
8// cannot see:
9//   agent.spawn:   blitz agents never become teammates and never run on haiku
10//   tool.check:    calls inside a blitz critic's loop (by agentId) stay read-only
11//   tool.call Agent:  a blitz:dev/test-writer/critic spawn below `medium` effort
12//                   is raised to it (agent.spawn has no effort field; the Agent
13//                   call's own `effort` does). Workflow agents never pass here.
14//   status line:   plan progress from docs/plans/*/tasks.json
15//   turn.complete:  each subagent run's final token counts to
16//                   .cc-sessions/agent-usage.mod.jsonl (the SubagentStop hook's
17//                   transcript row has output only as a streaming lower bound)
18
19// agentId → subagentType for spawns this session saw. A module variable is
20// lost on reload; tool.check falls back to $.agent.list(), which covers every
21// subagent but not workflow agents.
22const typeOf = new Map<string, string>()
23
24// Plan progress for the status line: the newest unfinished tasks.json under
25// docs/plans/ (archive excluded). Refreshed after any scripts/tasks.sh call and
26// at session start; cleared when no plan has open work.
27async function refresh($: EngineInterface) {
28  if (!(await $.fs.exists('docs/plans'))) return
29  const plans = (await $.fs.list('docs/plans')).filter(d => d.kind === 'dir' && d.name !== 'archive')
30  let best: { mtime: number; text: string } | undefined
31  for (const d of plans) {
32    const path = `docs/plans/${d.name}/tasks.json`
33    const stat = await $.fs.stat(path).catch(() => undefined)
34    if (stat === undefined) continue
35    const p = planProgress(d.name, String(await $.fs.read(path)))
36    if (p === undefined || p.done === p.total) continue
37    if (best === undefined || stat.mtimeMs > best.mtime) best = { mtime: stat.mtimeMs, text: statusText(p) }
38  }
39  $.ui.status(best?.text)
40}
41
42// Appends one line to the mod's usage ledger. Read-modify-write, serialized
43// through `pending` so two subagents finishing together do not lose a line;
44// only this mod writes the file (the bash hook writes agent-usage.jsonl).
45const LEDGER = '.cc-sessions/agent-usage.mod.jsonl'
46let pending: Promise<void> = Promise.resolve()
47async function appendUsage($: EngineInterface, line: string) {
48  const before = (await $.fs.exists(LEDGER)) ? String(await $.fs.read(LEDGER)) : ''
49  await $.fs.write(LEDGER, before + line + '\n')
50}
51
52export const register: Register = on => {
53  on('agent.spawn', async ($, e, next) => {
54    const verdict = spawnVerdict({
55      subagentType: e.subagentType,
56      model: e.model,
57      isTeammate: e.isTeammate === true,
58      isWorkflow: e.workflow !== undefined,
59    })
60    if (verdict.kind === 'deny') return { deny: verdict.reason }
61    const started = verdict.kind === 'model' ? await next({ ...e, model: verdict.model }) : await next(e)
62    if (verdict.kind === 'model') $.ui.toast(verdict.reason)
63    if (started.agentId !== undefined && e.subagentType.startsWith('blitz:')) {
64      typeOf.set(started.agentId, e.subagentType)
65      if (e.subagentType === 'blitz:dev') $.ui.status(`blitz ${e.description.slice(0, 40)} running`)
66    }
67    return started
68  }).catch(($, e, next) => next(e))
69
70  on('tool.check', async ($, e, next) => {
71    if (e.agentId === undefined) return next(e)
72    let agentType = typeOf.get(e.agentId)
73    if (agentType === undefined) {
74      const row = (await $.agent.list()).find(a => a.id === e.agentId)
75      if (row !== undefined && row.type.startsWith('blitz:')) {
76        agentType = row.type
77        typeOf.set(e.agentId, row.type)
78      }
79    }
80    const verdict = readOnlyVerdict(agentType, e.tool, e.input)
81    return verdict.kind === 'deny' ? { decision: 'deny', reason: verdict.reason } : next(e)
82  }).catch(($, e, next) => {
83    // A failed check inside a known critic refuses; anywhere else the bash
84    // hooks and the permission rules decide, as without this mod.
85    const known = e.agentId !== undefined ? typeOf.get(e.agentId) : undefined
86    return known !== undefined && READ_ONLY_AGENTS.has(known)
87      ? { decision: 'deny' as const, reason: 'blitz-guard: the read-only check failed inside a blitz critic; refused.' }
88      : next(e)
89  })
90
91  on('tool.call', { tool: 'Agent' }, ($, e, next) => {
92    const raised = effortFloor(e.subagent_type, e.effort)
93    if (raised === undefined) return next(e)
94    $.ui.toast(`blitz-guard: ${e.subagent_type} runs at ${raised} effort or above (was ${e.effort}).`)
95    return next({ ...e, effort: raised })
96  }).catch(($, e, next) => next(e))
97
98  on('session.start', async ($, e, next) => {
99    const result = await next(e)
100    await refresh($).catch(() => undefined)
101    return result
102  })
103
104  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
105    const ran = await next(e)
106    if (/\btasks\.sh"?\s+(set|verify|add|init)\b/.test(e.command)) await refresh($).catch(() => undefined)
107    return ran
108  }).catch(($, e, next) => next(e))
109
110  on('turn.complete', async ($, e, next) => {
111    const result = await next(e)
112    if (e.agentId === undefined || e.usage === undefined) return result
113    const agentType = typeOf.get(e.agentId) ?? (await $.agent.list()).find(a => a.id === e.agentId)?.type ?? ''
114    const line = usageLine({ ts: new Date(await $.clock.now()).toISOString(), agentId: e.agentId, agentType, usage: e.usage })
115    const agentId = e.agentId
116    pending = pending.then(() => appendUsage($, line)).catch(() => undefined)
117    await pending
118    if (agentType.startsWith('blitz:')) typeOf.set(agentId, agentType)
119    return result
120  }).catch(($, e, next) => next(e))
121}
122
hooks/policy.ts 219 lines
1// Pure policy for the blitz-guard mod: no `$`, so `claude plugin test` can
2// exercise every rule directly. register.ts wires these into the events.
3
4/** blitz agent types whose definition is read-only (agents/*.md, security.md §5). */
5export const READ_ONLY_AGENTS = new Set([
6  'blitz:critic',
7  'blitz:research-critic',
8  'blitz:design-critic',
9])
10
11/** The model each blitz agent falls back to when a spawn names a disallowed one (agents.reference.md §11). */
12export const DEFAULT_MODEL: Record<string, string> = {
13  'blitz:dev': 'sonnet',
14  'blitz:test-writer': 'sonnet',
15  'blitz:critic': 'opus',
16  'blitz:research-critic': 'sonnet',
17  'blitz:design-critic': 'sonnet',
18}
19
20export type SpawnFacts = {
21  subagentType: string
22  model?: string
23  isTeammate?: boolean
24  isWorkflow: boolean
25}
26
27export type SpawnVerdict =
28  | { kind: 'pass' }
29  | { kind: 'deny'; reason: string }
30  | { kind: 'model'; model: string; reason: string }
31
32const isBlitz = (t: string) => t.startsWith('blitz:')
33const family = (m: string) => (/haiku/i.test(m) ? 'haiku' : /sonnet/i.test(m) ? 'sonnet' : /opus/i.test(m) ? 'opus' : 'other')
34
35/**
36 * agent.spawn policy. blitz agents never run as teammates (a teammate loses its
37 * skills preload and cannot be resumed) and never on haiku (the routing matrix
38 * keeps haiku for collectors and retrieval, not implementation or verdicts).
39 * A workflow agent's content cannot be rewritten, only refused.
40 */
41export function spawnVerdict(f: SpawnFacts): SpawnVerdict {
42  if (!isBlitz(f.subagentType)) return { kind: 'pass' }
43  if (f.isTeammate) {
44    return {
45      kind: 'deny',
46      reason: `blitz-guard: ${f.subagentType} runs as a subagent, not a teammate. With agent teams on, drop \`name\` or pass \`isolation\` (agents.reference.md §12).`,
47    }
48  }
49  if (f.model !== undefined && family(f.model) === 'haiku') {
50    const fallback = DEFAULT_MODEL[f.subagentType] ?? 'sonnet'
51    const reason = `blitz-guard: ${f.subagentType} does not run on haiku (agents.reference.md §11)`
52    return f.isWorkflow
53      ? { kind: 'deny', reason: `${reason}; the workflow agent is refused and the skill falls back to the Agent path.` }
54      : { kind: 'model', model: fallback, reason: `${reason}; running it on ${fallback}.` }
55  }
56  return { kind: 'pass' }
57}
58
59const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
60
61// Shell that changes the repository or the machine. Best effort by text: the
62// critic's own Bash is a read subset by contract, and blitz's bash hooks and the
63// sandbox stay the boundary. Writes under /tmp and /dev/null are allowed (the
64// critic's §2.8b literals file); `scripts/tasks.sh verify` is the one plan-state
65// write a critic makes.
66const MUTATING: { rx: RegExp; what: string }[] = [
67  { rx: /\bgit\s+(commit|push|reset|checkout|switch|restore|add|rm|mv|merge|rebase|cherry-pick|revert|stash|tag|clean|apply|am|branch\s+-[dDmM]|worktree\s+(add|remove|prune))\b/, what: 'a git write' },
68  { rx: /(^|[;&|(]\s*|\b(xargs|sudo|env|timeout\s+\d+)\s+)(rm|rmdir|mv|cp|chmod|chown|ln|truncate|shred|dd|install|touch|mkdir)\s/, what: 'a file-system write' },
69  { rx: /\b(sed|perl)\s+(-[a-zA-Z]*i|--in-place)/, what: 'an in-place edit' },
70  { rx: /\b(npm|pnpm|yarn|bun)\s+(i|install|add|remove|uninstall|update|upgrade|link|publish)\b/, what: 'a package change' },
71  { rx: /\b(pip3?|uv|poetry|cargo|go)\s+(install|add|remove|uninstall|get)\b/, what: 'a package change' },
72]
73
74// Checked on the raw command: the script path is usually quoted ("${CLAUDE_PLUGIN_ROOT}/scripts/tasks.sh").
75const PLAN_WRITE = /\btasks\.sh"?\s+(init|add|set)\b/
76
77// Shell that runs code the text does not show: these are refused in a critic
78// rather than guessed at.
79const OPAQUE: { rx: RegExp; what: string }[] = [
80  { rx: /(^|[;&|(]\s*)(eval|source|\.)\s/, what: 'dynamic execution (eval/source)' },
81  { rx: /(^|[;&|(]\s*)(\$[A-Za-z_{(]|`)/, what: 'a command taken from a variable or substitution' },
82  { rx: /\bxargs\b[^|;&]*\b(rm|mv|cp|sh|bash|tee|sed\s+-i)\b/, what: 'xargs into a writer' },
83  { rx: /\bfind\b[^|;&]*\s-(delete|exec(dir)?\s+(rm|mv|cp|sh|bash|sed))\b/, what: 'find that deletes or rewrites' },
84  // Writing to /tmp is allowed, so running what is there is not: it is code no check saw.
85  { rx: /(^|[;&|(]\s*|\b(?:ba|z|da|k)?sh\s+|\b(?:python3?|node|perl|ruby|deno|bun)\s+)(\/tmp\/|\/var\/tmp\/|\$\{?(TMPDIR|SESSION_TMP_DIR)\}?\/)/, what: 'running a file from a temp directory' },
86]
87// Interpreter one-liners that write or delete files.
88const INLINE_CODE = /\b(python3?|node|perl|ruby|deno|bun)\s+(-c|-e|--eval|-p)\s/
89const INLINE_WRITE = /(writeFile|appendFile|createWriteStream|unlink|rmSync|rmdirSync|renameSync|copyFile|os\.remove|os\.unlink|os\.rename|shutil\.|\.write_text|\.write_bytes|open\([^)]*,\s*['"][wax]|File\.write|IO\.write|truncate)/
90// `sh -c '<payload>'` and friends: the payload is shell too and is checked the same way.
91const SHELL_C = /\b(?:ba|z|da|k)?sh\s+(?:-[a-zA-Z]*c[a-zA-Z]*)\s+(?:'([^']*)'|"((?:[^"\\]|\\.)*)")/g
92
93/** The command with quoted strings blanked, so a grep pattern like 'a>b' or 'rm -rf' is not read as shell. */
94function unquoted(cmd: string): string {
95  return cmd.replace(/'[^']*'/g, "''").replace(/"(?:[^"\\]|\\.)*"/g, '""')
96}
97
98/** Output redirections to anything but /dev/null, /tmp, a numbered fd or $TMPDIR. */
99function writesFile(cmd: string): boolean {
100  const re = /(^|[^<>&0-9])(>>?|>\|)\s*("?)([^\s"&;|)]+)/g
101  let m: RegExpExecArray | null
102  while ((m = re.exec(cmd)) !== null) {
103    const target = m[4] ?? ''
104    if (/^(\/dev\/null|\/dev\/std(out|err)|&\d|\/tmp\/|\$\{?TMPDIR\}?\/|\$\{?SESSION_TMP_DIR\}?\/)/.test(target)) continue
105    return true
106  }
107  return /\btee\s+(-a\s+)?(?!\/tmp\/|\/dev\/null)[^\s|;&]+/.test(cmd)
108}
109
110export type CheckVerdict = { kind: 'pass' } | { kind: 'deny'; reason: string }
111
112/** tool.check policy for a call inside a read-only blitz agent's loop. */
113export function readOnlyVerdict(agentType: string | undefined, tool: string, input: unknown): CheckVerdict {
114  if (agentType === undefined || !READ_ONLY_AGENTS.has(agentType)) return { kind: 'pass' }
115  if (WRITE_TOOLS.has(tool)) {
116    return { kind: 'deny', reason: `blitz-guard: ${agentType} is read-only; ${tool} is refused.` }
117  }
118  if (tool === 'Bash' || tool === 'PowerShell') {
119    const raw = String((input as { command?: unknown } | null)?.command ?? '')
120    const why = shellWrite(raw, 0)
121    if (why !== undefined) return { kind: 'deny', reason: `blitz-guard: ${agentType} is read-only; ${why}.` }
122  }
123  return { kind: 'pass' }
124}
125
126/** Why a shell command writes, or undefined. Recurses into `sh -c` payloads (depth-capped). */
127function shellWrite(raw: string, depth: number): string | undefined {
128  if (depth > 3) return 'shell nested too deep to check'
129  if (PLAN_WRITE.test(raw)) return 'this command is a plan-state write (only `tasks.sh verify` is allowed)'
130  if (INLINE_CODE.test(raw) && INLINE_WRITE.test(raw)) return 'an inline interpreter script writes or deletes files'
131  for (const m of raw.matchAll(SHELL_C)) {
132    const inner = shellWrite((m[1] ?? m[2] ?? '').replace(/\\(["\\$`])/g, '$1'), depth + 1)
133    if (inner !== undefined) return `inside sh -c: ${inner}`
134  }
135  const cmd = unquoted(raw)
136  for (const { rx, what } of OPAQUE) if (rx.test(cmd)) return `this command is ${what}`
137  for (const { rx, what } of MUTATING) if (rx.test(cmd)) return `this command is ${what}`
138  if (writesFile(cmd)) return 'redirect output to /tmp or /dev/null'
139  return undefined
140}
141
142export type PlanProgress = { slug: string; done: number; total: number; inProgress: string[]; blocked: number }
143
144/** Progress of one tasks.json, or undefined when it does not parse as blitz-tasks. */
145export function planProgress(slug: string, text: string): PlanProgress | undefined {
146  let doc: unknown
147  try {
148    doc = JSON.parse(text)
149  } catch {
150    return undefined
151  }
152  const tasks = (doc as { tasks?: unknown })?.tasks
153  if (!Array.isArray(tasks)) return undefined
154  const status = (t: unknown) => String((t as { status?: unknown })?.status ?? '')
155  return {
156    slug,
157    done: tasks.filter(t => status(t) === 'done').length,
158    total: tasks.length,
159    inProgress: tasks.filter(t => status(t) === 'in_progress').map(t => String((t as { id?: unknown }).id ?? '?')),
160    blocked: tasks.filter(t => status(t) === 'blocked').length,
161  }
162}
163
164/** The status-line text: `blitz <slug> 3/7 · T-004 running · 1 blocked`. Slug and ids are repo data: kept to safe characters. */
165export function statusText(p: PlanProgress): string {
166  const safe = (s: string) => s.replace(/[^A-Za-z0-9_.-]/g, '').slice(0, 40)
167  const parts = [`blitz ${safe(p.slug)} ${p.done}/${p.total}`]
168  if (p.inProgress.length > 0) parts.push(`${p.inProgress.slice(0, 3).map(safe).join(' ')} running`)
169  if (p.blocked > 0) parts.push(`${p.blocked} blocked`)
170  return parts.join(' · ')
171}
172
173export type UsageFacts = {
174  ts: string
175  agentId: string
176  agentType: string
177  usage: { model: string; input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens?: number }
178}
179
180/** One JSONL row for .cc-sessions/agent-usage.mod.jsonl: a subagent run's final counts. Identifiers kept to safe characters. */
181export function usageLine(f: UsageFacts): string {
182  const safe = (s: string) => s.replace(/[^A-Za-z0-9_.:/-]/g, '').slice(0, 80)
183  const n = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0)
184  return JSON.stringify({
185    ts: f.ts,
186    source: 'turn.complete',
187    agent_id: safe(f.agentId),
188    agent: safe(f.agentType),
189    model: safe(f.usage.model),
190    input: n(f.usage.input_tokens),
191    output: n(f.usage.output_tokens),
192    cache_read: n(f.usage.cache_read_input_tokens),
193    cache_write: n(f.usage.cache_creation_input_tokens),
194  })
195}
196
197export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
198const EFFORT_ORDER: Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
199
200/**
201 * The lowest effort a blitz agent runs at. Anthropic documents Sonnet 5.5 at
202 * `low` skipping its own checks and Opus 5.5 `medium` as the strong default, so
203 * an implementation agent or the verdict below `medium` is a routing mistake.
204 * An absent effort is left alone: the agent file's `effort:` applies.
205 */
206export const EFFORT_FLOOR: Record<string, Effort> = {
207  'blitz:dev': 'medium',
208  'blitz:test-writer': 'medium',
209  'blitz:critic': 'medium',
210}
211
212/** The effort an Agent call should carry, or undefined to leave the call as it is. */
213export function effortFloor(subagentType: string | undefined, effort: Effort | undefined): Effort | undefined {
214  if (subagentType === undefined || effort === undefined) return undefined
215  const floor = EFFORT_FLOOR[subagentType]
216  if (floor === undefined) return undefined
217  return EFFORT_ORDER.indexOf(effort) < EFFORT_ORDER.indexOf(floor) ? floor : undefined
218}
219