Stops irreversible calls: force-push to main, reset --hard, rm -r outside the project, publishing, DROP TABLE, and file edits outside the project

Stops tool calls that cannot be undone from inside the session, before they run:
| Group | Refused |
|---|---|
| git | force-push or delete a protected branch (main, master, prod, production, release, trunk; a bare git push -f reads the current branch from .git/HEAD) · reset --hard · clean -f · checkout . · restore . · stash drop/clear · branch -D |
| rm | -r on /, ~, $HOME, ., ./*, .., the project root, or anything outside the project and temp folders |
| ship | npm/pnpm/yarn publish (not --dry-run), cargo publish, twine upload, gh release create, docker push, gem push |
| infra | terraform destroy, kubectl delete, helm uninstall |
| data | DROP TABLE/DATABASE/SCHEMA, TRUNCATE TABLE through a database client (psql, mysql, sqlite3, …); grep "DROP TABLE" is fine |
| disk | mkfs, dd of=/dev/…, a fork bomb |
| files | Write / Edit / NotebookEdit outside the project, ~/.claude and temp folders, with symbolic links resolved |
Allowed as usual: rm -rf node_modules dist, git push --force-with-lease origin feature/x, git reset --soft, git restore --staged ., writes to /tmp and plan files under ~/.claude.
Claude receives:
game-barrier blocked this call: it discards uncommitted changes (git reset --hard). This cannot be undone from here.
Find a reversible way (commit or stash first, work on a branch, stay inside the project), or explain why it is needed
and ask the user to run it themselves or to type /barrier pass.
Real run: examples/RUN-2026-10-06.md (the uncommitted change survived).
Part of the GAME MODE pack. Requires Claude Code 2.1.287+; built and tested on 2.1.291.
claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-barrier@bitgame-mods
mode (block) | block refuses · warn runs it and tells Claude and you · off |
allow (empty) | more folders where edits and rm -r are fine, comma-separated (~/notes, /srv/shared) |
/barrier | status, with the folders counted as inside |
/barrier block · warn · off | change the mode; remembered |
/barrier pass | let the next refused call run once (within 10 minutes) |
warn, off and pass are accepted only when you type them.
./deploy.sh running npm publish) is not opened.rm -r on a path built from a variable (rm -rf "$BUILD_DIR") is let through: the value is not known before the command runs.C:\, c:/, Git Bash /c/). If the project folder cannot be read, edits outside the project are not checked for that session.되돌릴 수 없는 명령(main 강제 push, reset --hard, 프로젝트 밖 rm -rf, 배포·publish, DROP TABLE, 프로젝트 밖 파일 수정)을 실행 전에 막습니다. 꼭 필요하면 직접 /barrier pass를 입력하세요.
hooks/register.ts 388 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// GAME MODE · BARRIER (결계)
4//
5// Stops tool calls that cannot be undone from inside the session:
6// git force-push or delete a protected branch (main, master, prod…),
7// reset --hard, clean -f, checkout/restore the whole tree,
8// stash drop/clear, branch -D
9// rm -r on /, ~, the project root, ./*, or anything outside the
10// project and temp folders
11// ship npm/pnpm/yarn publish, cargo publish, twine upload,
12// gh release create, docker push, gem push
13// infra terraform destroy, kubectl delete, helm uninstall
14// data DROP TABLE/DATABASE/SCHEMA, TRUNCATE TABLE
15// disk mkfs, dd of=/dev/…, a fork bomb
16// files Write/Edit outside the project, ~/.claude and temp folders
17// (symbolic links resolved)
18// On Windows, paths compare without regard to case or slash direction, and
19// Git Bash drives (/c/…) count as C:\….
20//
21// mode block (default) refuses before the call runs; warn lets it run and
22// tells Claude and you. /barrier pass lets the next refused call run once.
23
24type Mode = 'block' | 'warn' | 'off'
25
26type Finding = { why: string }
27
28type Context = {
29 cwd: string
30 home: string
31 win: boolean
32 isAllowed: (path: string) => boolean
33 branch: () => Promise<string | null>
34}
35
36const PROTECTED = /^(?:main|master|prod|production|release|trunk)$/
37const PASS_MS = 10 * 60 * 1000
38
39const WHOLE: { pattern: RegExp; why: string; unless?: RegExp }[] = [
40 { pattern: /(?:^|[\s;&|(])(?:npm|pnpm|yarn)\s+publish\b/, why: 'publishes a package (npm publish), which cannot be taken back', unless: /--dry-run\b/ },
41 { pattern: /\bcargo\s+publish\b/, why: 'publishes a crate (cargo publish), which cannot be taken back', unless: /--dry-run\b/ },
42 { pattern: /\btwine\s+upload\b/, why: 'uploads a release to PyPI (twine upload), which cannot be taken back' },
43 { pattern: /\bgh\s+release\s+create\b/, why: 'creates a public release (gh release create)' },
44 { pattern: /\bdocker\s+push\b/, why: 'pushes an image to a registry (docker push)' },
45 { pattern: /\bgem\s+push\b/, why: 'publishes a gem (gem push), which cannot be taken back' },
46 { pattern: /\bterraform\s+destroy\b|\bterraform\s+apply\b[^\n]*\s-destroy\b/, why: 'destroys infrastructure (terraform destroy)' },
47 { pattern: /\bkubectl\s+delete\b/, why: 'deletes cluster resources (kubectl delete)' },
48 { pattern: /\bhelm\s+(?:uninstall|delete)\b/, why: 'uninstalls a release from a cluster (helm uninstall)' },
49 {
50 // Only through a database client: grep "DROP TABLE" migrations/ is fine.
51 pattern: /\b(?:psql|mysql|mariadb|sqlite3|sqlcmd|duckdb|clickhouse(?:-client)?|mongosh?|cockroach\s+sql|supabase\s+db|prisma\s+db\s+execute)\b[^\n]*\b(?:drop\s+(?:table|database|schema)|truncate\s+table)\b/i,
52 why: 'drops data through a database client (DROP/TRUNCATE)',
53 },
54 { pattern: /\bmkfs(?:\.\w+)?\b|\bdd\b[^\n]*\bof=\/dev\//, why: 'overwrites a disk (mkfs / dd of=/dev/…)' },
55 { pattern: /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/, why: 'starts a fork bomb' },
56]
57
58export const register: Register = (on, options) => {
59 let mode: Mode = modeOf(options.mode)
60 let passUntil = 0
61 let roots: string[] = []
62 let projectRoot = ''
63 let cwd = ''
64 let home = ''
65 let win = false
66
67 on('session.start', async ($, e, next) => {
68 const rawRoot = await $.session.root()
69 const rawCwd = await $.session.cwd()
70 win = isWindowsPath(rawRoot) || isWindowsPath(rawCwd) || (await $.env.get('OS')) === 'Windows_NT'
71 projectRoot = normalize(rawRoot, win)
72 cwd = normalize(rawCwd, win)
73 let rawHome = (await $.env.get('HOME')) ?? ''
74 if (rawHome === '' && win) rawHome = (await $.env.get('USERPROFILE')) ?? ''
75 home = rawHome === '' ? '' : normalize(rawHome, win)
76 const temps = [(await $.env.get('TMPDIR')) ?? '']
77 if (win) temps.push((await $.env.get('TEMP')) ?? '', (await $.env.get('TMP')) ?? '')
78 const listed = [
79 projectRoot,
80 cwd,
81 home ? `${home}/.claude` : '',
82 ...temps,
83 '/tmp',
84 '/private/tmp',
85 '/var/folders',
86 '/private/var/folders',
87 ...String(options.allow ?? '')
88 .split(',')
89 .map(p => p.trim())
90 .filter(p => p.length > 0)
91 .map(p => (p.startsWith('~') ? home + p.slice(1) : p)),
92 ]
93 roots = [...new Set(listed.filter(p => p !== '' && isAbsolute(p, win)).map(p => normalize(p, win)))]
94 for (const root of [...roots]) {
95 const real = await realOf($, root, win)
96 if (real && !roots.includes(real)) roots.push(real)
97 }
98 // Without the project folder among them, every edit would count as outside the project:
99 // check nothing rather than refuse everything.
100 if (!roots.includes(projectRoot) && !roots.includes(cwd)) {
101 roots = []
102 $.ui.log('could not read the project folder, so edits outside the project are not checked this session')
103 }
104
105 const stored = await $.store.get('mode')
106 if (stored === 'block' || stored === 'warn' || stored === 'off') mode = stored
107 await $.command.register({
108 name: 'barrier',
109 description: 'GAME MODE barrier: status, block, warn, off, or pass the next refused call once',
110 argumentHint: '[block|warn|off|pass]',
111 })
112 return next(e)
113 })
114
115 const isAllowed = (path: string) =>
116 roots.length === 0 || roots.some(root => path === root || path.startsWith(root.endsWith('/') ? root : root + '/'))
117
118 on('tool.call', async ($, e, next) => {
119 if (mode === 'off') return next(e)
120 const ctx: Context = {
121 cwd: cwd || '/',
122 home,
123 win,
124 isAllowed,
125 branch: () => currentBranch($, projectRoot),
126 }
127 const finding = await inspect($, String(e.tool), e as unknown as Record<string, unknown>, ctx, projectRoot)
128 if (finding === undefined) return next(e)
129
130 const label = String(e.tool).toUpperCase()
131 if (mode === 'block') {
132 const now = await $.clock.now()
133 if (now < passUntil) {
134 passUntil = 0
135 $.ui.log(`passed once by /barrier pass — ${label} (${finding.why})`)
136 return next(e)
137 }
138 $.ui.toast(`BARRIER! ${label} 차단 — ${finding.why} · /barrier pass`)
139 $.ui.log(`BARRIER! blocked ${label} (${finding.why})`)
140 return { deny: denyText(finding) }
141 }
142
143 const ran = await next(e)
144 $.ui.toast(`BARRIER? ${label} — ${finding.why} (warn mode: ran)`)
145 if (ran.deny !== undefined || ran.isError === true) return ran
146 return { ...ran, context: [...(ran.context ?? []), `game-barrier (warn mode): this call ${finding.why}. Tell the user what was changed and how to undo it, if it can be.`] }
147 }).catch(($, e, next) =>
148 next.called ? next(e) : { deny: 'game-barrier: the guard failed while checking this call, so it was stopped. Ask the user to check /barrier.' },
149 )
150
151 on('command.run', { command: 'barrier' }, async ($, e) => {
152 const sub = e.args.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
153 const byPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
154
155 if (sub === 'block' || sub === 'warn' || sub === 'off') {
156 if (sub !== 'block' && !byPerson) return { text: 'Only you can loosen the barrier: type the command yourself.' }
157 mode = sub
158 await $.store.set('mode', mode)
159 return { text: `barrier: ${mode}` }
160 }
161 if (sub === 'pass') {
162 if (!byPerson) return { text: 'Only you can pass a refused call: type /barrier pass yourself.' }
163 passUntil = (await $.clock.now()) + PASS_MS
164 return { text: 'The next call the barrier would refuse runs once (within 10 minutes).' }
165 }
166 if (sub !== '') return { text: `Unknown option "${sub}". Use block, warn, off or pass.` }
167 return {
168 text: [
169 `barrier: ${mode}`,
170 `inside: ${roots.join(', ') || '(set at session start)'}`,
171 '/barrier block refuse irreversible calls (default)',
172 '/barrier warn let them run; tell Claude and show a toast',
173 '/barrier off stop checking',
174 '/barrier pass let the next refused call run once',
175 ].join('\n'),
176 }
177 })
178}
179
180function modeOf(value: unknown): Mode {
181 return value === 'warn' || value === 'off' ? value : 'block'
182}
183
184function str(input: Record<string, unknown>, key: string): string | undefined {
185 const value = input[key]
186 return typeof value === 'string' ? value : undefined
187}
188
189async function inspect($: EngineInterface, tool: string, input: Record<string, unknown>, ctx: Context, projectRoot: string): Promise<Finding | undefined> {
190 if (tool === 'Bash') return inspectCommand(str(input, 'command') ?? '', ctx, projectRoot)
191 if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit') {
192 const raw = str(input, 'file_path') ?? str(input, 'notebook_path')
193 if (raw === undefined) return undefined
194 const path = normalize(isAbsolute(raw, ctx.win) ? raw : `${ctx.cwd}/${raw}`, ctx.win)
195 if (!ctx.isAllowed(path)) return { why: `edits a file outside the project (${path})` }
196 const real = await realOf($, path, ctx.win)
197 if (real !== undefined && !ctx.isAllowed(real)) return { why: `edits a file outside the project through a link (${path} → ${real})` }
198 }
199 return undefined
200}
201
202export async function inspectCommand(command: string, ctx: Context, projectRoot: string): Promise<Finding | undefined> {
203 for (const rule of WHOLE) {
204 if (rule.pattern.test(command) && !(rule.unless?.test(command) ?? false)) return { why: rule.why }
205 }
206
207 for (const segment of command.split(/\|\||&&|[|;&\n]/)) {
208 const words = tokens(segment)
209 while (words[0] !== undefined && (words[0] === 'sudo' || words[0] === 'command' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) words.shift()
210 const [head, ...rest] = words
211 if (head === 'git') {
212 const found = await inspectGit(rest, ctx)
213 if (found) return found
214 }
215 if (head === 'rm' && rest.some(w => /^-[a-zA-Z]*[rR]/.test(w) || w === '--recursive')) {
216 for (const target of rest.filter(w => !w.startsWith('-'))) {
217 const found = inspectRemoval(target, ctx, projectRoot)
218 if (found) return found
219 }
220 }
221 if ((head === 'chmod' || head === 'chown') && rest.some(w => /^-[a-zA-Z]*R/.test(w))) {
222 if (rest.some(w => w === '/' || w === '~' || w === '~/' || w === '$HOME')) return { why: `changes ${head === 'chmod' ? 'permissions' : 'owners'} of everything under / or ~` }
223 }
224 }
225 return undefined
226}
227
228async function inspectGit(args: string[], ctx: Context): Promise<Finding | undefined> {
229 // Skip git's own options (-C dir, -c key=value, --no-pager) to the subcommand.
230 let i = 0
231 while (i < args.length && args[i]!.startsWith('-')) i += args[i] === '-C' || args[i] === '-c' ? 2 : 1
232 const sub = args[i]
233 const rest = args.slice(i + 1)
234 const flags = rest.filter(w => w.startsWith('-'))
235 const plain = rest.filter(w => !w.startsWith('-'))
236
237 switch (sub) {
238 case 'push': {
239 const isForce =
240 flags.some(f => f === '--force' || f.startsWith('--force-with-lease') || f === '--force-if-includes' || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(f)) ||
241 plain.some(w => w.startsWith('+'))
242 const isDelete = flags.includes('--delete') || flags.includes('-d') || plain.slice(1).some(w => w.startsWith(':'))
243 if (!isForce && !isDelete) return undefined
244 const verb = isDelete ? 'deletes' : 'force-pushes over'
245 const named = plain.slice(1).map(ref => (ref.replace(/^\+/, '').split(':').pop() ?? '').replace(/^refs\/heads\//, ''))
246 // HEAD and @ stand for the current branch.
247 const needsBranch = named.some(t => t === 'HEAD' || t === '@')
248 const current = needsBranch ? await ctx.branch() : null
249 const targets = named.map(t => (t === 'HEAD' || t === '@' ? (current ?? t) : t))
250 const hit = targets.find(t => PROTECTED.test(t))
251 if (hit) return { why: `${verb} the protected branch ${hit} (git push)` }
252 if (needsBranch && current === null) return { why: 'force-pushes HEAD, and the current branch could not be read' }
253 if (targets.length === 0 && !isDelete) {
254 const branch = await ctx.branch()
255 if (branch === null) return { why: 'force-pushes without naming a branch, and the current branch could not be read' }
256 if (PROTECTED.test(branch)) return { why: `force-pushes over the protected branch ${branch} (git push -f on ${branch})` }
257 }
258 return undefined
259 }
260 case 'reset':
261 return flags.includes('--hard') ? { why: 'discards uncommitted changes (git reset --hard)' } : undefined
262 case 'clean':
263 return flags.some(f => f === '--force' || /^-[a-zA-Z]*f/.test(f)) ? { why: 'deletes untracked files (git clean -f)' } : undefined
264 case 'checkout':
265 return plain.includes('.') ? { why: 'discards changes in the whole working tree (git checkout .)' } : undefined
266 case 'restore':
267 return plain.includes('.') && !(flags.includes('--staged') && !flags.includes('--worktree'))
268 ? { why: 'discards changes in the whole working tree (git restore .)' }
269 : undefined
270 case 'stash':
271 return plain[0] === 'drop' || plain[0] === 'clear' ? { why: `drops stashed work (git stash ${plain[0]})` } : undefined
272 case 'branch':
273 return flags.includes('-D') || (flags.includes('--delete') && flags.includes('--force'))
274 ? { why: 'force-deletes a branch that may not be merged (git branch -D)' }
275 : undefined
276 default:
277 return undefined
278 }
279}
280
281function inspectRemoval(raw: string, ctx: Context, projectRoot: string): Finding | undefined {
282 const target = raw.replace(/^['"]|['"]$/g, '')
283 if (/^(?:\/|\/\*|~|~\/|~\/\*|\$HOME|\$\{HOME\}|\$HOME\/\*?|\.|\.\/|\.\/\*|\*|\.\.|\.\.\/|\.\.\/\*)$/.test(target)) {
284 return { why: `deletes everything under ${target} (rm -r)` }
285 }
286 // Windows: a drive (C:\, /c/), the profile folder, or the current folder spelled with a backslash.
287 if (ctx.win && /^(?:[A-Za-z]:[\\/]?\*?|\/(?:cygdrive\/)?[A-Za-z]\/?\*?|~\\\*?|\.\\\*?|\.\.\\\*?|\$\{?USERPROFILE\}?[\\/]?\*?)$/.test(target)) {
288 return { why: `deletes everything under ${target} (rm -r)` }
289 }
290 if (target.includes('$') || target.includes('`')) return undefined
291 const expanded = /^~[\\/]/.test(target) ? ctx.home + target.slice(1) : target
292 const path = normalize(isAbsolute(expanded, ctx.win) ? expanded : `${ctx.cwd}/${expanded}`, ctx.win)
293 if (projectRoot !== '' && path === projectRoot) return { why: `deletes the project root (${path})` }
294 if (!ctx.isAllowed(path)) return { why: `deletes outside the project (${path})` }
295 return undefined
296}
297
298async function currentBranch($: EngineInterface, root: string): Promise<string | null> {
299 try {
300 const head = String(await $.fs.read(`${root}/.git/HEAD`)).trim()
301 const match = /^ref:\s*refs\/heads\/(.+)$/.exec(head)
302 return match?.[1] ?? null
303 } catch {
304 return null
305 }
306}
307
308/** The path with every symbolic link resolved, through its nearest existing ancestor. */
309async function realOf($: EngineInterface, path: string, win: boolean): Promise<string | undefined> {
310 try {
311 let cur = path
312 const rest: string[] = []
313 for (let step = 0; step < 64 && cur !== ''; step++) {
314 if (await $.fs.exists(cur)) {
315 const stat = await $.fs.stat(cur, { resolve: true })
316 if (stat.realPath === undefined) return undefined
317 return normalize([stat.realPath, ...rest].join('/'), win)
318 }
319 if (isRoot(cur)) return undefined
320 const cut = cur.lastIndexOf('/')
321 rest.unshift(cur.slice(cut + 1))
322 const parent = cut <= 0 ? '/' : cur.slice(0, cut)
323 // `c:` alone is the current folder on drive C, not its root.
324 cur = /^[a-z]:$/.test(parent) ? `${parent}/` : parent
325 }
326 } catch {
327 // Unreadable: judged by the spelling alone.
328 }
329 return undefined
330}
331
332function isRoot(path: string): boolean {
333 return path === '/' || /^[a-z]:\/$/.test(path) || /^\/\/[^/]+\/[^/]+$/.test(path)
334}
335
336/** A Windows spelling: a drive (`C:\`, `c:/`) or a network share (`\\server\share`). */
337export function isWindowsPath(path: string): boolean {
338 return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith('\\\\')
339}
340
341function isAbsolute(path: string, win: boolean): boolean {
342 return path.startsWith('/') || (win && isWindowsPath(path))
343}
344
345/**
346 * One comparable spelling of a path: `.`, `..` and repeated slashes collapsed. With `win` (a
347 * Windows session), backslashes become slashes, a Git Bash drive (`/c/…`) becomes `c:/…`, and
348 * the path is lower-cased, as Windows file names ignore case.
349 */
350export function normalize(path: string, win = false): string {
351 let p = path
352 let prefix = ''
353 if (win) {
354 p = p.replace(/\\/g, '/').toLowerCase()
355 const bash = /^\/(?:cygdrive\/)?([a-z])(?=\/|$)/.exec(p)
356 if (bash) p = `${bash[1]}:${p.slice(bash[0].length)}`
357 const drive = /^([a-z]):/.exec(p)
358 if (drive) {
359 prefix = `${drive[1]}:/`
360 p = p.slice(2)
361 } else if (p.startsWith('//')) {
362 prefix = '//'
363 p = p.slice(2)
364 }
365 }
366 const absolute = prefix !== '' || p.startsWith('/')
367 const out: string[] = []
368 for (const part of p.split('/')) {
369 if (part === '' || part === '.') continue
370 if (part === '..') out.pop()
371 else out.push(part)
372 }
373 if (prefix !== '') return prefix + out.join('/')
374 return (absolute ? '/' : '') + out.join('/') || (absolute ? '/' : '.')
375}
376
377function tokens(segment: string): string[] {
378 return (segment.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).map(t => t.replace(/^(['"])(.*)\1$/, '$2'))
379}
380
381function denyText(finding: Finding): string {
382 return (
383 `game-barrier blocked this call: it ${finding.why}. ` +
384 'This cannot be undone from here. Find a reversible way (commit or stash first, work on a branch, stay inside the project), ' +
385 'or explain why it is needed and ask the user to run it themselves or to type /barrier pass.'
386 )
387}
388