SLOPSHOPPER

warden

KOZMOS Warden: a guard for destructive shell commands. Before Claude or a subagent runs rm -rf on a broad path, git push --force, reset --hard, clean -fd, DROP…

newbandguardcommandtoasttimer
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/warden
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · warden
› fix the failing auth test and add an audit log call ╭────────────────────────────────────╮ │ warden │ ⏺ Read(src/auth.ts) │ 🛡 warden blocked: git push │ ⎿ Read 6 lines │ --force │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by warden: KOZMOS warden: blocked git push --force (high risk). Overwrites the remote branch: commi ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /warden ⎿ warden: 🛡 KOZMOS warden · mode ask · band shown (/warden hide) ⎿ warden: ⎿ warden: Rules: ⎿ warden: CRITICAL rm -r / Remove-Item -Recurse / rd /s on /, ~, C:\, a home or a system folder ⎿ warden: CRITICAL mkfs, wipefs, dd of=/dev/…, format C:, Format-Volume, Clear-Disk ⎿ warden: CRITICAL DROP DATABASE / DROP SCHEMA ⟨Claude Code's own drawing⟩ 🛡 warden blocked: git push --force · high · rm -rf build && git push --force origin main ▮▮▮▮▮▮▮▮ ✕ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 🛡 warden blocked: git push --force · high · rm -rf build && git push --force origin main ▮▮▮▮▮▮▮▮ ✕
README

KOZMOS Warden

A guard for destructive shell commands. Before Claude or a subagent runs a Bash or PowerShell command that matches a risk rule, warden stops and asks you: Allow once or Deny. When nobody can answer (a dismissed dialog, a -p run) the command is refused, and if warden itself fails it fails closed.

SeverityRules
criticalrm -r / Remove-Item -Recurse / rd /s on /, ~, C:\, a home or system folder (and $VAR/ that would become /), mkfs, dd of=/dev/…, format C:, Format-Volume, DROP DATABASE
highrm -rf . / * / .., git push --force (not --force-with-lease), git reset --hard, git clean -f[d], git checkout -- ., git restore ., DROP TABLE, TRUNCATE, del /s, shutdown, chmod -R 777, `curl … \sh, iex (irm …)`, your own patterns
mediumgit branch -D, git stash clear / drop

Look-alikes are left alone on purpose: rm -rf node_modules, rm -rf dist, any named path inside the project, /tmp/…, git push --force-with-lease, git clean -n, git checkout -- one-file, and SQL words inside grep, echo or a commit message. Commands inside bash -c, powershell -Command, cmd /c and $( … ) are judged too.

  • Terminal: a one-row band 🛡 warden blocked: git push --force · high · … for 8 s, with a countdown and ✕.
  • Desktop: the same band as one SVG: a shield, the rule, a severity chip and a burning fuse.
  • /warden prints the rules and this session's blocked / allowed / warned log; /warden hide and /warden show toggle the band (kept across sessions).

Settings: mode (ask default, deny, warn) and extraPatterns (regexes separated by ;;, e.g. kubectl delete ;; terraform destroy).

Türkçe

Yıkıcı kabuk komutlarına karşı bir bekçi. Claude ya da bir alt ajan bir risk kuralına uyan Bash veya PowerShell komutu çalıştırmadan önce warden durur ve sorar: Allow once (bir kez izin ver) ya da Deny (reddet). Kimse yanıt veremezse (iletişim kutusu kapatıldıysa, -p çalışmasıysa) komut reddedilir; warden'ın kendisi hata verirse yine reddeder (kapalı başarısızlık).

Seviyeler: critical — kök, sürücü, ev ya da sistem klasörünü silmek, mkfs, format C:, DROP DATABASE; high — git push --force, reset --hard, clean -fd, checkout -- ., DROP TABLE, TRUNCATE, del /s, shutdown, chmod -R 777, curl | sh, kendi kalıplarınız; medium — git branch -D, git stash clear.

Benzer görünen zararsız komutlara dokunulmaz: rm -rf node_modules, rm -rf dist, projenin içindeki adlı yollar, --force-with-lease, git clean -n, grep / echo / commit mesajı içindeki SQL sözcükleri.

  • Terminal: engellemeden sonra 8 saniye görünen tek satırlık bant ve ✕.
  • Masaüstü: kalkan, kural, önem çipi ve yanan fitilli tek bir SVG.
  • /warden kuralları ve bu oturumun kaydını gösterir; /warden hide|show bandı gizler/gösterir.

Ayarlar: mode (ask, deny, warn) ve extraPatterns (;; ile ayrılmış düzenli ifadeler).

Source 4 files
hooks/register.tsx 226 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { WardenEntry, WardenSnap, WardenVerdict } from '../types'
5import { KZ, clip, fitText, fmtSpan, pxOf, svg, svgText } from './lib/kz.ts'
6import { RULE_BOOK, classify, parseExtra } from './rules.ts'
7import type { Hit, Severity } from './rules.ts'
8
9const FLASH_MS = 8000
10const EMPTY: WardenSnap = { log: [], blocked: 0, allowed: 0, warned: 0, flash: null }
11const snapAtom = atom({ plugin: 'warden', key: 'snap' } as const, EMPTY)
12const hiddenAtom = atom({ plugin: 'warden', key: 'isHidden' } as const, false)
13
14type Mode = 'ask' | 'deny' | 'warn'
15
16const SEV_COLOR: Record<Severity, string> = { critical: KZ.red, high: KZ.amber, medium: KZ.yellow }
17const VERDICT_COLOR: Record<WardenVerdict, string> = { blocked: KZ.red, allowed: KZ.green, warned: KZ.amber }
18const VERDICT_GLYPH: Record<WardenVerdict, string> = { blocked: '✖', allowed: '✓', warned: '⚠' }
19
20/** The command of a shell tool call, or undefined for any other tool. */
21function commandOf(e: { tool: string; command?: unknown }): string | undefined {
22  return (e.tool === 'Bash' || e.tool === 'PowerShell') && typeof e.command === 'string' ? e.command : undefined
23}
24
25// ---------------------------------------------------------------------------
26// Judging, recording, the hide toggle: top-level, since `$` flows only here.
27
28let flashTimer: { cancel: () => void } | undefined
29
30async function judge($: EngineInterface, mode: Mode, found: Hit, command: string, isAgent: boolean): Promise<{ verdict: WardenVerdict; by: string }> {
31  if (mode === 'deny') return { verdict: 'blocked', by: 'mode deny' }
32  if (mode === 'warn') return { verdict: 'warned', by: 'mode warn' }
33  try {
34    const who = isAgent ? 'A subagent' : 'Claude'
35    const answer = await $.ui.ask(
36      `🛡 KOZMOS warden: ${who} wants to run a ${found.severity}-risk command (${found.label}): ${clip(command, 160)} — ${found.why} Run it?`,
37      { options: ['Allow once', 'Deny'], header: 'warden' },
38    )
39    return answer.trim() === 'Allow once' ? { verdict: 'allowed', by: 'you' } : { verdict: 'blocked', by: 'you' }
40  } catch {
41    // Dismissed, or a -p run: no one could say yes.
42    return { verdict: 'blocked', by: 'no one to ask' }
43  }
44}
45
46async function record($: EngineInterface, entry: WardenEntry): Promise<void> {
47  const flashes = entry.verdict !== 'allowed'
48  await update($, snapAtom, prev => ({
49    log: [...prev.log, entry].slice(-40),
50    blocked: prev.blocked + (entry.verdict === 'blocked' ? 1 : 0),
51    allowed: prev.allowed + (entry.verdict === 'allowed' ? 1 : 0),
52    warned: prev.warned + (entry.verdict === 'warned' ? 1 : 0),
53    flash: flashes
54      ? { verdict: entry.verdict, label: entry.label, severity: entry.severity, command: entry.command, until: entry.at + FLASH_MS }
55      : prev.flash,
56  }))
57  if (!flashes) return
58  flashTimer?.cancel()
59  flashTimer = $.clock.after(FLASH_MS, () => void clearFlash($).catch(() => undefined))
60}
61
62async function clearFlash($: EngineInterface): Promise<void> {
63  const now = await $.clock.now()
64  // The one live timer always belongs to a flash, so `flash` is set here; a
65  // timer that fired early keeps it (the band hides it once its time is up).
66  await update($, snapAtom, prev => (Number(prev.flash?.until) <= now + 50 ? { ...prev, flash: null } : prev))
67}
68
69async function setHidden($: EngineInterface, isHidden: boolean): Promise<void> {
70  await update($, hiddenAtom, () => isHidden)
71  try {
72    await $.store.set('isHidden', isHidden)
73  } catch {
74    // Hidden for this session at least.
75  }
76}
77
78async function loadHidden($: EngineInterface): Promise<void> {
79  try {
80    const v = await $.store.get('isHidden')
81    if (typeof v === 'boolean') await update($, hiddenAtom, () => v)
82  } catch {
83    // Nothing stored yet.
84  }
85}
86
87async function report($: EngineInterface, mode: Mode, bad: readonly string[]): Promise<string> {
88  const snap = await read($, snapAtom)
89  const isHidden = await read($, hiddenAtom)
90  const now = await $.clock.now()
91  const lines = [`🛡 KOZMOS warden · mode ${mode} · band ${isHidden ? 'hidden (/warden show)' : 'shown (/warden hide)'}`, '', 'Rules:']
92  for (const [sev, text] of RULE_BOOK) lines.push(`  ${sev.toUpperCase().padEnd(8)} ${text}`)
93  if (bad.length) lines.push(`  (skipped bad extraPatterns: ${bad.join(' ;; ')})`)
94  lines.push('', `This session: ${snap.blocked} blocked · ${snap.allowed} allowed · ${snap.warned} warned`)
95  if (!snap.log.length) lines.push('  Nothing risky yet.')
96  for (const l of [...snap.log].reverse().slice(0, 20)) {
97    lines.push(`  ${VERDICT_GLYPH[l.verdict]} ${l.verdict.padEnd(7)} ${l.label.padEnd(20)} ${fmtSpan(now - l.at).padStart(5)} ago · ${l.by}${l.isAgent ? ' · subagent' : ''}`)
98    lines.push(`      ${clip(l.command, 100)}`)
99  }
100  return lines.join('\n')
101}
102
103export const register: Register = (on, options) => {
104  const mode: Mode = options.mode === 'deny' || options.mode === 'warn' ? options.mode : 'ask'
105  // The load checks userConfig: extraPatterns is always a string (default '').
106  const { patterns: extra, bad } = parseExtra(options.extraPatterns as string)
107
108  on('session.start', async ($, e, next) => {
109    const started = await next(e)
110    await $.command.register({ name: 'warden', description: 'KOZMOS: the destructive-command guard — rules and this session\'s log (/warden hide|show for the band)', argumentHint: '[hide|show]', immediate: true })
111    await loadHidden($)
112    return started
113  })
114
115  on('command.run', { command: 'warden' }, async ($, e) => {
116    const arg = e.args.trim().toLowerCase()
117    if (arg === 'hide' || arg === 'show' || arg === 'toggle') {
118      const isHidden = arg === 'toggle' ? !(await read($, hiddenAtom)) : arg === 'hide'
119      await setHidden($, isHidden)
120      return { text: isHidden ? 'warden band hidden (the guard still runs). /warden show brings it back.' : 'warden band shown.' }
121    }
122    return { text: await report($, mode, bad) }
123  })
124
125  // The guard: judge before `next`, so nothing has run when it refuses.
126  on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
127    const command = commandOf(e)
128    const found = command === undefined ? null : classify(command, extra)
129    if (!found || command === undefined) return next(e)
130    const isAgent = e.agentId !== undefined
131    const { verdict, by } = await judge($, mode, found, command, isAgent)
132    const at = await $.clock.now()
133    await record($, { at, verdict, label: found.label, severity: found.severity, command: clip(command, 200), by, isAgent })
134    if (verdict === 'blocked') {
135      $.ui.toast(`🛡 warden blocked: ${found.label}`)
136      const why = by === 'you' ? 'The person denied it.' : by === 'no one to ask' ? 'No one could be asked to confirm it.' : 'warden is set to deny risky commands.'
137      return { deny: `KOZMOS warden: blocked ${found.label} (${found.severity} risk). ${found.why} ${why} Ask the person first, or use a safer form.` }
138    }
139    if (verdict === 'warned') $.ui.toast(`⚠ warden: running ${found.label} (${found.severity} risk)`)
140    return next(e)
141  }).catch(($, e, next) => {
142    // Raised beneath another hook's call: judge the command alone, no `$`.
143    if (next.error.kind === 're-entry') {
144      const command = commandOf(e)
145      const found = command === undefined ? null : classify(command, extra)
146      return found ? { deny: `KOZMOS warden: blocked ${found.label} (${found.severity} risk); it could not be confirmed here.` } : next(e)
147    }
148    return next.called ? next(e) : { deny: 'KOZMOS warden failed closed' }
149  })
150
151  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
152    const drawn = await next(e)
153    if (e.props.hasSurvey || (await read($, hiddenAtom))) return drawn
154    const { flash } = await read($, snapAtom)
155    if (!flash) return drawn
156    const now = await $.clock.now()
157    const left = flash.until - now
158    if (left <= 0) return drawn
159    const ui = $.ui.resolve(e)
160    const verb = flash.verdict === 'blocked' ? 'blocked' : 'warned'
161    const color = VERDICT_COLOR[flash.verdict]
162    const cols = Math.max(24, e.props.bodyColumns || 80)
163
164    if ('Svg' in ui && e.surface !== 'terminal') {
165      const { Box, Button, Svg } = ui
166      const W = Math.max(220, pxOf(cols) - 40)
167      return (
168        <Box flexDirection="column">
169          {drawn}
170          <Box key="warden" flexDirection="row" alignItems="center">
171            <Svg source={flashSvg(W, flash.verdict, flash.label, flash.severity, flash.command, left)} alt={`warden ${verb}: ${flash.label}`} width={W} height={40} />
172            <Button key="warden-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
173          </Box>
174        </Box>
175      )
176    }
177
178    const { Box, Button, Text } = ui
179    const head = `🛡 warden ${verb}: ${flash.label}`
180    const room = Math.max(0, cols - head.length - 18)
181    return (
182      <Box flexDirection="column">
183        {drawn}
184        <Box key="warden" flexDirection="row" justifyContent="space-between">
185          <Text wrap="truncate-end">
186            <Text bold color={color}>{head}</Text>
187            <Text color={SEV_COLOR[flash.severity]}> · {flash.severity}</Text>
188            {room > 8 ? <Text dimColor> · {clip(flash.command, room)}</Text> : ''}
189            <Text dimColor> {'▮'.repeat(Math.max(1, Math.ceil(left / 1000)))}</Text>
190          </Text>
191          <Button key="warden-hide" label="✕" plain dimColor role="dismiss" onPress={() => void setHidden($, true)} />
192        </Box>
193      </Box>
194    )
195  })
196}
197
198/** The desktop flash: a shield, the verdict, the rule, the command and an 8 s fuse. */
199function flashSvg(W: number, verdict: WardenVerdict, label: string, severity: Severity, command: string, leftMs: number): string {
200  const c = VERDICT_COLOR[verdict]
201  const sc = SEV_COLOR[severity]
202  const head = `warden ${verdict === 'blocked' ? 'blocked' : 'warned'}:`
203  const headW = head.length * 7.2
204  const labelX = 44 + headW + 6
205  const labelW = Math.min(W * 0.4, label.length * 7.4 + 4)
206  const chipX = labelX + labelW + 8
207  const chipW = severity.length * 6.4 + 14
208  const cmdX = chipX + chipW + 10
209  const css = `.fuse{transform-box:fill-box;transform-origin:left center;animation:wdfuse ${Math.round(leftMs)}ms linear forwards}@keyframes wdfuse{to{transform:scaleX(0)}}
210.glow{animation:wdglow 1.2s ease-in-out infinite}@keyframes wdglow{50%{opacity:.25}}`
211  const body = `<rect class="p" x="0" y="0" width="${W}" height="40" rx="10"/>
212<rect x="0" y="0" width="4" height="40" rx="2" fill="${c}"/>
213<circle cx="24" cy="18" r="13" fill="${c}" opacity=".18" class="glow"/>
214<path d="M24 7l9 3.5v6.2c0 6-3.9 10.3-9 12.3-5.1-2-9-6.3-9-12.3v-6.2z" fill="${c}"/>
215${verdict === 'blocked' ? '<path d="M20.5 14.5l7 7M27.5 14.5l-7 7" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/>' : '<path d="M24 12.5v6.5" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/><circle cx="24" cy="23" r="1.4" fill="#fff"/>'}
216${svgText(44, 22, head, { size: 12.5, weight: 700, fill: c })}
217${svgText(labelX, 22, fitText(label, 12.5, labelW), { size: 12.5, weight: 700, mono: true })}
218<rect x="${chipX}" y="10" width="${chipW}" height="17" rx="8.5" fill="${sc}" opacity=".2"/>
219${svgText(chipX + chipW / 2, 22, severity, { size: 10, weight: 700, anchor: 'middle', fill: sc })}
220${cmdX + 40 < W ? svgText(cmdX, 22, fitText(command, 11, W - cmdX - 12), { cls: 'm', size: 11, mono: true }) : ''}
221<rect class="k" x="44" y="32" width="${W - 56}" height="3" rx="1.5"/>
222<rect class="fuse" x="44" y="32" width="${W - 56}" height="3" rx="1.5" fill="${c}"/>`
223  return svg(W, 40, body, css)
224}
225
226
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/rules.ts 563 lines
1// KOZMOS warden: the risk classifier. Pure: no `$`, no engine calls, so the
2// hook, its `.catch` handler and the tests can all run it on a command alone.
3//
4// Severity levels:
5//   critical  wipes a disk, a home folder, a system path or a whole database:
6//             rm -rf / ~ C:\ /usr, mkfs, format C:, dd to a device, DROP DATABASE
7//   high      loses work or history, opens the machine, or runs code from the
8//             network: git push --force, reset --hard, clean -fd, checkout -- .,
9//             rm -rf . or *, DROP TABLE, TRUNCATE, chmod -R 777, curl | sh,
10//             shutdown, del /s, your own extra patterns
11//   medium    recoverable with some effort (the reflog keeps it):
12//             git branch -D, git stash clear / drop
13//
14// Not flagged on purpose (look-alikes): rm -rf node_modules / dist / ./build/*
15// and any other named path inside the project, rm -rf /tmp/x, deeper absolute
16// paths, git push --force-with-lease, git reset --soft, git clean -n,
17// git checkout -- one-file, git branch -d, chmod 777 one-file, and SQL words
18// inside grep / echo / git commit messages.
19
20export type Severity = 'critical' | 'high' | 'medium'
21
22export type Hit = {
23  /** A stable id of the rule (`rm`, `git-push-force`, ...). */
24  rule: string
25  /** What the band and the log say: `git push --force`, `rm -rf ~`. */
26  label: string
27  severity: Severity
28  /** One plain sentence: why this is risky. */
29  why: string
30}
31
32export const SEVERITY_RANK: Record<Severity, number> = { critical: 3, high: 2, medium: 1 }
33
34export type Segment = {
35  /** The words with quotes taken off. */
36  words: string[]
37  /** The segment as typed. */
38  raw: string
39  /** True when a `|` feeds this segment. */
40  pipedFrom: boolean
41  /** True when this segment's output is piped on. */
42  pipesTo: boolean
43}
44
45// ---------------------------------------------------------------------------
46// A small shell-ish splitter: good enough for bash, PowerShell and cmd lines.
47
48/** A word that reads as a Windows path so far (`C:`, `D:\Data`). */
49const WIN_PATH = /^[A-Za-z]:|\\/
50
51/**
52 * Splits a command line into segments. By default `\"` and `\ ` are escapes,
53 * as bash reads them. With `isWinPaths`, a backslash after a Windows path is
54 * the path's own, as PowerShell and cmd read `C:\ -Recurse` and `"C:\"`. The
55 * rules judge both readings and flag the union, so neither reading can hide
56 * what the other one runs.
57 */
58export function splitSegments(cmd: string, isWinPaths = false): Segment[] {
59  const segs: Segment[] = []
60  let words: string[] = []
61  let word = ''
62  let hasWord = false
63  let raw = ''
64  let quote: '' | "'" | '"' = ''
65  let pipedFrom = false
66  let varBrace = 0
67
68  const endWord = (): void => {
69    if (hasWord) words.push(word)
70    word = ''
71    hasWord = false
72  }
73  const endSeg = (pipe: boolean): void => {
74    endWord()
75    if (words.length) segs.push({ words, raw: raw.trim(), pipedFrom, pipesTo: pipe })
76    words = []
77    raw = ''
78    pipedFrom = pipe
79  }
80
81  for (let i = 0; i < cmd.length; i++) {
82    const c = cmd.charAt(i)
83    const n = cmd.charAt(i + 1)
84    if (quote) {
85      raw += c
86      if (c === quote) {
87        quote = ''
88        continue
89      }
90      if (quote === '"' && c === '\\' && (n === '\\' || n === '$' || n === '`' || (n === '"' && !(isWinPaths && WIN_PATH.test(word))))) {
91        word += n
92        raw += n
93        i++
94        continue
95      }
96      word += c
97      continue
98    }
99    if (c === "'" || c === '"') {
100      quote = c
101      hasWord = true
102      raw += c
103      continue
104    }
105    if (c === '\\' && (n === '"' || n === "'" || (n === ' ' && !(isWinPaths && WIN_PATH.test(word))) || n === '\n')) {
106      if (n !== '\n') {
107        word += n
108        hasWord = true
109      }
110      raw += c + n
111      i++
112      continue
113    }
114    if (c === '`') {
115      // PowerShell line continuation, else a bash command substitution edge.
116      if (n === '\n' || n === '\r') {
117        i++
118        if (cmd[i + 1] === '\n') i++
119        raw += ' '
120        continue
121      }
122      endSeg(false)
123      continue
124    }
125    if (c === '$' && n === '{') {
126      varBrace++
127      word += '${'
128      hasWord = true
129      raw += '${'
130      i++
131      continue
132    }
133    if (c === '}' && varBrace > 0) {
134      varBrace--
135      word += c
136      raw += c
137      continue
138    }
139    if (c === '$' && n === '(') {
140      endSeg(false)
141      i++
142      continue
143    }
144    if (c === '(' || c === ')') {
145      endSeg(false)
146      continue
147    }
148    if ((c === '{' && !hasWord && /\s/.test(n)) || (c === '}' && !hasWord)) {
149      endSeg(false)
150      continue
151    }
152    if (c === ';' || c === '\n' || c === '\r') {
153      endSeg(false)
154      continue
155    }
156    if (c === '&') {
157      const prev = cmd.charAt(i - 1)
158      if (prev === '>' || prev === '<' || n === '>') {
159        word += c
160        hasWord = true
161        raw += c
162        continue
163      }
164      if (n === '&') i++
165      endSeg(false)
166      continue
167    }
168    if (c === '|') {
169      if (n === '|') {
170        i++
171        endSeg(false)
172        continue
173      }
174      if (n === '&') i++
175      endSeg(true)
176      continue
177    }
178    if (/\s/.test(c)) {
179      endWord()
180      raw += c
181      continue
182    }
183    word += c
184    hasWord = true
185    raw += c
186  }
187  endSeg(false)
188  return segs
189}
190
191/** `C:\Tools\Git.EXE` → `git`. */
192export function baseProg(w: string): string {
193  return w.replace(/^[\s\S]*[\\/]/, '').toLowerCase().replace(/\.(exe|cmd|bat|ps1)$/, '')
194}
195
196const PREFIXES = new Set(['sudo', 'doas', 'nohup', 'time', 'env', 'command', 'exec', 'xargs', 'nice', 'ionice', 'builtin', 'then', 'do', 'else', '!', 'stdbuf', 'timeout', 'watch', 'call', 'start'])
197const SUDO_VALUED = new Set(['-u', '-g', '-h', '-p', '-C', '-U', '-r', '-t', '-D'])
198
199/** The program a segment runs and its arguments, past `VAR=x`, `sudo` and kin. */
200export function programOf(words: readonly string[]): { prog: string; args: string[] } {
201  let i = 0
202  for (let w = words[i]; w !== undefined; w = words[i]) {
203    if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
204      i++
205      continue
206    }
207    const base = baseProg(w)
208    if (PREFIXES.has(base) || w === '&' || w === '.') {
209      i++
210      for (let flag = words[i]; flag?.startsWith('-'); flag = words[i]) {
211        i++
212        if ((base === 'sudo' || base === 'doas') && SUDO_VALUED.has(flag)) i++
213        if (base === 'xargs' && /^-[IdEsnLP]$/.test(flag)) i++
214      }
215      if (base === 'timeout' && /^\d/.test(words[i] ?? '')) i++
216      continue
217    }
218    return { prog: base, args: words.slice(i + 1) }
219  }
220  return { prog: '', args: [] }
221}
222
223// ---------------------------------------------------------------------------
224// How broad a path is.
225
226export type Breadth = 'system' | 'here' | null
227
228const SYSTEM_DIRS = /^\/(bin|boot|dev|etc|lib|lib32|lib64|opt|proc|root|sbin|srv|sys|usr|var|system|library|applications|volumes|private)(\/[^/]+)?$/i
229const HOME = /^(~|\$home|\$\{home\}|%userprofile%|\$env:userprofile|\$env:home|\/home\/[^/]+|\/users\/[^/]+|[a-z]:\/users\/[^/]+)$/i
230
231/** `system` for a root, drive, home or system path; `here` for `.`, `*`, `..`; else null. */
232export function breadth(path: string): Breadth {
233  let p = path.trim().replace(/\\/g, '/').replace(/\/{2,}/g, '/')
234  if (!p) return null
235  if (p === '/' || /^\/(\*|\.)$/.test(p)) return 'system'
236  // Trailing `/`, `/*`, `/.` say the same folder (or all of it).
237  while (p.length > 1 && /(\/\*|\/\.|\/)$/.test(p)) p = p.replace(/(\/\*|\/\.|\/)$/, '')
238  if (p === '') return 'system'
239  if (/^[a-z]:$/i.test(p)) return 'system'
240  if (/^\/(mnt\/)?[a-z]$/i.test(p)) return 'system'
241  if (/^[a-z]:\/(windows|program files|program files \(x86\)|programdata)(\/.*)?$/i.test(p)) return 'system'
242  if (/^[a-z]:\/users$/i.test(p)) return 'system'
243  if (HOME.test(p)) return 'system'
244  // A top folder of a home: ~/Documents, ~/.ssh.
245  const cut = p.lastIndexOf('/')
246  if (cut >= 0 && HOME.test(p.slice(0, cut)) && !/^(node_modules|\.cache|tmp|temp)$/i.test(p.slice(cut + 1))) return 'system'
247  if (/^\/[^/]+$/.test(p) || /^[a-z]:\/[^/]+$/i.test(p)) return 'system'
248  if (SYSTEM_DIRS.test(p)) return 'system'
249  // An unset variable turns `$DIR/` into `/`.
250  if (/^\$(\{\w+\}|\w+|env:\w+)$/i.test(p) && /\/\*?$/.test(path.trim().replace(/\\/g, '/'))) return 'system'
251  if (p === '.' || p === '..' || p === '*' || p === '.*' || /^(\.\.\/)+\.\.$/.test(p) || /^\.\/\.\.?$/.test(p)) return 'here'
252  return null
253}
254
255// ---------------------------------------------------------------------------
256// The rules.
257
258const TEXT_TOOLS = new Set([
259  'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'echo', 'printf', 'cat', 'less', 'more', 'head', 'tail', 'sed', 'awk',
260  'git', 'select-string', 'sls', 'findstr', 'write-host', 'write-output', 'write-error', 'man', 'diff', 'code', 'vim',
261  'nano', 'type', 'gc', 'get-content', 'truncate', 'jq', 'tee', 'claude', 'gh',
262])
263const DB_CLIENTS = new Set(['psql', 'mysql', 'mariadb', 'sqlite3', 'sqlcmd', 'clickhouse-client', 'cockroach', 'duckdb', 'mongosh', 'pgcli', 'mycli'])
264const DOWNLOADERS = new Set(['curl', 'wget', 'iwr', 'irm', 'invoke-webrequest', 'invoke-restmethod', 'fetch', 'http', 'aria2c'])
265const RUNNERS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'iex', 'invoke-expression', 'python', 'python3', 'node', 'perl', 'ruby', 'pwsh', 'powershell', 'cmd'])
266const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh'])
267const REMOVERS_PS = new Set(['remove-item', 'ri', 'del', 'erase', 'rd', 'rmdir'])
268
269const hit = (rule: string, label: string, severity: Severity, why: string): Hit => ({ rule, label, severity, why })
270
271function shortFlags(args: readonly string[]): string {
272  return args.filter(a => /^-[A-Za-z]+$/.test(a)).map(a => a.slice(1)).join('')
273}
274
275function clipLabel(s: string): string {
276  return s.length > 48 ? s.slice(0, 47) + '…' : s
277}
278
279function rmHits(args: readonly string[]): Hit[] {
280  let isRecursive = false
281  let isForce = false
282  const targets: string[] = []
283  let isEnd = false
284  for (const a of args) {
285    if (!isEnd && a === '--') {
286      isEnd = true
287      continue
288    }
289    if (!isEnd && a.startsWith('--')) {
290      if (a === '--recursive') isRecursive = true
291      if (a === '--force') isForce = true
292      continue
293    }
294    if (!isEnd && /^-[A-Za-z]+$/.test(a)) {
295      if (/[rR]/.test(a)) isRecursive = true
296      if (/f/.test(a)) isForce = true
297      continue
298    }
299    targets.push(a)
300  }
301  if (!isRecursive) return []
302  const flag = `-r${isForce ? 'f' : ''}`
303  return rankedTargets(targets).map(([t, b]) =>
304    b === 'system'
305      ? hit('rm', clipLabel(`rm ${flag} ${t}`), 'critical', 'Deletes a root, drive, home or system folder and everything in it.')
306      : hit('rm-here', clipLabel(`rm ${flag} ${t}`), 'high', 'Deletes everything in the working folder.'),
307  )
308}
309
310function rankedTargets(targets: readonly string[]): [string, Breadth][] {
311  const out: [string, Breadth][] = []
312  for (const t of targets.flatMap(x => x.split(','))) {
313    const b = breadth(t)
314    if (b) out.push([t, b])
315  }
316  return out.sort((a, b) => (a[1] === b[1] ? 0 : a[1] === 'system' ? -1 : 1)).slice(0, 1)
317}
318
319function removeItemHits(prog: string, args: readonly string[]): Hit[] {
320  let isRecursive = false
321  let isForce = false
322  const targets: string[] = []
323  const rest = args.values()
324  for (const a of rest) {
325    if (/^\/s$/i.test(a)) {
326      isRecursive = true
327      continue
328    }
329    if (/^\/q$/i.test(a)) {
330      isForce = true
331      continue
332    }
333    if (a.startsWith('-')) {
334      const name = a.toLowerCase().replace(/:.*$/, '')
335      if (/^-r(e(c(u(r(s(e)?)?)?)?)?)?$/.test(name)) isRecursive = true
336      else if (/^-fo(r(c(e)?)?)?$/.test(name) || name === '-f') isForce = true
337      else if (/^-(path|literalpath|pspath|lp)$/.test(name)) {
338        const v = rest.next()
339        if (!v.done) targets.push(v.value)
340      } else if (/^-(include|exclude|filter|credential|stream)$/.test(name)) rest.next()
341      continue
342    }
343    targets.push(a)
344  }
345  const isCmdDel = prog === 'del' || prog === 'erase'
346  if (!isRecursive) return []
347  const shown = isCmdDel ? `${prog} /s` : `Remove-Item -Recurse${isForce ? ' -Force' : ''}`
348  const broad = rankedTargets(targets)
349  const first = broad[0]
350  if (first) {
351    return [first[1] === 'system'
352      ? hit('remove-item', clipLabel(`${shown} ${first[0]}`), 'critical', 'Deletes a root, drive, home or system folder and everything in it.')
353      : hit('remove-item-here', clipLabel(`${shown} ${first[0]}`), 'high', 'Deletes everything in the working folder.')]
354  }
355  // `del /s` deletes matching files through every subfolder, wherever it points.
356  if (isCmdDel) return [hit('del-s', clipLabel(`${shown} ${targets.join(' ')}`.trim()), 'high', 'Deletes matching files in every subfolder, with no recycle bin.')]
357  return []
358}
359
360function gitHits(args: readonly string[]): Hit[] {
361  let i = 0
362  for (let a = args[i]; a !== undefined; a = args[i]) {
363    if (a === '-C' || a === '-c' || a === '--git-dir' || a === '--work-tree' || a === '--namespace') {
364      i += 2
365      continue
366    }
367    if (a.startsWith('-')) {
368      i++
369      continue
370    }
371    break
372  }
373  const sub = (args[i] ?? '').toLowerCase()
374  const rest = args.slice(i + 1)
375  const flags = rest.filter(a => a.startsWith('-'))
376  const positional = rest.filter(a => !a.startsWith('-'))
377  const short = shortFlags(rest)
378  switch (sub) {
379    case 'push': {
380      const isForce = flags.includes('--force') || short.includes('f') || positional.some(p => p.startsWith('+'))
381      return isForce ? [hit('git-push-force', 'git push --force', 'high', 'Overwrites the remote branch: commits others pushed can vanish. --force-with-lease is the safe form.')] : []
382    }
383    case 'reset':
384      return flags.includes('--hard') ? [hit('git-reset-hard', 'git reset --hard', 'high', 'Throws away every uncommitted change in the working tree.')] : []
385    case 'clean': {
386      const isForce = flags.includes('--force') || short.includes('f')
387      const isDry = flags.includes('--dry-run') || short.includes('n')
388      if (!isForce || isDry) return []
389      const label = `git clean -f${short.includes('d') ? 'd' : ''}${short.includes('x') || short.includes('X') ? 'x' : ''}`
390      return [hit('git-clean', label, 'high', 'Deletes untracked files for good; git cannot bring them back.')]
391    }
392    case 'checkout': {
393      if (positional.some(p => p === '.' || p === ':/' || p === '*')) return [hit('git-checkout-dot', 'git checkout -- .', 'high', 'Discards every unstaged change in the tree.')]
394      if (flags.includes('--force') || /^f+$/.test(short) && short.length > 0) return [hit('git-checkout-force', 'git checkout -f', 'high', 'Switches branch and discards local changes.')]
395      return []
396    }
397    case 'restore': {
398      const isStagedOnly = flags.includes('--staged') && !flags.includes('--worktree') && !short.includes('W')
399      if (!isStagedOnly && positional.some(p => p === '.' || p === ':/' || p === '*')) return [hit('git-restore-dot', 'git restore .', 'high', 'Discards every unstaged change in the tree.')]
400      return []
401    }
402    case 'branch': {
403      const isForceDelete = flags.includes('-D') || ((flags.includes('--delete') || short.includes('d')) && (flags.includes('--force') || short.includes('f')))
404      return isForceDelete ? [hit('git-branch-D', 'git branch -D', 'medium', 'Deletes a branch even when it is not merged; only the reflog remembers it.')] : []
405    }
406    case 'stash': {
407      const what = (positional[0] ?? '').toLowerCase()
408      if (what === 'clear') return [hit('git-stash-clear', 'git stash clear', 'medium', 'Drops every stash at once.')]
409      if (what === 'drop') return [hit('git-stash-drop', 'git stash drop', 'medium', 'Drops a stash; only its dangling commit remains.')]
410      return []
411    }
412    default:
413      return []
414  }
415}
416
417function sqlHits(text: string): Hit[] {
418  const out: Hit[] = []
419  if (/\bdrop\s+(database|schema)\b/i.test(text)) out.push(hit('sql-drop-db', 'DROP DATABASE', 'critical', 'Drops a whole database.'))
420  if (/\bdrop\s+table\b/i.test(text)) out.push(hit('sql-drop-table', 'DROP TABLE', 'high', 'Drops a table and all its rows.'))
421  if (/\btruncate\s+(table\s+)?[`"[\w]/i.test(text)) out.push(hit('sql-truncate', 'TRUNCATE', 'high', 'Empties a table, with no undo.'))
422  return out
423}
424
425function segmentHits(seg: Segment, next: Segment | undefined, depth: number, extra: readonly RegExp[]): Hit[] {
426  const { prog, args } = programOf(seg.words)
427  if (!prog) return []
428  const out: Hit[] = []
429  const lower = args.map(a => a.toLowerCase())
430
431  // A shell inside the shell: judge what it runs.
432  if (depth < 3) {
433    if (SHELLS.has(prog)) {
434      const at = args.findIndex(a => /^-[a-z]*c[a-z]*$/.test(a))
435      const inner = at >= 0 ? args[at + 1] : undefined
436      if (inner) out.push(...scan(inner, extra, depth + 1))
437    }
438    if (prog === 'pwsh' || prog === 'powershell') {
439      const at = lower.findIndex(a => /^-(c|command)$/.test(a))
440      if (at >= 0) out.push(...scan(args.slice(at + 1).join(' '), extra, depth + 1))
441    }
442    if (prog === 'cmd') {
443      const at = lower.findIndex(a => a === '/c' || a === '/k')
444      if (at >= 0) out.push(...scan(args.slice(at + 1).join(' '), extra, depth + 1))
445    }
446    if (prog === 'eval' || prog === 'invoke-expression' || prog === 'iex') {
447      if (args.length && !seg.pipedFrom) out.push(...scan(args.join(' '), extra, depth + 1))
448    }
449  }
450
451  if (prog === 'rm') {
452    // In PowerShell `rm` is Remove-Item: `-r -fo` read the same as `-rf` here,
453    // and `-Path X` / `-Recurse` spelled out go through Remove-Item's reading.
454    const asRm = rmHits(args)
455    out.push(...(asRm.length ? asRm : removeItemHits('remove-item', args)))
456  }
457  if (REMOVERS_PS.has(prog)) out.push(...removeItemHits(prog, args))
458  if (prog === 'git') out.push(...gitHits(args))
459
460  if (!TEXT_TOOLS.has(prog)) out.push(...sqlHits(seg.raw))
461  else if (seg.pipesTo && next && DB_CLIENTS.has(programOf(next.words).prog)) out.push(...sqlHits(seg.raw))
462
463  if (/^mkfs(\..+)?$/.test(prog) || prog === 'mke2fs' || prog === 'wipefs') out.push(hit('mkfs', prog, 'critical', 'Formats a disk or partition: everything on it is gone.'))
464  if (prog === 'dd' && lower.some(a => /^of=\/dev\/(sd|hd|nvme|disk|rdisk|mmcblk|xvd|vd)/.test(a))) out.push(hit('dd-device', 'dd of=/dev/…', 'critical', 'Writes raw bytes over a disk.'))
465  const drive = args.find(a => /^[a-z]:/i.test(a))
466  if (prog === 'format' && drive !== undefined && args.some(a => /^[a-z]:\\?$/i.test(a))) out.push(hit('format', clipLabel(`format ${drive}`), 'critical', 'Formats a drive: everything on it is gone.'))
467  if (prog === 'format-volume' || prog === 'clear-disk' || prog === 'initialize-disk') out.push(hit('format', prog === 'clear-disk' ? 'Clear-Disk' : prog === 'initialize-disk' ? 'Initialize-Disk' : 'Format-Volume', 'critical', 'Formats or wipes a disk.'))
468  if (prog === 'diskpart') out.push(hit('diskpart', 'diskpart', 'high', 'Edits disk partitions.'))
469
470  const isShutdown = ['shutdown', 'reboot', 'halt', 'poweroff', 'stop-computer', 'restart-computer'].includes(prog)
471  const isCancel = lower.some(a => a === '-c' || a === '/a' || a === '-a' || a === '/?' || a === '--help')
472  if (isShutdown && !isCancel) out.push(hit('shutdown', prog === 'stop-computer' ? 'Stop-Computer' : prog === 'restart-computer' ? 'Restart-Computer' : prog, 'high', 'Turns the machine off or restarts it mid-session.'))
473  if (prog === 'systemctl' && lower.some(a => ['poweroff', 'reboot', 'halt', 'kexec'].includes(a))) out.push(hit('shutdown', `systemctl ${lower.find(a => ['poweroff', 'reboot', 'halt', 'kexec'].includes(a))}`, 'high', 'Turns the machine off or restarts it mid-session.'))
474  if (prog === 'init' && (lower[0] === '0' || lower[0] === '6')) out.push(hit('shutdown', `init ${lower[0]}`, 'high', 'Turns the machine off or restarts it mid-session.'))
475
476  if (prog === 'chmod') {
477    const isRecursive = lower.includes('--recursive') || /R/.test(shortFlags(args))
478    const isOpen = lower.some(a => /^(0?777|a\+rwx|ugo\+rwx|\+rwx|a=rwx|ugo=rwx)$/.test(a))
479    if (isRecursive && isOpen) {
480      const broad = rankedTargets(args.filter(a => !a.startsWith('-') && !/^(0?777|[augo]*[+=]rwx)$/i.test(a)))
481      out.push(hit('chmod-777', 'chmod -R 777', broad[0]?.[1] === 'system' ? 'critical' : 'high', 'Makes every file writable and runnable by every user.'))
482    }
483  }
484
485  if (DOWNLOADERS.has(prog) && seg.pipesTo && next && RUNNERS.has(programOf(next.words).prog)) {
486    out.push(hit('curl-sh', `${prog} | ${programOf(next.words).prog}`, 'high', 'Runs a script straight from the network, unread.'))
487  }
488  return out
489}
490
491const RAW_RULES: readonly [RegExp, Hit][] = [
492  [/\b(ba|z|da|k)?sh\s+<\(\s*(curl|wget)\b/i, hit('curl-sh', 'sh <(curl …)', 'high', 'Runs a script straight from the network, unread.')],
493  [/\b(iex|invoke-expression)\b[\s(&]+.*\b(irm|iwr|invoke-restmethod|invoke-webrequest|downloadstring)\b/i, hit('curl-sh', 'iex (irm …)', 'high', 'Runs a script straight from the network, unread.')],
494  [/\b(ba|z)?sh\s+-c\s+["']?\$\(\s*(curl|wget)\b/i, hit('curl-sh', 'sh -c "$(curl …)"', 'high', 'Runs a script straight from the network, unread.')],
495  [/:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, hit('fork-bomb', 'fork bomb', 'high', 'Spawns processes until the machine stalls.')],
496]
497
498function scan(command: string, extra: readonly RegExp[], depth: number): Hit[] {
499  const out: Hit[] = []
500  // The bash reading and the Windows-path reading: a hit in either counts.
501  for (const segs of [splitSegments(command), splitSegments(command, true)]) {
502    segs.forEach((seg, i) => out.push(...segmentHits(seg, segs[i + 1], depth, extra)))
503  }
504  if (depth === 0) {
505    for (const [re, h] of RAW_RULES) if (re.test(command)) out.push(h)
506    for (const re of extra) {
507      re.lastIndex = 0
508      if (re.test(command)) out.push(hit('custom', clipLabel(`custom /${re.source}/`), 'high', 'Matches one of your own warden patterns.'))
509    }
510  }
511  return out
512}
513
514/** Every rule a command trips, the most severe first. */
515export function classifyAll(command: string, extra: readonly RegExp[] = []): Hit[] {
516  if (!command || !command.trim()) return []
517  const seen = new Set<string>()
518  return scan(command, extra, 0)
519    .filter(h => {
520      const k = `${h.rule}|${h.label}`
521      if (seen.has(k)) return false
522      seen.add(k)
523      return true
524    })
525    .sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity])
526}
527
528/** The most severe rule a command trips, or null when it looks safe. */
529export function classify(command: string, extra: readonly RegExp[] = []): Hit | null {
530  return classifyAll(command, extra)[0] ?? null
531}
532
533/** `extraPatterns`: regexes separated by `;;`. Bad ones are skipped (and reported). */
534export function parseExtra(src: string | undefined): { patterns: RegExp[]; bad: string[] } {
535  const patterns: RegExp[] = []
536  const bad: string[] = []
537  for (const part of String(src ?? '').split(';;')) {
538    const p = part.trim()
539    if (!p) continue
540    try {
541      patterns.push(new RegExp(p, 'i'))
542    } catch {
543      bad.push(p)
544    }
545  }
546  return { patterns, bad }
547}
548
549/** The rule book, for /warden. */
550export const RULE_BOOK: readonly [Severity, string][] = [
551  ['critical', 'rm -r / Remove-Item -Recurse / rd /s on /, ~, C:\\, a home or a system folder'],
552  ['critical', 'mkfs, wipefs, dd of=/dev/…, format C:, Format-Volume, Clear-Disk'],
553  ['critical', 'DROP DATABASE / DROP SCHEMA'],
554  ['high', 'rm -rf . or * or .. (the whole working folder)'],
555  ['high', 'git push --force / -f / +ref (not --force-with-lease)'],
556  ['high', 'git reset --hard · git clean -f[d] · git checkout -- . · git restore .'],
557  ['high', 'DROP TABLE · TRUNCATE (not inside grep, echo or a commit message)'],
558  ['high', 'del /s · shutdown / reboot / Stop-Computer · chmod -R 777'],
559  ['high', 'curl | sh, wget | bash, iex (irm …), sh <(curl …) · fork bomb'],
560  ['high', 'your extraPatterns'],
561  ['medium', 'git branch -D · git stash clear / drop'],
562]
563
types/index.d.ts 44 lines
1export type WardenSeverity = 'critical' | 'high' | 'medium'
2
3export type WardenVerdict = 'blocked' | 'allowed' | 'warned'
4
5export type WardenEntry = {
6  /** When it was judged, in $.clock.now() milliseconds. */
7  at: number
8  verdict: WardenVerdict
9  /** The rule's short label: `git push --force`. */
10  label: string
11  severity: WardenSeverity
12  /** The command, clipped. */
13  command: string
14  /** Who decided: `you`, `mode deny`, `mode warn`, `no one to ask`. */
15  by: string
16  /** True when a subagent made the call. */
17  isAgent: boolean
18}
19
20export type WardenFlash = {
21  verdict: WardenVerdict
22  label: string
23  severity: WardenSeverity
24  command: string
25  /** When the band stops showing it. */
26  until: number
27}
28
29export type WardenSnap = {
30  /** This session's judgements, newest last (at most 40). */
31  log: WardenEntry[]
32  blocked: number
33  allowed: number
34  warned: number
35  /** What the band shows for 8 s after a block or a warning. */
36  flash: WardenFlash | null
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    warden: { snap: WardenSnap; isHidden: boolean }
42  }
43}
44