Holds a destructive shell command (rm -rf, force push, hard reset…), shows what it would destroy, and lets you cancel or proceed.

<img src="docs/media/banner.png" alt="tripwire: a Claude Code mod that holds a destructive command and shows what it would destroy before it runs. The pane lists 17 paths of build/ and the dialog offers Cancel or Proceed" width="100%">
A Claude Code mod that holds a destructive command and shows you what it would destroy — before it runs.
<img src="docs/media/demo.gif" alt="Claude runs rm -rf build; tripwire opens its pane listing 17 paths · 56K that would be deleted and asks Cancel or Proceed; the user cancels. Claude then force-pushes a branch; tripwire shows the 2 remote commits that would be overwritten and the user proceeds. /tripwire lists both decisions" width="880">
<sub>An animation drawn from the mod's own pane and dialog. A recording from a real session is <a href="#in-a-real-session">below</a>.</sub>
One bad rm -rf, one force push to the wrong branch, one git reset --hard — and hours of work are gone. Claude Code's permission prompt tells you what command is about to run. Tripwire tells you what it would take with it, then waits for your answer.
╭──────────────────────────────────────────────────────────╮
│ ◆ tripwire · Recursive delete │
│ rm -rf build │
│ 17 paths · 56K would be deleted │
│ build │
│ build/index.html │
│ build/chunks │
│ build/chunks/list.js.map │
│ … │
│ Answer in Claude's dialog · Cancel is the default │
╰──────────────────────────────────────────────────────────╯
tripwire · 17 paths · 56K would be deleted
rm -rf build
1: Cancel 2: Proceed
<img src="docs/media/tripwire.gif" alt="Claude runs rm -rf build; tripwire holds it, opens a pane saying 17 paths · 56K would be deleted with the paths listed, and asks Cancel or Proceed; the user picks Cancel and Claude reports the folder is still there" width="800">
Inside Claude Code:
/plugin marketplace add flaviorg/tripwire
/plugin install tripwire@tripwire
Then start a new session, or run /reload-plugins. Tripwire is also listed in my claude-mods marketplace with my other mods.
| Command | What the pane shows | |
|---|---|---|
| ● | rm -r / rm -rf / rm with a glob | How many paths and how much disk space, with the first 30 paths |
| ● | git push --force, -f, --force-with-lease | The remote commits that would be overwritten |
| ● | git reset --hard | The changed files that would be discarded |
| ● | git checkout -- ., git restore . | The changed files that would be discarded |
| ○ | git clean -f | What git clean -n would remove |
| ○ | git branch -D | The unmerged commits on that branch |
| ● | DROP TABLE / DROP DATABASE / TRUNCATE | The statement |
| ○ | chmod -R 777 | The path |
● high severity · ○ medium. Commands are checked segment by segment, so cd app && sudo rm -rf build is caught and measured inside app/.
tool.call hook sees every Bash command before it runs. Safe commands pass straight through — no delay, no prompt.find, du, git log, git status, git clean -n) with a 2-second timeout each to measure the damage.claude -p run — denies it, and Claude reads why.Tripwire is a safety net, not a permission system: keep your normal permissions on.
| Command | What it does |
|---|---|
/tripwire | Lists the rules and every command held in this session, with your decision |
Set them with /plugin configure tripwire@tripwire:
| Option | Values | Default | What it does |
|---|---|---|---|
mode | ask, deny, off | ask | deny refuses hits without asking; off disables the guard |
extraPatterns | comma-separated regexes | (empty) | Extra commands to hold, e.g. terraform destroy,kubectl delete |
allowPaths | comma-separated folder names | (empty) | Folders rm -rf may delete without a hold, e.g. dist,build,.cache |
Tripwire reads no files of yours and makes no network requests. On a hit it runs read-only find, du and git commands in your working directory. You can confirm this before installing:
claude plugin validate ./tripwire
The calls: line lists every mods API call it makes.
Tested with Claude Code 2.1.296 in the terminal and the Desktop app's Code tab. Mods need Claude Code 2.1.287 or later. In claude -p runs nobody can answer the dialog, so a hit is denied.
claude plugin validate --strict . # manifest, hooks and calls
claude plugin test . # 26 tests, no session or sign-in needed
claude --plugin-dir . # try it in a session; edits hot-reload
Inspired by Anthropic's blast-radius sample mod; written from scratch against the public mods API.
MIT © flaviorg
hooks/register.ts 131 lines1// tripwire — holds a destructive Bash command, shows what it would destroy, asks you.
2// This is the only file that touches the mods API ($).
3import { findMatch, RULES, type Match } from './rules.ts'
4import { formatPreview, previewPlan, type PlanStep, type Preview, type RunResult } from './preview.ts'
5import type { Segment } from './segments.ts'
6
7const PANE = 'tripwire'
8
9type Options = { mode: 'ask' | 'deny' | 'off'; extraPatterns: string; allowPaths: string }
10
11type Held = { command: string; segment: Segment; match: Match; preview: Preview }
12type LogEntry = { at: number; command: string; ruleId: string; decision: 'proceeded' | 'cancelled' | 'denied' }
13
14// The last held command, for the pane and /tripwire; the session's decisions.
15let last: Held | null = null
16const log: LogEntry[] = []
17
18const DENY_CANCELLED = 'The user cancelled this command because it is destructive (tripwire). Ask before trying an alternative.'
19const DENY_MODE = 'tripwire blocked this destructive command (mode: deny). Ask the user before trying an alternative.'
20
21// Runs each preview step with a short timeout; a failure becomes null, never a throw.
22async function runPlan($: any, plan: PlanStep[]): Promise<RunResult[]> {
23 const results: RunResult[] = []
24 for (const step of plan.slice(0, 2)) {
25 try {
26 const r = await $.process.run(step.argv, { timeoutMs: 2000, ...(step.cwd ? { cwd: step.cwd } : {}) })
27 results.push({ exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr })
28 } catch {
29 results.push(null)
30 }
31 }
32 return results
33}
34
35// Shows the pane, asks in the dialog, closes the pane. The wait lives inside $.ui.ask.
36async function holdAndAsk($: any, held: Held): Promise<'proceed' | 'cancel'> {
37 let placed = false
38 try {
39 const opened = await $.ui.open({ id: PANE, title: 'Tripwire', holdToasts: true })
40 placed = opened?.isPlaced === true
41 } catch {}
42 const question = [`tripwire · ${held.preview.hit}`, held.segment.text, ...(placed ? [] : held.preview.summary.slice(1))].join('\n')
43 let answer = 'Cancel'
44 try {
45 answer = await $.ui.ask(question, ['Cancel', 'Proceed'])
46 } catch {}
47 try {
48 await $.ui.close({ id: PANE })
49 } catch {}
50 return answer === 'Proceed' ? 'proceed' : 'cancel'
51}
52
53function remember(held: Held, decision: LogEntry['decision'], at: number) {
54 log.push({ at, command: held.command, ruleId: held.match.ruleId, decision })
55}
56
57export function register(on: any, options: Options) {
58 const mode = options?.mode ?? 'ask'
59 const findOptions = { extraPatterns: options?.extraPatterns ?? '', allowPaths: options?.allowPaths ?? '' }
60
61 on('session.start', async ($: any, e: any, next: any) => {
62 try {
63 await $.command.register({ name: 'tripwire', description: 'What tripwire guards, and what it held this session', immediate: true })
64 } catch {}
65 return next(e)
66 })
67
68 on('tool.call', { tool: 'Bash' }, async ($: any, e: any, next: any) => {
69 if (mode === 'off') return next(e)
70 const found = findMatch(e.command, findOptions)
71 if (!found) return next(e)
72 const held: Held = { command: e.command, segment: found.segment, match: found.match, preview: formatPreview(found.match, found.segment.text, []) }
73 if (mode === 'deny') {
74 last = held
75 remember(held, 'denied', Date.now())
76 return { deny: DENY_MODE }
77 }
78 const results = await runPlan($, previewPlan(found.match, found.segment.cwd))
79 held.preview = formatPreview(found.match, found.segment.text, results)
80 last = held
81 const decision = await holdAndAsk($, held)
82 if (decision === 'proceed') {
83 remember(held, 'proceeded', Date.now())
84 $.ui.toast('proceeding')
85 return next(e)
86 }
87 remember(held, 'cancelled', Date.now())
88 $.ui.toast('cancelled · command not run')
89 return { deny: DENY_CANCELLED }
90 }).catch(async ($: any, e: any, next: any) => {
91 if (next.called) return next(e)
92 return { deny: 'tripwire failed before deciding, so this command was not run: ' + next.error.kind }
93 })
94
95 on('ui.render', { component: 'Pane' }, async ($: any, e: any, next: any) => {
96 if (e.requestId !== PANE) return next(e)
97 const { Box, Text, Code } = $.ui.resolve(e)
98 const width = Math.max(20, (e.props?.bodyColumns ?? 80) - 2)
99 const cut = (s: string) => (s.length > width ? s.slice(0, width - 1) + '…' : s)
100 if (!last) {
101 return Box({
102 flexDirection: 'column',
103 children: [
104 Text({ bold: true, children: ['◆ tripwire'] }),
105 Text({ dimColor: true, children: ['No command held yet. Tripwire holds rm -rf, force pushes, hard resets and more.'] }),
106 ],
107 })
108 }
109 const { preview, segment } = last
110 return Box({
111 flexDirection: 'column',
112 children: [
113 Text({ bold: true, children: ['◆ tripwire · ' + preview.title] }),
114 Code({ source: segment.text, language: 'bash' }),
115 Text({ color: 'red', bold: true, children: [cut(preview.hit)] }),
116 ...preview.lines.map((line) => Text({ dimColor: true, children: [cut(' ' + line)] })),
117 Text({ children: [' '] }),
118 Text({ dimColor: true, children: ["Answer in Claude's dialog · Cancel is the default"] }),
119 ],
120 })
121 })
122
123 on('command.run', { command: 'tripwire' }, async () => {
124 const rules = RULES.map((r) => ` ${r.severity === 'high' ? '●' : '○'} ${r.label}`).join('\n')
125 const history = log.length === 0
126 ? 'No commands held yet.'
127 : log.map((l) => ` ${l.decision === 'proceeded' ? '✓' : '✗'} ${l.decision} · ${l.command}`).join('\n')
128 return { text: `Tripwire holds:\n${rules}\n\nThis session:\n${history}` }
129 })
130}
131hooks/rules.ts 176 lines1// The destructive-command rules. Pure: no mods API here.
2import { splitSegments, type Segment } from './segments.ts'
3
4export type Severity = 'high' | 'medium'
5
6export type Match = {
7 ruleId: string
8 label: string
9 severity: Severity
10 /** Paths the command targets (rm, chmod), if any. */
11 paths: string[]
12 branch?: string
13 remote?: string
14 /** The flag letters seen (e.g. "rf", "fd"). */
15 flags: string
16}
17
18type Hit = Omit<Match, 'ruleId' | 'label' | 'severity'>
19
20export type Rule = {
21 id: string
22 label: string
23 severity: Severity
24 test(segment: string): Hit | null
25}
26
27function words(segment: string): string[] {
28 return segment.split(/\s+/).filter(Boolean)
29}
30
31function flagLetters(ws: string[]): string {
32 return ws
33 .filter((w) => /^-[A-Za-z]+$/.test(w))
34 .map((w) => w.slice(1))
35 .join('')
36}
37
38function positional(ws: string[], skip: number): string[] {
39 return ws.slice(skip).filter((w) => !w.startsWith('-'))
40}
41
42export const RULES: Rule[] = [
43 {
44 id: 'rm',
45 label: 'Recursive delete',
46 severity: 'high',
47 test(segment) {
48 const ws = words(segment)
49 if (ws[0] !== 'rm') return null
50 const flags = flagLetters(ws)
51 const paths = positional(ws, 1)
52 const recursive = /r/i.test(flags) || ws.includes('--recursive')
53 const glob = paths.some((p) => p.includes('*'))
54 if (!recursive && !glob) return null
55 return { paths, flags }
56 },
57 },
58 {
59 id: 'git-force-push',
60 label: 'Force push',
61 severity: 'high',
62 test(segment) {
63 const ws = words(segment)
64 if (ws[0] !== 'git' || ws[1] !== 'push') return null
65 const forced = ws.some((w) => w === '-f' || w === '--force' || w.startsWith('--force-with-lease') || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(w))
66 if (!forced) return null
67 const [remote, branch] = positional(ws, 2)
68 return { paths: [], flags: flagLetters(ws), remote: remote ?? 'origin', branch: branch?.split(':')[0] }
69 },
70 },
71 {
72 id: 'git-reset-hard',
73 label: 'Hard reset',
74 severity: 'high',
75 test(segment) {
76 const ws = words(segment)
77 if (ws[0] !== 'git' || ws[1] !== 'reset' || !ws.includes('--hard')) return null
78 return { paths: [], flags: '' }
79 },
80 },
81 {
82 id: 'git-clean',
83 label: 'Git clean',
84 severity: 'medium',
85 test(segment) {
86 const ws = words(segment)
87 if (ws[0] !== 'git' || ws[1] !== 'clean') return null
88 const flags = flagLetters(ws)
89 if (!/f/.test(flags) && !ws.includes('--force')) return null
90 if (/n/.test(flags) || ws.includes('--dry-run')) return null
91 return { paths: [], flags }
92 },
93 },
94 {
95 id: 'git-branch-delete',
96 label: 'Force-delete branch',
97 severity: 'medium',
98 test(segment) {
99 const ws = words(segment)
100 if (ws[0] !== 'git' || ws[1] !== 'branch') return null
101 const forced = ws.includes('-D') || (ws.includes('--delete') && ws.includes('--force')) || (ws.includes('-d') && ws.includes('-f'))
102 if (!forced) return null
103 const branch = positional(ws, 2)[0]
104 return { paths: [], flags: flagLetters(ws), branch }
105 },
106 },
107 {
108 id: 'git-checkout-discard',
109 label: 'Discard working changes',
110 severity: 'high',
111 test(segment) {
112 const ws = words(segment)
113 if (ws[0] !== 'git') return null
114 if (ws[1] === 'checkout' && ws[2] === '--' && ws[3] === '.') return { paths: ['.'], flags: '' }
115 if (ws[1] === 'restore' && positional(ws, 2).includes('.')) return { paths: ['.'], flags: flagLetters(ws) }
116 return null
117 },
118 },
119 {
120 id: 'sql-drop',
121 label: 'Destructive SQL',
122 severity: 'high',
123 test(segment) {
124 return /\b(DROP\s+(TABLE|DATABASE|SCHEMA)|TRUNCATE(\s+TABLE)?)\b/i.test(segment) ? { paths: [], flags: '' } : null
125 },
126 },
127 {
128 id: 'chmod-777',
129 label: 'World-writable permissions',
130 severity: 'medium',
131 test(segment) {
132 const ws = words(segment)
133 if (ws[0] !== 'chmod' || !ws.includes('777')) return null
134 return { paths: positional(ws, 1).filter((w) => w !== '777'), flags: flagLetters(ws) }
135 },
136 },
137]
138
139/** Comma-separated regex sources → medium-severity rules. Invalid patterns are skipped. */
140export function customRules(patterns: string): Rule[] {
141 return patterns
142 .split(',')
143 .map((p) => p.trim())
144 .filter(Boolean)
145 .flatMap((source, i) => {
146 try {
147 const re = new RegExp(source, 'i')
148 return [{ id: `custom-${i + 1}`, label: source, severity: 'medium' as const, test: (s: string) => (re.test(s) ? { paths: [], flags: '' } : null) }]
149 } catch {
150 return []
151 }
152 })
153}
154
155export type FindOptions = { extraPatterns?: string; allowPaths?: string }
156
157function isAllowed(hit: Hit, allow: string[]): boolean {
158 if (allow.length === 0 || hit.paths.length === 0) return false
159 return hit.paths.every((p) => allow.includes(p.replace(/\/+$/, '').split('/').pop() ?? p))
160}
161
162/** The first destructive segment of a command, or null. */
163export function findMatch(command: string, options: FindOptions = {}): { match: Match; segment: Segment } | null {
164 const rules = [...RULES, ...customRules(options.extraPatterns ?? '')]
165 const allow = (options.allowPaths ?? '').split(',').map((s) => s.trim()).filter(Boolean)
166 for (const segment of splitSegments(command)) {
167 for (const rule of rules) {
168 const hit = rule.test(segment.text)
169 if (!hit) continue
170 if (rule.id === 'rm' && isAllowed(hit, allow)) continue
171 return { match: { ruleId: rule.id, label: rule.label, severity: rule.severity, ...hit }, segment }
172 }
173 }
174 return null
175}
176hooks/preview.ts 104 lines1// Which read-only commands measure a hit, and how their output reads. Pure.
2import type { Match } from './rules.ts'
3
4export type RunResult = { exitCode: number; stdout: string; stderr: string } | null
5
6export type PlanStep = { argv: string[]; cwd?: string }
7
8export type Preview = {
9 title: string
10 /** One sentence with the count in it. */
11 hit: string
12 /** Up to 30 dim rows. */
13 lines: string[]
14 /** `hit` plus the first rows, for the dialog when the pane can't place. */
15 summary: string[]
16}
17
18const MAX_LINES = 30
19
20/** At most two read-only commands that show what the hit would destroy. */
21export function previewPlan(match: Match, cwd?: string): PlanStep[] {
22 const at = (argv: string[]): PlanStep => (cwd ? { argv, cwd } : { argv })
23 switch (match.ruleId) {
24 case 'rm': {
25 const p = match.paths[0]
26 return p ? [at(['find', p, '-maxdepth', '3']), at(['du', '-sh', p])] : []
27 }
28 case 'git-force-push': {
29 const ref = match.branch ? `${match.remote ?? 'origin'}/${match.branch}` : '@{upstream}'
30 return [at(['git', 'log', '--oneline', `HEAD..${ref}`]), at(['git', 'log', '--oneline', `${ref}..HEAD`])]
31 }
32 case 'git-reset-hard':
33 case 'git-checkout-discard':
34 return [at(['git', 'status', '--porcelain'])]
35 case 'git-clean':
36 return [at(['git', 'clean', '-n', ...(match.flags.includes('d') ? ['-d'] : [])])]
37 case 'git-branch-delete':
38 return match.branch ? [at(['git', 'log', '--oneline', `main..${match.branch}`])] : []
39 default:
40 return []
41 }
42}
43
44function rows(result: RunResult): string[] {
45 return (result?.stdout ?? '').split('\n').map((l) => l.trimEnd()).filter(Boolean)
46}
47
48export function formatPreview(match: Match, segmentText: string, results: RunResult[]): Preview {
49 const title = match.label
50 let hit = 'nothing measurable — review the command'
51 let lines: string[] = []
52 const failed = results.length > 0 && results.some((r) => r === null || r.exitCode !== 0)
53
54 switch (match.ruleId) {
55 case 'rm': {
56 const found = rows(results[0])
57 // macOS du pads the size with a leading space (' 56K\tbuild'), so trim before splitting.
58 const size = rows(results[1])[0]?.trim().split(/\s+/)[0]
59 lines = found
60 hit = failed ? 'could not measure — review the command' : `${found.length} path${found.length === 1 ? '' : 's'}${size ? ` · ${size}` : ''} would be deleted`
61 break
62 }
63 case 'git-force-push': {
64 const lost = rows(results[0])
65 const pushed = rows(results[1])
66 lines = [...lost.map((l) => `− ${l}`), ...pushed.slice(0, 5).map((l) => `+ ${l}`)]
67 hit = failed ? 'could not measure — review the command' : lost.length === 0 ? 'no remote commits would be lost' : `${lost.length} remote commit${lost.length === 1 ? '' : 's'} would be overwritten`
68 break
69 }
70 case 'git-reset-hard':
71 case 'git-checkout-discard': {
72 const changed = rows(results[0])
73 lines = changed
74 hit = failed ? 'could not measure — review the command' : `${changed.length} changed file${changed.length === 1 ? '' : 's'} would be discarded`
75 break
76 }
77 case 'git-clean': {
78 const removed = rows(results[0]).map((l) => l.replace(/^Would remove\s+/, ''))
79 lines = removed
80 hit = failed ? 'could not measure — review the command' : `${removed.length} untracked path${removed.length === 1 ? '' : 's'} would be removed`
81 break
82 }
83 case 'git-branch-delete': {
84 const commits = rows(results[0])
85 lines = commits
86 hit = failed ? 'could not measure — review the command' : `${commits.length} unmerged commit${commits.length === 1 ? '' : 's'} on ${match.branch ?? 'the branch'} would be lost`
87 break
88 }
89 case 'sql-drop':
90 hit = 'this SQL destroys data'
91 lines = [segmentText]
92 break
93 case 'chmod-777':
94 hit = `${match.paths.join(', ') || 'the path'} would become world-writable`
95 break
96 default:
97 hit = `matches ${match.label}`
98 lines = [segmentText]
99 }
100
101 if (lines.length > MAX_LINES) lines = [...lines.slice(0, MAX_LINES), `… ${lines.length - MAX_LINES} more`]
102 return { title, hit, lines, summary: [hit, ...lines.slice(0, 4)] }
103}
104hooks/segments.ts 85 lines1// Splits a shell command into the segments the guard inspects.
2// Pure: no mods API here.
3
4export type Segment = {
5 /** The segment's text, without a leading sudo/env prefix. */
6 text: string
7 /** The directory a preceding `cd` moved into, relative to the session's cwd. */
8 cwd?: string
9}
10
11const OPERATORS = ['&&', '||', ';', '|', '\n']
12
13/** Splits on &&, ||, ;, | and newlines outside quotes. */
14function splitRaw(command: string): string[] {
15 const out: string[] = []
16 let current = ''
17 let quote: '"' | "'" | null = null
18 for (let i = 0; i < command.length; i++) {
19 const ch = command[i]
20 if (quote) {
21 current += ch
22 if (ch === quote && command[i - 1] !== '\\') quote = null
23 continue
24 }
25 if (ch === '"' || ch === "'") {
26 quote = ch
27 current += ch
28 continue
29 }
30 const op = OPERATORS.find((o) => command.startsWith(o, i))
31 if (op) {
32 out.push(current)
33 current = ''
34 i += op.length - 1
35 continue
36 }
37 current += ch
38 }
39 out.push(current)
40 return out.map((s) => s.trim()).filter(Boolean)
41}
42
43/** Drops a leading `sudo [flags]`, `env`, and `VAR=value` words. */
44function stripPrefixes(text: string): string {
45 const words = text.split(/\s+/)
46 let i = 0
47 while (i < words.length) {
48 const w = words[i]
49 if (w === 'sudo' || w === 'env') {
50 i++
51 while (i < words.length && words[i].startsWith('-') && words[i - 1] === 'sudo') i++
52 continue
53 }
54 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
55 i++
56 continue
57 }
58 break
59 }
60 return words.slice(i).join(' ')
61}
62
63function joinCwd(base: string | undefined, dir: string): string | undefined {
64 const clean = dir.replace(/^["']|["']$/g, '')
65 if (clean === '' || clean === '~' || clean.startsWith('~/')) return undefined
66 if (clean.startsWith('/')) return clean
67 return base ? `${base}/${clean}` : clean
68}
69
70export function splitSegments(command: string): Segment[] {
71 const segments: Segment[] = []
72 let cwd: string | undefined
73 for (const raw of splitRaw(command)) {
74 const text = stripPrefixes(raw)
75 const cd = /^cd(?:\s+(\S+))?\s*$/.exec(text)
76 if (cd) {
77 cwd = cd[1] === undefined ? undefined : joinCwd(cwd, cd[1])
78 continue
79 }
80 if (!text) continue
81 segments.push(cwd ? { text, cwd } : { text })
82 }
83 return segments
84}
85