Dangerous Bash commands need launch codes: red alert pane, siren, a code to arm and a LAUNCH to fire

Dangerous Bash commands need launch codes: red alert pane, siren, a code to arm and a LAUNCH to fire.

Hooks tool.call on Bash and classifies the command before it runs: risky rm -rf, git push --force, git reset --hard, DROP/TRUNCATE through a live SQL client, supabase db reset, vercel --prod, chmod -R 777, and downloaded scripts piped into a shell. A match opens a red-alert pane with a siren and a one-time code. Type the code to arm, press LAUNCH to fire. Anything else is denied and Claude is told why. /launch-codes test <command> shows the verdict without running anything.
It is strict. It blocked one of our own cleanup commands while we were putting this repo together, which is the point.
In a Claude Code session (2.1.287 or later):
/plugin marketplace add OneWave-AI/claude-code-mods
/plugin install launch-codes@claude-code-mods
Or load this folder for one session: claude --plugin-dir ./launch-codes
| Mod | Network | Runs processes | Files | Calls a model | Sends data anywhere |
|---|---|---|---|---|---|
| launch-codes | No | No | No | No | No |
Run claude plugin validate ./launch-codes to list every event it hooks and every call it makes. See the repository README for the full security notes.
Part of claude-code-mods by OneWave AI. MIT licensed.
hooks/register.tsx 215 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Alert, Phase } from '../types'
5import { classify, isCode, makeCode } from './classify'
6import { hazardCells, launchWav, sirenWav } from './fx'
7
8const PANE = 'launch-codes'
9const CODE_TTL_MS = 30_000
10const HAZARD_ROWS = 2
11
12const alert = atom({ plugin: 'launch-codes', key: 'alert' } as const, null as Alert | null)
13
14const RED = '#e5484d'
15const GOLD = '#c2a87e'
16const PAPER = '#E8E2D6'
17const INK = '#0a0606'
18
19const SIREN = sirenWav()
20const LAUNCH = launchWav()
21
22let hazardTimer: { cancel: () => void } | null = null
23let hazardSize = 0
24
25const startHazard = ($: EngineInterface, columns: number) => {
26 if (hazardTimer && hazardSize === columns) return
27 hazardTimer?.cancel()
28 hazardSize = columns
29 const timer = $.clock.every(70, () => {
30 void Promise.all([$.clock.now(), read($, alert)]).then(([now, a]) =>
31 Promise.all(
32 ['top', 'bottom'].map(key =>
33 $.ui.blit({
34 requestId: PANE,
35 key,
36 cells: hazardCells(columns, HAZARD_ROWS, now / 1000, a?.phase === 'armed' || a?.phase === 'launched'),
37 }),
38 ),
39 ).then(results => {
40 if (results.some(r => r.deny) && hazardTimer === timer) {
41 timer.cancel()
42 hazardTimer = null
43 }
44 }),
45 )
46 })
47 hazardTimer = timer
48}
49
50const random = (n: number) => {
51 const box = new Uint32Array(1)
52 crypto.getRandomValues(box)
53 return box[0]! % n
54}
55
56const setPhase = ($: EngineInterface, phase: Phase) =>
57 update($, alert, a => (a ? { ...a, phase } : a))
58
59const short = (command: string) => (command.length > 70 ? `${command.slice(0, 67)}...` : command)
60
61/**
62 * Runs the whole ceremony for one command and says whether it may fire.
63 * Every wait is a `$` call (the ask dialog), so none of it spends the hook's budget.
64 */
65const ceremony = async ($: EngineInterface, command: string, reason: string, isDemo: boolean) => {
66 const now = await $.clock.now()
67 const code = makeCode(random)
68 await update($, alert, () => ({
69 command,
70 reason,
71 code,
72 startedAt: now,
73 expiresAt: now + CODE_TTL_MS,
74 phase: 'code' as Phase,
75 isDemo,
76 }))
77 await $.ui.open({ id: PANE, title: 'RED ALERT', rows: 16 })
78 const siren = new AbortController()
79 void $.audio.play({ base64: SIREN, mime: 'audio/wav' }, { shouldLoop: true, signal: siren.signal, gain: 0.6 }).catch(() => undefined)
80 // Repaint once a second for the countdown.
81 const tick = $.clock.every(1000, () => $.ui.invalidate('ui.render'))
82
83 const finish = async (phase: Phase, verdict: { isLaunch: boolean; why: string }) => {
84 siren.abort()
85 tick.cancel()
86 await setPhase($, phase)
87 if (phase === 'launched') void $.audio.play({ base64: LAUNCH, mime: 'audio/wav' }).catch(() => undefined)
88 $.clock.after(phase === 'launched' ? 1800 : 2500, () => {
89 void $.ui.close({ id: PANE }).then(() => update($, alert, () => null))
90 })
91 return verdict
92 }
93
94 try {
95 const typed = await $.ui.ask(
96 `Launch codes required: ${short(command)}. Type the 4-character code from the RED ALERT pane under "Other" to arm it.`,
97 { header: 'LAUNCH CODE', options: ['ABORT', 'ABORT and find a safer way'] },
98 )
99 if (!isCode(typed, code)) {
100 const why = typed.startsWith('ABORT') ? 'the user pressed ABORT' : 'the launch code was wrong'
101 return finish('aborted', { isLaunch: false, why })
102 }
103 if ((await $.clock.now()) > now + CODE_TTL_MS) {
104 return finish('aborted', { isLaunch: false, why: 'the launch code expired before it was entered' })
105 }
106 siren.abort()
107 await setPhase($, 'armed')
108 const fire = await $.ui.ask(`ARMED. Fire ${short(command)}?`, {
109 header: 'ARMED',
110 options: ['LAUNCH', 'ABORT'],
111 })
112 if (fire !== 'LAUNCH') return finish('aborted', { isLaunch: false, why: 'the user pressed ABORT after arming' })
113 return finish('launched', { isLaunch: true, why: 'launched' })
114 } catch {
115 return finish('aborted', { isLaunch: false, why: 'no one was there to enter the launch code' })
116 }
117}
118
119const deny = (command: string, reason: string, why: string) => ({
120 deny:
121 `launch-codes: blocked \`${short(command)}\` (${reason}) because ${why}. ` +
122 'Do not retry it or work around the check; tell the user what you wanted to do and ask, or find a safer way.',
123})
124
125export const register: Register = on => {
126 on('session.start', async ($, e, next) => {
127 await $.command.register({
128 name: 'launch-codes',
129 description: 'Launch codes for dangerous commands: /launch-codes [demo|test <command>]',
130 })
131 return next(e)
132 })
133
134 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
135 const danger = classify(e.command)
136 if (!danger) return next(e)
137 const verdict = await ceremony($, e.command, danger.reason, false)
138 return verdict.isLaunch ? next(e) : deny(e.command, danger.reason, verdict.why)
139 })
140 // A hook that fails must not let the command through.
141 .catch(($, e) => {
142 const danger = classify(e.command)
143 return danger ? deny(e.command, danger.reason, 'the launch check failed') : { deny: 'launch-codes: the launch check failed; ask the user before retrying.' }
144 })
145
146 on('command.run', { command: 'launch-codes' }, async ($, e) => {
147 const arg = e.args.trim()
148 if (arg.startsWith('test ')) {
149 const danger = classify(arg.slice(5))
150 return { text: danger ? `Needs launch codes: ${danger.reason}.` : 'Safe: runs without launch codes.' }
151 }
152 const command = 'rm -rf ~/Projects/prod-backup && git push --force origin main'
153 const verdict = await ceremony($, command, 'rm -rf on ~/Projects/prod-backup', true)
154 return { text: verdict.isLaunch ? 'Demo: LAUNCHED. (Nothing ran, it was a demo.)' : `Demo: aborted, ${verdict.why}. Nothing ran.` }
155 })
156
157 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
158 const elements = $.ui.resolve(e)
159 const { Box, Text } = elements
160 const Raster = 'Raster' in elements ? elements.Raster : null
161 const a = await read($, alert)
162 const now = await $.clock.now()
163 const width = Math.max(30, (e.props.bodyColumns || e.viewport?.columns || 80) - 2)
164 if (!a) {
165 return (
166 <Box paddingX={1}>
167 <Text dimColor>All quiet. Dangerous commands will raise the alarm here.</Text>
168 </Box>
169 )
170 }
171
172 const isTerminal = e.surface === 'terminal' && Raster !== null
173 if (isTerminal) startHazard($, width)
174 const left = Math.max(0, Math.ceil((a.expiresAt - now) / 1000))
175 const isFlash = Math.floor(now / 400) % 2 === 0
176 const banner: Record<Phase, { text: string; color: string }> = {
177 code: { text: 'LAUNCH CODES REQUIRED', color: RED },
178 armed: { text: 'ARMED - CONFIRM LAUNCH', color: GOLD },
179 launched: { text: 'LAUNCHED', color: GOLD },
180 aborted: { text: 'ABORTED - NOTHING RAN', color: PAPER },
181 }
182 const head = banner[a.phase]
183 const hazard = (key: string) =>
184 isTerminal && Raster ? (
185 <Raster key={key} columns={width} rows={HAZARD_ROWS} cells={hazardCells(width, HAZARD_ROWS, now / 1000, a.phase !== 'code' && a.phase !== 'aborted')} />
186 ) : (
187 <Text color={RED}>{'/'.repeat(width)}</Text>
188 )
189
190 return (
191 <Box flexDirection="column" paddingX={1}>
192 {hazard('top')}
193 <Box justifyContent="space-between" marginTop={1}>
194 <Text bold backgroundColor={head.color} color={INK}>{` ${head.text} `}</Text>
195 <Text dimColor>{a.isDemo ? 'DEMO ' : ''}{a.phase === 'code' ? `code expires in ${left}s` : ''}</Text>
196 </Box>
197 <Box marginTop={1} flexDirection="column">
198 <Text dimColor>COMMAND</Text>
199 <Text bold color={PAPER} wrap="wrap">{a.command}</Text>
200 <Text color={RED}>{a.reason}</Text>
201 </Box>
202 {a.phase === 'code' && (
203 <Box marginTop={1} gap={2}>
204 <Text dimColor>LAUNCH CODE</Text>
205 <Text bold color={isFlash ? GOLD : PAPER}>{a.code.split('').join(' ')}</Text>
206 </Box>
207 )}
208 {a.phase === 'code' && <Text dimColor>Type it under "Other" in the dialog below, then confirm LAUNCH. ABORT blocks it.</Text>}
209 {a.phase === 'armed' && <Text color={GOLD} bold>Code accepted. Choose LAUNCH to fire or ABORT to stand down.</Text>}
210 <Box marginTop={1}>{hazard('bottom')}</Box>
211 </Box>
212 )
213 })
214}
215hooks/classify.ts 210 lines1/** Which shell commands need launch codes. Pure: a command string in, a reason (or null) out. */
2
3export type Danger = { kind: string; reason: string }
4
5type Token = { op: string } | { word: string }
6
7/** Splits a command line into words and operators, honoring quotes and backslashes. */
8export const tokenize = (line: string): Token[] => {
9 const out: Token[] = []
10 let word = ''
11 let hasWord = false
12 const flush = () => {
13 if (hasWord) out.push({ word })
14 word = ''
15 hasWord = false
16 }
17 for (let i = 0; i < line.length; i++) {
18 const c = line[i]!
19 if (c === "'") {
20 const end = line.indexOf("'", i + 1)
21 word += line.slice(i + 1, end === -1 ? line.length : end)
22 hasWord = true
23 i = end === -1 ? line.length : end
24 } else if (c === '"') {
25 let j = i + 1
26 while (j < line.length && line[j] !== '"') {
27 if (line[j] === '\\' && j + 1 < line.length) j++
28 word += line[j]
29 j++
30 }
31 hasWord = true
32 i = j
33 } else if (c === '\\' && i + 1 < line.length) {
34 word += line[i + 1]
35 hasWord = true
36 i++
37 } else if (c === ' ' || c === '\t') {
38 flush()
39 } else if (c === '\n' || c === ';') {
40 flush()
41 out.push({ op: ';' })
42 } else if (c === '&' || c === '|') {
43 flush()
44 const pair = line[i + 1] === c
45 out.push({ op: pair ? c + c : c })
46 if (pair) i++
47 } else if (c === '(' || c === ')') {
48 flush()
49 out.push({ op: c })
50 } else {
51 word += c
52 hasWord = true
53 }
54 }
55 flush()
56 return out
57}
58
59/** Simple commands, each with the pipe position it sits in. */
60type Simple = { words: string[]; pipedFrom: string | null }
61
62const WRAPPERS = new Set(['sudo', 'time', 'command', 'exec', 'nohup', 'env', 'npx', 'bunx', 'xargs', 'doas'])
63
64/** The program a command really runs, past sudo, env assignments and npx. */
65const program = (words: string[]) => {
66 let i = 0
67 while (i < words.length) {
68 const w = words[i]!
69 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w) || WRAPPERS.has(w) || (w.startsWith('-') && i > 0)) {
70 i++
71 continue
72 }
73 if ((w === 'pnpm' || w === 'yarn' || w === 'bun') && words[i + 1] === 'dlx') {
74 i += 2
75 continue
76 }
77 break
78 }
79 return { name: (words[i] ?? '').split('/').pop() ?? '', args: words.slice(i + 1) }
80}
81
82const simples = (tokens: Token[]): Simple[] => {
83 const out: Simple[] = []
84 let words: string[] = []
85 let pipedFrom: string | null = null
86 const end = (op: string) => {
87 if (words.length) {
88 const name = program(words).name
89 out.push({ words, pipedFrom })
90 pipedFrom = op === '|' ? name : null
91 } else if (op !== '|') {
92 pipedFrom = null
93 }
94 words = []
95 }
96 for (const t of tokens) {
97 if ('op' in t) end(t.op)
98 else words.push(t.word)
99 }
100 end(';')
101 return out
102}
103
104const SAFE_RM = /(^|\/)(node_modules|\.next|dist|tmp|\.turbo|\.cache|coverage|__pycache__)(\/|$)/
105const SAFE_RM_ROOT = /^(\/tmp\/|\/private\/tmp\/|\$TMPDIR|\/var\/folders\/)/
106
107const isSafeRmTarget = (path: string) => SAFE_RM.test(path) || SAFE_RM_ROOT.test(path)
108
109const rmDanger = (args: string[]): Danger | null => {
110 let recursive = false
111 let force = false
112 const targets: string[] = []
113 let isEndOfFlags = false
114 for (const a of args) {
115 if (!isEndOfFlags && a === '--') {
116 isEndOfFlags = true
117 } else if (!isEndOfFlags && a.startsWith('--')) {
118 if (a === '--recursive') recursive = true
119 if (a === '--force') force = true
120 } else if (!isEndOfFlags && a.startsWith('-') && a.length > 1) {
121 if (/[rR]/.test(a)) recursive = true
122 if (/f/.test(a)) force = true
123 } else {
124 targets.push(a)
125 }
126 }
127 if (!recursive || !force || targets.length === 0) return null
128 const risky = targets.filter(t => !isSafeRmTarget(t))
129 if (risky.length === 0) return null
130 return { kind: 'rm', reason: `rm -rf on ${risky.slice(0, 3).join(' ')}` }
131}
132
133const SQL_CLIENTS = new Set(['psql', 'mysql', 'mariadb', 'sqlite3', 'sqlcmd', 'duckdb', 'clickhouse-client', 'pgcli', 'mycli'])
134const SQL_DROP = /\b(drop\s+(table|database|schema)|truncate\s+(table\s+)?["`\w])/i
135const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'fish'])
136const FETCHERS = new Set(['curl', 'wget'])
137
138/** The first dangerous thing `command` does, or null when it is safe to run unasked. */
139export const classify = (command: string): Danger | null => {
140 const tokens = tokenize(command)
141 const list = simples(tokens)
142 for (const s of list) {
143 const { name, args } = program(s.words)
144 const joined = args.join(' ')
145
146 if (name === 'rm') {
147 const d = rmDanger(args)
148 if (d) return d
149 }
150
151 if (name === 'git') {
152 // git's own options come before the subcommand; -C and -c take a value.
153 let at = 0
154 while (at < args.length && args[at]!.startsWith('-')) at += ['-C', '-c', '--git-dir', '--work-tree'].includes(args[at]!) ? 2 : 1
155 const sub = args[at]
156 if (sub === 'push') {
157 const rest = args.slice(at + 1)
158 const isForce = rest.some(a => a === '--force' || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(a) || /^\+\S/.test(a))
159 if (isForce) return { kind: 'git-push-force', reason: 'git push --force rewrites remote history' }
160 }
161 if (sub === 'reset' && args.includes('--hard')) {
162 return { kind: 'git-reset-hard', reason: 'git reset --hard throws away uncommitted work' }
163 }
164 }
165
166 // A heredoc's body lands in commands of its own, so then the whole line counts.
167 const sql = args.some(a => a.startsWith('<<')) ? command : joined
168 if (SQL_CLIENTS.has(name) && SQL_DROP.test(sql)) {
169 return { kind: 'sql-drop', reason: 'DROP / TRUNCATE through a live SQL client' }
170 }
171
172 if (name === 'supabase') {
173 if (args[0] === 'db' && args[1] === 'reset') {
174 return { kind: 'supabase-reset', reason: 'supabase db reset wipes the database' }
175 }
176 if (SQL_DROP.test(joined)) return { kind: 'sql-drop', reason: 'DROP / TRUNCATE through supabase' }
177 }
178
179 if (name === 'vercel' && args.some(a => a === '--prod' || a === '--production')) {
180 return { kind: 'vercel-prod', reason: 'vercel --prod ships to production' }
181 }
182
183 if (name === 'chmod' && args.some(a => /^-[a-zA-Z]*R/.test(a) || a === '--recursive') && args.some(a => /^0?777$/.test(a) || a === 'a+rwx')) {
184 return { kind: 'chmod-777', reason: 'chmod -R 777 opens every file to everyone' }
185 }
186
187 if (SHELLS.has(name) && s.pipedFrom && FETCHERS.has(s.pipedFrom)) {
188 return { kind: 'curl-sh', reason: `${s.pipedFrom} piped straight into ${name}` }
189 }
190 if (SHELLS.has(name) && args.some(a => /\$\(\s*(curl|wget)\b/.test(a))) {
191 return { kind: 'curl-sh', reason: `${name} running a downloaded script` }
192 }
193 }
194 // bash <(curl ...): the tokenizer splits the parens off, so look at the raw line.
195 if (/\b(sh|bash|zsh)\s+<\(\s*(curl|wget)\b/.test(command)) {
196 return { kind: 'curl-sh', reason: 'shell running a downloaded script' }
197 }
198 return null
199}
200
201const CODE_ALPHABET = 'ACDEFHJKMNPRTVWXY3479'
202
203/** A 4-character launch code from unambiguous letters and digits. */
204export const makeCode = (random: (n: number) => number) =>
205 Array.from({ length: 4 }, () => CODE_ALPHABET[random(CODE_ALPHABET.length)]!).join('')
206
207/** Whether what the person typed matches the code: case and spaces don't matter. */
208export const isCode = (typed: string, code: string) =>
209 typed.replace(/[\s-]/g, '').toUpperCase() === code
210hooks/fx.ts 102 lines1/** The red alert's sound and pixels: a synthesized siren WAV and hazard-stripe Raster frames. */
2
3const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
4
5export const toBase64 = (bytes: Uint8Array) => {
6 let out = ''
7 for (let i = 0; i < bytes.length; i += 3) {
8 const a = bytes[i] ?? 0
9 const b = bytes[i + 1] ?? 0
10 const c = bytes[i + 2] ?? 0
11 const n = (a << 16) | (b << 8) | c
12 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
13 out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
14 out += i + 2 < bytes.length ? B64[n & 63]! : '='
15 }
16 return out
17}
18
19const RATE = 11025
20
21/** A mono 8-bit WAV around `samples` (each -1..1). */
22export const wav = (samples: Float32Array) => {
23 const bytes = new Uint8Array(44 + samples.length)
24 const view = new DataView(bytes.buffer)
25 const text = (at: number, s: string) => [...s].forEach((ch, i) => view.setUint8(at + i, ch.charCodeAt(0)))
26 text(0, 'RIFF')
27 view.setUint32(4, 36 + samples.length, true)
28 text(8, 'WAVE')
29 text(12, 'fmt ')
30 view.setUint32(16, 16, true)
31 view.setUint16(20, 1, true)
32 view.setUint16(22, 1, true)
33 view.setUint32(24, RATE, true)
34 view.setUint32(28, RATE, true)
35 view.setUint16(32, 1, true)
36 view.setUint16(34, 8, true)
37 text(36, 'data')
38 view.setUint32(40, samples.length, true)
39 for (let i = 0; i < samples.length; i++) {
40 bytes[44 + i] = Math.max(0, Math.min(255, Math.round(128 + samples[i]! * 110)))
41 }
42 return bytes
43}
44
45/** One wail of a siren: a sweep up and back down, 1.4 s, square-ish so it cuts through. */
46export const sirenWav = () => {
47 const seconds = 1.4
48 const n = Math.round(RATE * seconds)
49 const out = new Float32Array(n)
50 let phase = 0
51 for (let i = 0; i < n; i++) {
52 const t = i / n
53 const sweep = t < 0.5 ? t * 2 : (1 - t) * 2
54 const freq = 620 + 760 * sweep
55 phase += (2 * Math.PI * freq) / RATE
56 const tone = Math.tanh(Math.sin(phase) * 3) * 0.7 + Math.sin(phase * 2) * 0.15
57 out[i] = tone * 0.8
58 }
59 return toBase64(wav(out))
60}
61
62/** A short rising confirm chirp: three steps, for LAUNCH. */
63export const launchWav = () => {
64 const steps = [523, 784, 1046]
65 const each = Math.round(RATE * 0.12)
66 const out = new Float32Array(each * steps.length)
67 steps.forEach((freq, s) => {
68 for (let i = 0; i < each; i++) {
69 const env = Math.min(1, i / 60) * Math.min(1, (each - i) / 200)
70 out[s * each + i] = Math.sin((2 * Math.PI * freq * i) / RATE) * 0.7 * env
71 }
72 })
73 return toBase64(wav(out))
74}
75
76const DEFAULT = 0x01000000
77const RED = 0xe5484d
78const DEEP = 0x5a0f12
79const BLACK = 0x0a0606
80const GOLD = 0xc2a87e
81
82/**
83 * A hazard band `columns` x `rows`: diagonal red/black stripes marching at
84 * time `t` (seconds), the whole band flashing between bright and deep red.
85 */
86export const hazardCells = (columns: number, rows: number, t: number, isArmed = false) => {
87 const words = new Uint32Array(columns * rows * 3)
88 const flash = Math.floor(t * 2.5) % 2 === 0
89 const hot = isArmed ? GOLD : flash ? RED : DEEP
90 const shift = Math.floor(t * 10)
91 for (let r = 0; r < rows; r++) {
92 for (let x = 0; x < columns; x++) {
93 const i = (r * columns + x) * 3
94 const stripe = Math.floor((x + r + shift) / 3) % 2 === 0
95 words[i] = 0x2588
96 words[i + 1] = stripe ? hot : BLACK
97 words[i + 2] = DEFAULT
98 }
99 }
100 return toBase64(new Uint8Array(words.buffer))
101}
102types/index.d.ts 18 lines1export type Phase = 'code' | 'armed' | 'launched' | 'aborted'
2
3export type Alert = {
4 command: string
5 reason: string
6 code: string
7 startedAt: number
8 expiresAt: number
9 phase: Phase
10 isDemo: boolean
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'launch-codes': { alert: Alert | null }
16 }
17}
18