SLOPSHOPPER

GAME MODE · Barrier

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

newguardcommandtoast
★ 2v0.2.0MITupdated 2026-10-06Reasonofmoon/bitgame-mods/plugins/game-barrier
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · game-barrier
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ game-barrier │ ● game-barrier: BARRIER! blocked BASH (force-pushes over the protected │ BARRIER! BASH 차단 — force-pushes over the │ ⏺ Read(src/auth.ts) │ protected branch main (git push) · │ ⎿ Read 6 lines │ /barrier pass │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by game-barrier: game-barrier blocked this call: it force-pushes over the protected branch main (gi ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /barrier ⎿ game-barrier: barrier: block ⎿ game-barrier: inside: /work/app, /Users/dev/.claude, /tmp, /private/tmp, /var/folders, /private/var/folders ⎿ game-barrier: /barrier block refuse irreversible calls (default) ⎿ game-barrier: /barrier warn let them run; tell Claude and show a toast ⎿ game-barrier: /barrier off stop checking ⎿ game-barrier: /barrier pass let the next refused call run once ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

GAME MODE · Barrier (결계)

Stops tool calls that cannot be undone from inside the session, before they run:

GroupRefused
gitforce-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
shipnpm/pnpm/yarn publish (not --dry-run), cargo publish, twine upload, gh release create, docker push, gem push
infraterraform destroy, kubectl delete, helm uninstall
dataDROP TABLE/DATABASE/SCHEMA, TRUNCATE TABLE through a database client (psql, mysql, sqlite3, …); grep "DROP TABLE" is fine
diskmkfs, dd of=/dev/…, a fork bomb
filesWrite / 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.

Install

claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-barrier@bitgame-mods

Settings and commands

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)
/barrierstatus, with the folders counted as inside
/barrier block · warn · offchange the mode; remembered
/barrier passlet the next refused call run once (within 10 minutes)

warn, off and pass are accepted only when you type them.

Known limits

  • It reads the command text. A script that does the same thing (./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.
  • On Windows, paths are compared without regard to case or slash direction (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를 입력하세요.

Source 1 files
hooks/register.ts 388 lines
1import 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