Holds destructive commands until you say so: rm -rf, force pushes, git reset --hard, DROP TABLE, curl | sh, sudo, chmod -R 777, edits to .env. One question in…

Holds destructive commands until you say so.
⏺ Bash(git push --force origin main)
tripwire · force-push: rewrites a protected branch for everyone
git push --force origin main
Run this command?
❯ 1. Run it
2. Refuse
The question opens in Claude's own dialog and the tool call waits for it. Nothing runs until you answer; the time you take does not count against the hook's budget, because the wait happens inside $.ui.ask.
| What trips it | Rule | Then | ||
|---|---|---|---|---|
rm -r of /, ~, .., a glob, a system path, or anything outside the working directory; rm -r inside it (see askForRm) | rm | asks | ||
git push --force / -f (never --force-with-lease) | force-push | asks; names the branch when it is protected | ||
git push --delete <protected>, git branch -D <protected> | branch-delete | refuses, never asks | ||
git reset --hard, git checkout -- ., git restore ., git clean -f, git stash drop, git filter-branch, git reflog expire, git gc --prune=now | reset-hard, discard, clean, stash, rewrite, reflog, gc | asks | ||
DROP TABLE, DROP DATABASE, TRUNCATE, a bare DELETE FROM t | sql | asks | ||
| `curl … \ | sh, wget … \ | bash` | pipe-to-shell | asks |
sudo … (see allowSudo) | sudo | asks | ||
chmod -R/chown -R on /, ~ or to 777 | permissions | asks | ||
kill -9 -1, killall, pkill -f | kill | asks | ||
docker system prune, volume rm, terraform destroy, npm publish, gh repo delete, rsync --delete, find -delete, crontab -r, history -c | various | asks | ||
mkfs, fdisk, dd of=/dev/…, > /dev/sd… | disk | refuses | ||
Write or Edit to .env, .env.local, … | env-file | asks |
When Claude is refused it reads a deny message that tells it not to retry or work around the command, and to ask you instead.
Fail-closed. If the guard itself throws or times out, the .catch handler answers with a refusal, so a broken guard never lets a command through. In claude -p, where nobody can answer, every held command is refused.
/tripwire prints the session's decisions: what tripped, which rule, and whether it ran, was refused, or was denied.
| Option | Default | What it does |
|---|---|---|
protectedBranches | main,master,production,release | Force pushes to these are questioned by name; deleting one is refused. |
askForRm | true | Also hold a recursive rm of a path inside the working directory. |
allowSudo | false | Let sudo through without asking. |
claude plugin marketplace add ryx2/slopshopper
claude plugin install tripwire@slopshopper
Tested with Claude Code 2.1.289. claude plugin validate and claude plugin test pass. The command matching is text matching: an obfuscated command (r''m -rf) passes. Protect what matters on the server side too.
hooks/register.ts 171 lines1// tripwire: holds destructive commands until you say so.
2//
3// tool.call (Bash): classifies the command. A rule that can never be undone
4// (writing a block device, mkfs, deleting a protected branch) is refused.
5// Anything else that trips holds the call in $.ui.ask with "Run it" and
6// "Refuse". A hook failure answers with a refusal, never a run.
7// tool.call (Write, Edit): holds edits to .env files the same way.
8// /tripwire lists this session's decisions.
9
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import type { Decision } from '../types'
14
15const MAX = 100
16const decisions = atom({ plugin: 'tripwire', key: 'decisions' } as const, [])
17
18type Trip = { rule: string; why: string; deny?: true }
19
20export const register: Register = (on, options) => {
21 const protectedBranches = String(options.protectedBranches ?? 'main,master')
22 .split(',')
23 .map(s => s.trim())
24 .filter(Boolean)
25 const askForRm = options.askForRm !== false
26 const allowSudo = options.allowSudo === true
27
28 on('session.start', async ($, e, next) => {
29 try {
30 await $.command.register({ name: 'tripwire', description: 'The destructive commands tripwire held this session, and what you decided' })
31 } catch {
32 // the name is taken; the guard still runs
33 }
34 return next(e)
35 })
36
37 on('command.run', { command: 'tripwire' }, async $ => {
38 const list = await read($, decisions)
39 if (list.length === 0) return { text: 'tripwire: nothing tripped this session.' }
40 return { text: list.map(d => `${d.outcome.padEnd(7)} ${d.rule.padEnd(14)} ${d.what} (${d.by})`).join('\n') }
41 })
42
43 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
44 const command = String(e.command ?? '')
45 const trip = classify(command, { protectedBranches, askForRm, allowSudo })
46 if (trip === null) return next(e)
47 const what = command.replace(/\s+/g, ' ').trim().slice(0, 120)
48 if (trip.deny) {
49 await record($, { what, rule: trip.rule, outcome: 'denied', by: 'rule', at: await $.clock.now() })
50 $.ui.toast(`tripwire refused: ${trip.rule}`)
51 return { deny: `tripwire refused this command (${trip.rule}): ${trip.why}. It cannot be undone, so tripwire never asks. Do not retry it or look for another way to do the same thing; tell the user what you wanted to do.` }
52 }
53 const answer = await ask($, `tripwire · ${trip.rule}: ${trip.why}\n\n${what}\n\nRun this command?`)
54 if (answer === 'Run it') {
55 await record($, { what, rule: trip.rule, outcome: 'ran', by: 'person', at: await $.clock.now() })
56 return next(e)
57 }
58 await record($, { what, rule: trip.rule, outcome: 'refused', by: answer === null ? 'no-one-to-ask' : 'person', at: await $.clock.now() })
59 return { deny: `tripwire held this command (${trip.rule}) and ${answer === null ? 'nobody was there to approve it' : 'the user refused it'}. Do not retry it or work around it; ask the user before trying anything with the same effect.` }
60 }).catch(async ($, e) => ({ deny: `tripwire's guard failed (${$.plugin.name}: ${String(e.command ?? '').slice(0, 60)}), so the command was not run. Ask the user before retrying.` }))
61
62 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
63 const file = String(e.file_path ?? '')
64 if (!/(^|\/)\.env(\.[\w.-]+)?$/.test(file)) return next(e)
65 const what = `${e.tool} ${file}`
66 const answer = await ask($, `tripwire · env-file: ${e.tool} to ${file} changes secrets or configuration outside version control.\n\nLet it through?`)
67 if (answer === 'Run it') {
68 await record($, { what, rule: 'env-file', outcome: 'ran', by: 'person', at: await $.clock.now() })
69 return next(e)
70 }
71 await record($, { what, rule: 'env-file', outcome: 'refused', by: answer === null ? 'no-one-to-ask' : 'person', at: await $.clock.now() })
72 return { deny: `tripwire held this ${e.tool} to ${file} and ${answer === null ? 'nobody was there to approve it' : 'the user refused it'}. Tell the user what you wanted to change there instead.` }
73 }).catch(async ($, e) => ({ deny: `tripwire's guard failed on ${e.tool} ${String(e.file_path ?? '')}, so the edit was not made. Ask the user before retrying.` }))
74}
75
76/** Puts the question to the person; null when nobody can answer. */
77async function ask($: EngineInterface, question: string): Promise<string | null> {
78 try {
79 return await $.ui.ask(question, ['Run it', 'Refuse'])
80 } catch {
81 return null
82 }
83}
84
85async function record($: EngineInterface, d: Decision): Promise<void> {
86 await update($, decisions, list => [...list, d].slice(-MAX))
87}
88
89const SHELL_SPLIT = /\s*(?:&&|\|\||;|\|(?!\|)|\n)\s*/
90
91/** The first rule a command trips, or null. */
92export function classify(command: string, o: { protectedBranches: string[]; askForRm: boolean; allowSudo: boolean }): Trip | null {
93 const segments = command.split(SHELL_SPLIT).map(s => s.trim()).filter(Boolean)
94 // pipe to shell is about the whole line
95 if (/\b(curl|wget|fetch)\b[^|]*\|\s*(sudo\s+)?(ba|z|da|k)?sh\b/.test(command)) return { rule: 'pipe-to-shell', why: 'runs whatever a download contains' }
96 for (const raw of segments) {
97 const seg = raw.replace(/^(?:env\s+)?(?:[A-Z_][A-Z0-9_]*=\S+\s+)*/, '')
98 const words = seg.split(/\s+/)
99 const first = words[0] ?? ''
100 if (/^(mkfs(\.\w+)?|fdisk|parted|wipefs)$/.test(first) || /\bdd\s+.*\bof=\/dev\//.test(seg) || />\s*\/dev\/(sd|nvme|disk|hd|mmcblk)/.test(seg)) return { rule: 'disk', why: 'writes a block device or a filesystem', deny: true }
101 if (first === 'sudo' && !o.allowSudo) return { rule: 'sudo', why: 'runs with root privileges' }
102 if (/^(rm|trash)$/.test(first) || /^sudo$/.test(first)) {
103 const body = first === 'sudo' ? seg.replace(/^sudo\s+/, '') : seg
104 if (/^rm\b/.test(body)) {
105 const r = rmTrip(body, o.askForRm)
106 if (r) return r
107 }
108 }
109 if (first === 'git') {
110 const g = gitTrip(words.slice(1), o.protectedBranches)
111 if (g) return g
112 }
113 if (/^(chmod|chown|chgrp)$/.test(first) && /\s-[a-zA-Z]*R/.test(seg) && /\s(\/|~|\$HOME|777)\b/.test(seg)) return { rule: 'permissions', why: 'changes permissions or ownership recursively on a broad path' }
114 if (/\b(DROP\s+(TABLE|DATABASE|SCHEMA|INDEX)|TRUNCATE\s+TABLE|DELETE\s+FROM\s+\w+\s*;?\s*$)/i.test(seg)) return { rule: 'sql', why: 'destroys data in a database' }
115 if (/^(kill|pkill|killall)$/.test(first) && (/\s-9\s+-1\b/.test(seg) || /^killall\s+-?\w*$/.test(seg) || /pkill\s+-f\s+\S+/.test(seg))) return { rule: 'kill', why: 'kills processes broadly' }
116 if (/^(docker|podman)$/.test(first) && /\b(system prune|volume (rm|prune)|rm\s+-f|rmi\s+-f)\b/.test(seg)) return { rule: 'containers', why: 'removes containers, images or volumes' }
117 if (/^(terraform|pulumi|cdk)$/.test(first) && /\b(destroy)\b/.test(seg)) return { rule: 'infra', why: 'destroys infrastructure' }
118 if (/^(npm|pnpm|yarn|bun)$/.test(first) && /\bpublish\b/.test(seg)) return { rule: 'publish', why: 'publishes a package' }
119 if (/^(gh)$/.test(first) && /\b(repo delete|release delete|secret delete)\b/.test(seg)) return { rule: 'github', why: 'deletes something on GitHub' }
120 if (/^(rsync)$/.test(first) && /\s--delete\b/.test(seg)) return { rule: 'rsync-delete', why: 'deletes files at the destination' }
121 if (/^(find)$/.test(first) && /\s-(delete|exec\s+rm)\b/.test(seg)) return { rule: 'find-delete', why: 'deletes every file it matches' }
122 if (/^(crontab)$/.test(first) && /\s-r\b/.test(seg)) return { rule: 'crontab', why: 'removes every cron job' }
123 if (/^(history)$/.test(first) && /\s-c\b/.test(seg)) return { rule: 'history', why: 'clears the shell history' }
124 }
125 return null
126}
127
128function rmTrip(seg: string, askInside: boolean): Trip | null {
129 const words = seg.split(/\s+/).slice(1)
130 const flags = words.filter(w => w.startsWith('-')).join(' ')
131 const recursive = /r|R|-recursive/.test(flags)
132 const targets = words.filter(w => !w.startsWith('-'))
133 if (targets.length === 0) return null
134 for (const t of targets) {
135 const bare = t.replace(/['"]/g, '')
136 if (/^(\/|~|\$HOME|\*|\.|\.\.|\/\*|~\/\*|\$HOME\/\*|\.\/\*)$/.test(bare) || /^\/(usr|etc|var|bin|lib|opt|home|Users|System|Library)(\/|$)/.test(bare)) return { rule: 'rm', why: `removes ${bare}, which reaches far outside this project` }
137 if (bare.includes('*') && recursive) return { rule: 'rm', why: `removes everything matching ${bare}` }
138 if (bare.startsWith('/') || bare.startsWith('~') || bare.startsWith('$HOME') || bare.startsWith('..')) return { rule: 'rm', why: `removes ${bare}, outside the working directory` }
139 }
140 if (recursive && askInside) return { rule: 'rm', why: `removes ${targets.join(', ')} recursively` }
141 return null
142}
143
144function gitTrip(args: string[], protectedBranches: string[]): Trip | null {
145 const sub = args.find(a => !a.startsWith('-')) ?? ''
146 const line = args.join(' ')
147 const protectedRe = new RegExp(`(^|[\\s:/])(${protectedBranches.map(b => b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})(\\s|$)`)
148 if (sub === 'push') {
149 const forced = /(^|\s)(--force|-f)(\s|$)/.test(line) && !/--force-with-lease/.test(line)
150 const deletes = /(^|\s)(--delete|-d)(\s|$)/.test(line) || /\s:\S+/.test(line)
151 if (deletes && protectedRe.test(line)) return { rule: 'branch-delete', why: 'deletes a protected branch on the remote', deny: true }
152 if (forced && protectedRe.test(line)) return { rule: 'force-push', why: 'rewrites a protected branch for everyone' }
153 if (forced) return { rule: 'force-push', why: 'rewrites history on the remote' }
154 if (deletes) return { rule: 'branch-delete', why: 'deletes a branch on the remote' }
155 return null
156 }
157 if (sub === 'reset' && /(^|\s)--hard(\s|$)/.test(line)) return { rule: 'reset-hard', why: 'throws away uncommitted work' }
158 if ((sub === 'checkout' || sub === 'restore') && /(\s--\s+\.|\s\.$|\s--\s*\*|\s\*$)/.test(line) && !/(^|\s)-b(\s|$)/.test(line)) return { rule: 'discard', why: 'discards every uncommitted change' }
159 if (sub === 'clean' && /(^|\s)-[a-zA-Z]*f/.test(line)) return { rule: 'clean', why: 'deletes untracked files' }
160 if (sub === 'branch' && /(^|\s)-D(\s|$)/.test(line)) {
161 if (protectedRe.test(line)) return { rule: 'branch-delete', why: 'deletes a protected branch', deny: true }
162 return { rule: 'branch-delete', why: 'deletes a branch and its unmerged commits' }
163 }
164 if (sub === 'stash' && /\b(drop|clear)\b/.test(line)) return { rule: 'stash', why: 'drops stashed work' }
165 if (sub === 'rebase' && !/(--abort|--continue|--skip|--quit)/.test(line) && protectedRe.test(line) && /\s-i\b|--interactive/.test(line)) return { rule: 'rebase', why: 'rewrites a protected branch' }
166 if (sub === 'filter-branch' || sub === 'filter-repo') return { rule: 'rewrite', why: 'rewrites the whole history' }
167 if (sub === 'reflog' && /\bexpire\b/.test(line)) return { rule: 'reflog', why: 'drops the safety net for recovering commits' }
168 if (sub === 'gc' && /--prune=now/.test(line)) return { rule: 'gc', why: 'drops unreachable commits now' }
169 return null
170}
171tests/register.test.ts 72 lines1import { describe, expect, mock, test } from 'claude-code/testing'
2
3const answering = (label: string) => ($: unknown, e: { tool: string; questions?: { question: string }[] }) =>
4 e.tool === 'AskUserQuestion' ? { result: { answers: { [e.questions![0]!.question]: label } } } : { result: 'ok' }
5
6describe('register', () => {
7 test('a force push is held, and a refusal denies the command', async ($, on) => {
8 mock.clock(on)
9 on('tool.call', answering('Refuse'))
10 on('ui.toast', () => ({ value: undefined }))
11 const out = await $.tool.call({ tool: 'Bash', command: 'git push --force origin main' })
12 expect(out.deny).toContain('force-push')
13 expect(out.deny).toContain('refused')
14 const log = await $.command.run({ command: 'tripwire', args: '' })
15 expect(log.text).toContain('refused')
16 expect(log.text).toContain('force-push')
17 })
18
19 test('"Run it" lets the command through', async ($, on) => {
20 mock.clock(on)
21 on('tool.call', answering('Run it'))
22 const out = await $.tool.call({ tool: 'Bash', command: 'rm -rf build' })
23 expect(out.deny).toBeUndefined()
24 expect(out.result).toBe('ok')
25 })
26
27 test('ordinary commands never ask', async ($, on) => {
28 let asked = 0
29 on('tool.call', ($, e) => {
30 if (e.tool === 'AskUserQuestion') asked++
31 return { result: 'ok' }
32 })
33 for (const command of ['git status', 'git push origin feat/x', 'git push --force-with-lease origin feat/x', 'rm build/out.js', 'ls -la', 'bun test', 'rm -f /tmp/x.lock', 'cat .env'])
34 expect((await $.tool.call({ tool: 'Bash', command })).deny).toBeUndefined()
35 expect(asked).toBe(0)
36 })
37
38 test('deleting a protected branch is refused without asking, and so is writing a disk', async ($, on) => {
39 mock.clock(on)
40 let asked = 0
41 on('tool.call', ($, e) => {
42 if (e.tool === 'AskUserQuestion') asked++
43 return { result: 'ok' }
44 })
45 on('ui.toast', () => ({ value: undefined }))
46 const branch = await $.tool.call({ tool: 'Bash', command: 'git push origin --delete main' })
47 expect(branch.deny).toContain('branch-delete')
48 const disk = await $.tool.call({ tool: 'Bash', command: 'dd if=/dev/zero of=/dev/sda bs=1M' })
49 expect(disk.deny).toContain('disk')
50 expect(asked).toBe(0)
51 })
52
53 test('with nobody to ask, a held command is refused', async ($, on) => {
54 mock.clock(on)
55 on('tool.call', ($, e) => {
56 if (e.tool === 'AskUserQuestion') throw new Error('no one to ask')
57 return { result: 'ok' }
58 })
59 const out = await $.tool.call({ tool: 'Bash', command: 'git reset --hard HEAD~3' })
60 expect(out.deny).toContain('nobody was there')
61 })
62
63 test('an edit to .env is held too', async ($, on) => {
64 mock.clock(on)
65 on('tool.call', answering('Refuse'))
66 const out = await $.tool.call({ tool: 'Edit', file_path: '/work/app/.env', old_string: 'A=1', new_string: 'A=2' })
67 expect(out.deny).toContain('.env')
68 const ok = await $.tool.call({ tool: 'Edit', file_path: '/work/app/src/env.ts', old_string: 'A=1', new_string: 'A=2' })
69 expect(ok.deny).toBeUndefined()
70 })
71})
72types/index.d.ts 20 lines1export type Decision = {
2 /** The command or file the call concerned, trimmed. */
3 what: string
4 /** Why it tripped: force-push, rm, reset, drop-table, pipe-to-shell, sudo, env-file, ... */
5 rule: string
6 /** What happened: ran, refused, or denied (never askable). */
7 outcome: 'ran' | 'refused' | 'denied'
8 /** Who decided: the person, the rule, or the guard failing closed. */
9 by: 'person' | 'rule' | 'fail-closed' | 'no-one-to-ask'
10 at: number
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 tripwire: {
16 decisions: Decision[]
17 }
18 }
19}
20README.md 55 lines1# tripwire
2
3Holds destructive commands until you say so.
4
5```text
6 ⏺ Bash(git push --force origin main)
7 tripwire · force-push: rewrites a protected branch for everyone
8
9 git push --force origin main
10
11 Run this command?
12 ❯ 1. Run it
13 2. Refuse
14```
15
16The question opens in Claude's own dialog and the tool call waits for it. Nothing runs until you answer; the time you take does not count against the hook's budget, because the wait happens inside `$.ui.ask`.
17
18| What trips it | Rule | Then |
19| --- | --- | --- |
20| `rm -r` of `/`, `~`, `..`, a glob, a system path, or anything outside the working directory; `rm -r` inside it (see `askForRm`) | `rm` | asks |
21| `git push --force` / `-f` (never `--force-with-lease`) | `force-push` | asks; names the branch when it is protected |
22| `git push --delete <protected>`, `git branch -D <protected>` | `branch-delete` | **refuses**, never asks |
23| `git reset --hard`, `git checkout -- .`, `git restore .`, `git clean -f`, `git stash drop`, `git filter-branch`, `git reflog expire`, `git gc --prune=now` | `reset-hard`, `discard`, `clean`, `stash`, `rewrite`, `reflog`, `gc` | asks |
24| `DROP TABLE`, `DROP DATABASE`, `TRUNCATE`, a bare `DELETE FROM t` | `sql` | asks |
25| `curl … \| sh`, `wget … \| bash` | `pipe-to-shell` | asks |
26| `sudo …` (see `allowSudo`) | `sudo` | asks |
27| `chmod -R`/`chown -R` on `/`, `~` or to `777` | `permissions` | asks |
28| `kill -9 -1`, `killall`, `pkill -f` | `kill` | asks |
29| `docker system prune`, `volume rm`, `terraform destroy`, `npm publish`, `gh repo delete`, `rsync --delete`, `find -delete`, `crontab -r`, `history -c` | various | asks |
30| `mkfs`, `fdisk`, `dd of=/dev/…`, `> /dev/sd…` | `disk` | **refuses** |
31| `Write` or `Edit` to `.env`, `.env.local`, … | `env-file` | asks |
32
33When Claude is refused it reads a deny message that tells it not to retry or work around the command, and to ask you instead.
34
35**Fail-closed.** If the guard itself throws or times out, the `.catch` handler answers with a refusal, so a broken guard never lets a command through. In `claude -p`, where nobody can answer, every held command is refused.
36
37`/tripwire` prints the session's decisions: what tripped, which rule, and whether it ran, was refused, or was denied.
38
39## Options
40
41| Option | Default | What it does |
42| --- | --- | --- |
43| `protectedBranches` | `main,master,production,release` | Force pushes to these are questioned by name; deleting one is refused. |
44| `askForRm` | `true` | Also hold a recursive `rm` of a path inside the working directory. |
45| `allowSudo` | `false` | Let `sudo` through without asking. |
46
47## Install
48
49```bash
50claude plugin marketplace add ryx2/slopshopper
51claude plugin install tripwire@slopshopper
52```
53
54Tested with Claude Code 2.1.289. `claude plugin validate` and `claude plugin test` pass. The command matching is text matching: an obfuscated command (`r''m -rf`) passes. Protect what matters on the server side too.
55