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…

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.
| Event | What it does | ||
|---|---|---|---|
agent.spawn | Refuses 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.check | Inside 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 init | add | set, 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 Agent | Raises 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.complete | Appends 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 line | blitz <slug> 3/7 · T-004 running · 1 blocked from the newest unfinished docs/plans/*/tasks.json, refreshed after each scripts/tasks.sh call. |
/plugin install blitz-guard@blitz
Requires Claude Code 2.1.287 or later (mods); blitz itself requires 2.1.296.
--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.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.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.
hooks/register.ts 122 lines1import 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}
122hooks/policy.ts 219 lines1// 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