SLOPSHOPPER

GAME MODE · Trap Guard

Stops tool calls that would leak a secret: literal keys in commands or source files, cat .env / echo $API_KEY, reading key files into the conversation

newguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · game-trap-guard
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ game-trap-guard │ ● game-trap-guard: TRAP! blocked BASH (prints a .env file into the conv│ TRAP! BASH 차단 — prints a .env file into │ ⏺ Read(src/auth.ts) │ the conversation (.env) · /trap-guard pass │ ⎿ Read 6 lines ╰────────────────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(cat .env) ⎿ Denied by game-trap-guard: game-trap-guard blocked this call: it prints a .env file into the conversation ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /trap-guard ⎿ game-trap-guard: trap guard: block ⎿ game-trap-guard: /trap-guard block refuse calls that leak secrets (default) ⎿ game-trap-guard: /trap-guard warn let them run; tell Claude and show a toast ⎿ game-trap-guard: /trap-guard off stop checking ⎿ game-trap-guard: /trap-guard pass let the next refused call run once ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

GAME MODE · Trap Guard

Stops a tool call that would put a secret where it does not belong, before it runs:

CallRefused when
Basha literal key or token is in the command (curl -H "x-api-key: sk-ant-…")
Bashit prints a secret file or variable: cat .env, grep KEY .env, echo $API_KEY, printenv GITHUB_TOKEN, a bare printenv / env
Read · Grepthe target is a secret file: .env* (not .env.example/.sample/.template), id_rsa/id_ed25519, *.pem/*.p12, ~/.aws/credentials, .netrc, .pypirc, ~/.docker/config.json
Write · Edit · NotebookEdita literal key is written into a file that is not an .env file (an .env file is the right place for one)
WebFetcha key is in the URL

Keys recognised: Anthropic, OpenAI, AWS access keys, GitHub tokens, Slack tokens, Google API keys, Stripe live keys, private key blocks, JSON web tokens. Messages never repeat the secret: (sk-ant…, 53자).

Claude receives:

game-trap-guard blocked this call: it prints a .env file into the conversation (.env). Keep secrets out of commands,
files and the conversation: read them from an environment variable (for example $ANTHROPIC_API_KEY) or a secret store,
refer to them by name, and keep literal values only in an .env file. If this is a false positive, ask the user to type
/trap-guard pass and then retry.

You get a toast (TRAP! BASH 차단 — …) and a dim transcript line. Real run: examples/RUN-2026-10-06.md.

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-trap-guard@bitgame-mods

Modes and commands

Command
/trap-guardstatus
/trap-guard blockrefuse (default; setting mode)
/trap-guard warnlet it run; Claude gets a note, you get a toast
/trap-guard offstop checking
/trap-guard passlet the next refused call run once (within 10 minutes)

warn, off and pass are accepted only when you type them, so Claude cannot lift the guard by running the command itself. The mode is remembered across sessions.

Known limits

  • Pattern-based: an unusual key format passes, and a test fixture that looks like a real key is refused (use pass, or build the fake value at run time).
  • grep -r KEY . over a folder that holds an .env is not caught; only explicit secret-file targets are.

한국어

비밀키가 명령어·소스 파일·대화에 들어가려는 순간 실행 전에 막습니다(cat .env, echo $API_KEY, 키를 코드에 직접 쓰기 등). .env 파일에 키를 쓰는 것은 허용합니다. 오탐이면 직접 /trap-guard pass를 입력하세요.

Source 1 files
hooks/register.ts 239 lines
1import type { Register } from 'claude-code'
2
3// GAME MODE · TRAP GUARD
4//
5// Stops a tool call that would put a secret where it does not belong:
6//   - a literal key or token in a command, a URL, or a source file
7//     (an .env-type file is the right place for one and is left alone)
8//   - a command that prints a secret file or secret variables into the
9//     conversation (cat .env, grep KEY .env, echo $API_KEY, a bare printenv)
10//   - Read or Grep on a secret file (.env, id_rsa, *.pem, ~/.aws/credentials)
11//
12// mode block (default): the call is refused before it runs and Claude is
13//   told why and what to do instead.
14// mode warn: the call runs; Claude gets a note and you get a toast.
15// /trap-guard pass lets the next refused call through once (typed by you).
16
17type Mode = 'block' | 'warn' | 'off'
18
19type Finding = { why: string }
20
21const LITERALS: { kind: string; pattern: RegExp }[] = [
22  { kind: 'Anthropic API key', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
23  { kind: 'OpenAI API key', pattern: /\bsk-(?!ant-)(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}/ },
24  { kind: 'AWS access key', pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
25  { kind: 'GitHub token', pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})/ },
26  { kind: 'Slack token', pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}/ },
27  { kind: 'Google API key', pattern: /\bAIza[0-9A-Za-z_-]{35}/ },
28  { kind: 'Stripe live key', pattern: /\b(?:sk|rk)_live_[0-9A-Za-z]{20,}/ },
29  { kind: 'private key', pattern: /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/ },
30  { kind: 'JSON web token', pattern: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
31]
32
33const READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'nl', 'strings', 'xxd', 'od', 'base64', 'grep', 'egrep', 'rg', 'ag', 'awk', 'sed', 'jq', 'type', 'Get-Content'])
34
35const SECRET_VAR = /\$\{?[A-Za-z_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL)[A-Za-z_]*\}?/i
36
37const PASS_MS = 10 * 60 * 1000
38
39export const register: Register = (on, options) => {
40  let mode: Mode = modeOf(options.mode)
41  let passUntil = 0
42
43  on('session.start', async ($, e, next) => {
44    const stored = await $.store.get('mode')
45    if (stored === 'block' || stored === 'warn' || stored === 'off') mode = stored
46    await $.command.register({
47      name: 'trap-guard',
48      description: 'GAME MODE trap guard: status, block, warn, off, or pass the next refused call once',
49      argumentHint: '[block|warn|off|pass]',
50    })
51    return next(e)
52  })
53
54  on('tool.call', async ($, e, next) => {
55    if (mode === 'off') return next(e)
56    const finding = inspect(String(e.tool), e as unknown as Record<string, unknown>)
57    if (finding === undefined) return next(e)
58
59    const label = String(e.tool).toUpperCase()
60    if (mode === 'block') {
61      const now = await $.clock.now()
62      if (now < passUntil) {
63        passUntil = 0
64        $.ui.log(`passed once by /trap-guard pass — ${label} (${finding.why})`)
65        return next(e)
66      }
67      $.ui.toast(`TRAP! ${label} 차단 — ${finding.why} · /trap-guard pass`)
68      $.ui.log(`TRAP! blocked ${label} (${finding.why})`)
69      return { deny: denyText(finding) }
70    }
71
72    const ran = await next(e)
73    $.ui.toast(`TRAP? ${label} — ${finding.why} (warn mode: ran)`)
74    if (ran.deny !== undefined || ran.isError === true) return ran
75    return { ...ran, context: [...(ran.context ?? []), warnText(finding)] }
76  }).catch(($, e, next) =>
77    next.called ? next(e) : { deny: 'game-trap-guard: the guard failed while checking this call, so it was stopped. Ask the user to check /trap-guard.' },
78  )
79
80  on('command.run', { command: 'trap-guard' }, async ($, e) => {
81    const sub = e.args.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
82    const byPerson = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
83
84    if (sub === 'block' || sub === 'warn' || sub === 'off') {
85      if (sub !== 'block' && !byPerson) return { text: 'Only you can loosen the trap guard: type the command yourself.' }
86      mode = sub
87      await $.store.set('mode', mode)
88      return { text: `trap guard: ${mode}` }
89    }
90    if (sub === 'pass') {
91      if (!byPerson) return { text: 'Only you can pass a refused call: type /trap-guard pass yourself.' }
92      passUntil = (await $.clock.now()) + PASS_MS
93      return { text: 'The next call the trap guard would refuse runs once (within 10 minutes).' }
94    }
95    if (sub !== '') return { text: `Unknown option "${sub}". Use block, warn, off or pass.` }
96    return {
97      text: [
98        `trap guard: ${mode}`,
99        '/trap-guard block   refuse calls that leak secrets (default)',
100        '/trap-guard warn    let them run; tell Claude and show a toast',
101        '/trap-guard off     stop checking',
102        '/trap-guard pass    let the next refused call run once',
103      ].join('\n'),
104    }
105  })
106}
107
108function modeOf(value: unknown): Mode {
109  return value === 'warn' || value === 'off' ? value : 'block'
110}
111
112function str(input: Record<string, unknown>, key: string): string | undefined {
113  const value = input[key]
114  return typeof value === 'string' ? value : undefined
115}
116
117/** What is wrong with this call, or undefined when nothing is. */
118export function inspect(tool: string, input: Record<string, unknown>): Finding | undefined {
119  switch (tool) {
120    case 'Bash': {
121      const command = str(input, 'command') ?? ''
122      const literal = literalIn(command)
123      if (literal) return { why: `writes ${article(literal.kind)} into a command (${mask(literal.value)})` }
124      return exposureIn(command)
125    }
126    case 'Read':
127    case 'NotebookRead': {
128      const path = str(input, 'file_path') ?? str(input, 'notebook_path') ?? ''
129      const kind = secretFile(path)
130      return kind ? { why: `reads a ${kind} into the conversation (${baseName(path)})` } : undefined
131    }
132    case 'Grep': {
133      const path = str(input, 'path') ?? ''
134      const kind = secretFile(path)
135      return kind ? { why: `prints lines of a ${kind} into the conversation (${baseName(path)})` } : undefined
136    }
137    case 'Write':
138    case 'Edit':
139    case 'MultiEdit':
140    case 'NotebookEdit': {
141      const path = str(input, 'file_path') ?? str(input, 'notebook_path') ?? ''
142      if (secretFile(path) === '.env file') return undefined
143      const edits = Array.isArray(input.edits) ? (input.edits as unknown[]) : []
144      const text = [
145        str(input, 'content'),
146        str(input, 'new_string'),
147        str(input, 'new_source'),
148        ...edits.map(one => (one && typeof one === 'object' ? str(one as Record<string, unknown>, 'new_string') : undefined)),
149      ]
150        .filter((t): t is string => t !== undefined)
151        .join('\n')
152      const literal = literalIn(text)
153      return literal ? { why: `writes ${article(literal.kind)} into ${baseName(path)} (${mask(literal.value)})` } : undefined
154    }
155    case 'WebFetch': {
156      const literal = literalIn(str(input, 'url') ?? '')
157      return literal ? { why: `puts ${article(literal.kind)} in a URL (${mask(literal.value)})` } : undefined
158    }
159    default:
160      return undefined
161  }
162}
163
164function literalIn(text: string): { kind: string; value: string } | undefined {
165  for (const rule of LITERALS) {
166    const match = rule.pattern.exec(text)
167    if (match) return { kind: rule.kind, value: match[0] }
168  }
169  return undefined
170}
171
172function exposureIn(command: string): Finding | undefined {
173  const whole = command.trim()
174  if (/^(?:printenv|env|export\s+-p|set)$/.test(whole)) return { why: 'prints every environment variable, secrets included' }
175
176  for (const segment of command.split(/\|\||&&|[|;&\n]/)) {
177    const words = segment.trim().split(/\s+/).filter(w => w.length > 0)
178    const [head, ...rest] = words[0] === 'sudo' ? words.slice(1) : words
179    if (head === undefined) continue
180    if ((head === 'echo' || head === 'printf') && SECRET_VAR.test(segment)) {
181      return { why: 'prints a secret variable into the conversation' }
182    }
183    if (head === 'printenv' && rest.some(w => /KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL/i.test(w))) {
184      return { why: 'prints a secret variable into the conversation' }
185    }
186    if (READERS.has(head)) {
187      // `sed -i` edits the file in place and prints nothing.
188      if (head === 'sed' && rest.some(w => /^-[a-zA-Z]*i/.test(w) || w === '--in-place')) continue
189      for (const word of rest) {
190        if (word.startsWith('-')) continue
191        const kind = secretFile(word.replace(/^['"]|['"]$/g, ''))
192        if (kind) return { why: `prints a ${kind} into the conversation (${baseName(word)})` }
193      }
194    }
195  }
196  return undefined
197}
198
199export function secretFile(path: string): string | undefined {
200  const clean = path.replace(/\\/g, '/')
201  const base = baseName(clean)
202  if (/^\.env(?:\..+)?$/.test(base) && !/\.(?:example|sample|template|dist|defaults?|schema)$/i.test(base)) return '.env file'
203  if (/^id_(?:rsa|dsa|ecdsa|ed25519)$/.test(base)) return 'SSH private key'
204  if (/\.(?:pem|p12|pfx)$/i.test(base)) return 'key file'
205  if (/(?:^|\/)\.aws\/credentials$/.test(clean)) return 'AWS credentials file'
206  if (/(?:^|\/)\.(?:netrc|pypirc)$/.test(clean)) return 'credentials file'
207  if (/(?:^|\/)\.docker\/config\.json$/.test(clean)) return 'Docker credentials file'
208  return undefined
209}
210
211function article(kind: string): string {
212  return (/^[AEIOU]/i.test(kind) ? 'an ' : 'a ') + kind
213}
214
215function baseName(path: string): string {
216  return path.split(/[\\/]/).pop() ?? path
217}
218
219/** Enough to recognise the value, never the value itself. */
220export function mask(value: string): string {
221  return `${value.slice(0, 6)}…, ${value.length}자`
222}
223
224function denyText(finding: Finding): string {
225  return (
226    `game-trap-guard blocked this call: it ${finding.why}. ` +
227    'Keep secrets out of commands, files and the conversation: read them from an environment variable ' +
228    '(for example $ANTHROPIC_API_KEY) or a secret store, refer to them by name, and keep literal values only in an .env file. ' +
229    'If this is a false positive, ask the user to type /trap-guard pass and then retry.'
230  )
231}
232
233function warnText(finding: Finding): string {
234  return (
235    `game-trap-guard (warn mode): this call ${finding.why}. ` +
236    'Do not repeat the secret in your reply, and tell the user it may now be in the transcript.'
237  )
238}
239