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

Stops a tool call that would put a secret where it does not belong, before it runs:
| Call | Refused when |
|---|---|
| Bash | a literal key or token is in the command (curl -H "x-api-key: sk-ant-…") |
| Bash | it prints a secret file or variable: cat .env, grep KEY .env, echo $API_KEY, printenv GITHUB_TOKEN, a bare printenv / env |
| Read · Grep | the 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 · NotebookEdit | a literal key is written into a file that is not an .env file (an .env file is the right place for one) |
| WebFetch | a 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.
claude plugin marketplace add Reasonofmoon/bitgame-mods
claude plugin install game-trap-guard@bitgame-mods
| Command | |
|---|---|
/trap-guard | status |
/trap-guard block | refuse (default; setting mode) |
/trap-guard warn | let it run; Claude gets a note, you get a toast |
/trap-guard off | stop checking |
/trap-guard pass | let 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.
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를 입력하세요.
hooks/register.ts 239 lines1import 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