SLOPSHOPPER

secret-guard

Stops Claude from reading .env files, private keys and credentials.

newguardtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-guard
› fix the failing auth test and add an audit log call ╭──────────────────────╮ │ secret-guard │ ⏺ Read(src/auth.ts) │ ⊘ blocked Bash: .env │ ⎿ Read 6 lines ╰──────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(cat .env) ⎿ Denied by secret-guard: secret-guard: .env looks like a secret; ask the person to share what is needed. ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ secret-guard: secret-guard · 1 blocked
README

secret-guard

Stops Claude from reading .env files, private keys and credentials.

What it draws

When Claude tries to read a secret-looking file, the call is refused and Claude is told to ask you for what it needs. A toast names the file, and a status entry counts the blocks in this session:

Bash(cat config/.env.production)
  secret-guard: config/.env.production looks like a secret; ask the person to share what is needed.

toast    ⊘ blocked Bash: config/.env.production
status   secret-guard · 1 blocked

If the check itself fails, the call is refused too.

What counts as a secret

  • .env and .env.*, except .env.example, .env.sample and .env.template
  • *.pem and *.key
  • id_rsa* and id_ed25519*, public keys included (allow *.pub to let them through)
  • .npmrc, .pypirc, credentials.json and .aws/credentials
  • anything under a .ssh/ folder

Matching ignores case and treats \ and / alike. It looks at the file name and at every folder in the path, so C:\Users\me\.ssh\config, ~/.aws/credentials and C:\repo\.env::$DATA are all caught.

What is refused

  • File tools: Read, Edit, Write, NotebookEdit and Grep (its path and glob) on such a path. MultiEdit and NotebookRead too, on builds that have them.
  • Shell: Bash, PowerShell and Monitor commands that read such a path. Readers include cat, less, head, tail, grep (also grep -f .env), sed, awk, base64, xxd, cp, mv, scp, rsync, tar, curl, source, python -c, Get-Content, type and [IO.File]::ReadAllText(...).
  • The shell check also catches < .env, bash -c "...", cmd /c ..., ssh host '...', eval "...", $(...), find ... -exec cat, ... | xargs cat, xargs -a .env, kubectl exec pod -- cat and git show HEAD:.env. A wildcard such as .env* is tried against typical secret names.
  • A reader counts only where it runs: as the command, after wrappers such as sudo, env, timeout and xargs, after -exec or --, or as a git subcommand. Text that only mentions a file passes: git commit -m "Move .env loading into config", a here-document body, a single-quoted string.
  • Writing (echo X=1 >> .env, cp .env.example .env), listing (ls -la .env) and a grep pattern (grep -rn ".env" src) are not reads, so they pass.

Options

OptionTypeDefaultWhat it does
extraPatternsstring""Comma-separated globs that also count as secrets, e.g. .envrc, .secret, config/prod/.yml.
allowstring""Comma-separated globs that always pass, checked first, e.g. fixtures/**, *.pub.

In a glob, * stays within a folder, ** crosses folders and ? is one character. A glob without / matches the file name or any folder name. A glob with / matches the end of the path.

Set them with /config, or in settings under pluginConfigs["secret-guard@mod-showroom"].options.

Install

  • Terminal: /plugin install secret-guard --marketplace KROU4/claude-code-mod-showroom
  • Shell: claude plugin install secret-guard --marketplace KROU4/claude-code-mod-showroom
  • Desktop app: + next to the prompt box → Plugins → Add marketplace KROU4/claude-code-mod-showroom → Add plugin

Limits

  • The shell check reads command lines by their spelling. It is a seatbelt against accidental reads, not a sandbox.
  • It does not look inside docker exec <container> ... without --, or inside the $(...) of an unquoted here-document.
  • MCP tools are not checked.

License

MIT. See LICENSE.

Source 3 files
hooks/register.ts 38 lines
1import { atom, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { findSecret, rulesFrom } from './secrets'
5
6const blocked = atom({ plugin: 'secret-guard', key: 'blocked' } as const, 0)
7
8const denial = (path: string) =>
9  `secret-guard: ${path} looks like a secret; ask the person to share what is needed.`
10const FAILED = 'secret-guard: its check failed, so the call was refused.'
11
12export const register: Register = (on, options) => {
13  const rules = rulesFrom(options)
14
15  on('tool.call', async ($, e, next) => {
16    const secret = findSecret(e, rules)
17    if (secret === undefined) return next(e)
18
19    const count = await update($, blocked, n => n + 1)
20    $.ui.status(`secret-guard · ${count} blocked`)
21    $.ui.toast(`⊘ blocked ${String(e.tool)}: ${secret}`)
22
23    return { deny: denial(secret) }
24  }).catch(async ($, e, next) => {
25    if (next.called) return next(e)
26    let secret: string | undefined
27    try {
28      secret = findSecret(e, rules)
29    } catch {
30      return { deny: FAILED }
31    }
32    if (secret !== undefined) return { deny: denial(secret) }
33
34    // Re-entry (raised beneath a call of ours): judged here on `e` alone. Any other failure fails closed.
35    return next.error.kind === 're-entry' ? next(e) : { deny: FAILED }
36  })
37}
38
hooks/secrets.ts 374 lines
1// Which tool calls reach secret-looking files. Pure functions, no `$`.
2
3import type { PluginOptions } from 'claude-code'
4
5export type Glob = { matches: (path: string, segments: readonly string[]) => boolean; sample: string }
6export type Rules = { extra: readonly Glob[]; allow: readonly Glob[] }
7
8const ALLOWED_ENV = new Set(['.env.example', '.env.sample', '.env.template'])
9const SECRET_NAMES = new Set(['.npmrc', '.pypirc', 'credentials.json'])
10// What a wildcard in a shell word is tried against: `cat .env*`, `cat *.pem`.
11const SAMPLES = ['.env', '.env.local', 'id_rsa', 'id_ed25519', 'cert.pem', 'server.key', '.npmrc', '.pypirc', 'credentials.json', 'credentials']
12
13// Tools whose arguments name a file; others are compared by name only.
14const FILE_FIELDS = new Map<string, readonly string[]>([
15  ['Read', ['file_path']],
16  ['Edit', ['file_path']],
17  ['Write', ['file_path']],
18  ['MultiEdit', ['file_path']],
19  ['NotebookEdit', ['notebook_path']],
20  ['NotebookRead', ['notebook_path']],
21  ['Grep', ['path', 'glob']],
22])
23// Tools whose `command` a shell runs, with that shell's escape character.
24const SHELL_ESCAPES = new Map([['Bash', '\\'], ['Monitor', '\\'], ['PowerShell', '`']])
25
26// Commands that read (or copy away) the files they name.
27const READERS = new Set([
28  'cat', 'tac', 'nl', 'less', 'more', 'most', 'bat', 'batcat', 'view', 'vi', 'vim', 'nvim', 'ex', 'ed', 'nano', 'emacs',
29  'head', 'tail', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'gawk', 'mawk', 'cut', 'sort', 'uniq',
30  'paste', 'join', 'comm', 'diff', 'cmp', 'column', 'fold', 'fmt', 'pr', 'rev', 'expand', 'strings', 'od', 'xxd',
31  'hexdump', 'hd', 'base64', 'base32', 'basenc', 'uuencode', 'cp', 'mv', 'ln', 'install', 'scp', 'rsync', 'sftp',
32  'tar', 'zip', '7z', 'gzip', 'bzip2', 'xz', 'zstd', 'zcat', 'dd', 'jq', 'yq', 'openssl', 'curl', 'wget', 'source', '.',
33  'python', 'python3', 'node', 'deno', 'bun', 'ruby', 'perl', 'php', 'lua',
34  'get-content', 'gc', 'type', 'select-string', 'sls', 'copy-item', 'copy', 'cpi', 'move-item', 'move', 'mi',
35  'format-hex', 'fhx', 'import-csv', 'findstr', 'xcopy', 'certutil',
36])
37const GREPS = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'select-string', 'sls', 'findstr'])
38const PATTERN_FLAGS = new Set(['-e', '--regexp', '-pattern'])
39// Flags that hand a grep its patterns or files, so its first positional is no pattern.
40const FILE_FLAGS = new Set(['-f', '--file', '-path', '-literalpath'])
41const COPIERS = new Set(['cp', 'mv', 'ln', 'install', 'scp', 'rsync', 'copy-item', 'copy', 'cpi', 'move-item', 'move', 'mi', 'xcopy'])
42const DESTINATION_FLAGS = new Set(['-t', '--target-directory', '-destination'])
43const GIT_READS = new Set(['show', 'diff', 'log', 'blame', 'cat-file', 'grep', 'archive'])
44const GIT_VALUED = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace'])
45// Words after which a command starts again: `find -exec`, `fd -x`, `kubectl exec pod --`.
46const EXEC_FLAGS = new Set(['-exec', '-execdir', '-ok', '-okdir', '-x', '--exec', '--'])
47// Flags whose following words are a command line: `bash -c`, `pwsh -Command`, `cmd /c`.
48const RUN_FLAGS = /^(-c|--?command|\/c|\/k)$/i
49
50// Words that run the command after them. `valued` flags take the next word; `operands` leading words are no command.
51type Wrapper = { valued?: readonly string[]; operands?: number }
52const WRAPPERS = new Map<string, Wrapper>([
53  ['sudo', { valued: ['-u', '-g', '-h', '-p', '-C', '-D', '-r', '-t', '-U'] }],
54  ['doas', { valued: ['-u', '-C'] }],
55  ['env', { valued: ['-u', '-C', '--unset', '--chdir'] }],
56  ['exec', { valued: ['-a'] }],
57  ['time', { valued: ['-f', '-o', '--format', '--output'] }],
58  ['nice', { valued: ['-n', '--adjustment'] }],
59  ['timeout', { valued: ['-s', '-k', '--signal', '--kill-after'], operands: 1 }],
60  ['watch', { valued: ['-n', '--interval'] }],
61  ['xargs', { valued: ['-a', '--arg-file', '-d', '--delimiter', '-E', '-I', '-L', '-n', '--max-args', '-P', '--max-procs', '-s', '--max-chars'] }],
62  ['ssh', { valued: ['-b', '-c', '-D', '-E', '-e', '-F', '-I', '-i', '-J', '-L', '-l', '-m', '-O', '-o', '-p', '-Q', '-R', '-S', '-W', '-w'], operands: 1 }],
63  ...['command', 'builtin', 'nohup', 'eval', 'iex', 'invoke-expression', 'if', 'then', 'else', 'elif', 'while', 'until', 'do', '!'].map(
64    name => [name, {}] as const,
65  ),
66])
67
68/** Lower case, forward slashes, no quotes, no `host:`, `HEAD:` or drive prefix, no `::$DATA` stream. */
69export function normalise(raw: string): string {
70  const path = raw.trim().replace(/^["']+|["']+$/g, '').replace(/\\/g, '/').toLowerCase().replace(/::\$data\b/g, '')
71  const colon = path.indexOf(':')
72
73  return colon > 0 && !path.slice(0, colon).includes('/') ? path.slice(colon + 1) : path
74}
75
76// A colon left inside a path parts a ref from its file: `origin/main:.env`.
77const segmentsOf = (path: string) => path.split(/[/:]/).filter(part => part !== '' && part !== '.')
78const hasWildcard = (text: string) => /[*?]/.test(text)
79// Short flags are case-sensitive (`-f` is not `-F`); PowerShell's long ones are not.
80const isFlag = (flags: ReadonlySet<string>, arg = '') => flags.has(arg.length > 2 ? arg.toLowerCase() : arg)
81
82function isBuiltinSecret(segments: readonly string[]): boolean {
83  const name = segments.at(-1) ?? ''
84  if (segments.includes('.ssh')) return true
85  if (name === 'credentials' && segments.at(-2) === '.aws') return true
86  if (name === '.env' || name.startsWith('.env.')) return !ALLOWED_ENV.has(name)
87
88  return SECRET_NAMES.has(name) || name.endsWith('.pem') || name.endsWith('.key') || name.startsWith('id_rsa') || name.startsWith('id_ed25519')
89}
90
91function globSource(glob: string): string {
92  return glob.replace(/\*\*\/|\*\*|\*|\?|[.+^${}()|[\]\\]/g, token =>
93    token === '**/' ? '(?:.*/)?' : token === '**' ? '.*' : token === '*' ? '[^/]*' : token === '?' ? '[^/]' : `\\${token}`,
94  )
95}
96
97/** A glob without `/` matches the file name or any folder; with `/`, the end of the path. */
98export function compileGlob(pattern: string): Glob | undefined {
99  const glob = normalise(pattern).replace(/^\.\//, '')
100  if (segmentsOf(glob).length === 0) return undefined
101  const sample = (segmentsOf(glob).at(-1) ?? '').replace(/[*?]+/g, 'x')
102
103  if (!glob.includes('/')) {
104    const re = new RegExp(`^${globSource(glob)}$`)
105    return { matches: (_, segments) => segments.some(segment => re.test(segment)), sample }
106  }
107  const re = new RegExp(`(?:^|/)${globSource(glob.replace(/^\/+/, ''))}$`)
108
109  return { matches: path => re.test(path), sample }
110}
111
112export function rulesFrom(options: PluginOptions): Rules {
113  const globs = (value: unknown) =>
114    (typeof value === 'string' ? value.split(',') : []).flatMap(part => compileGlob(part.trim()) ?? [])
115
116  return { extra: globs(options.extraPatterns), allow: globs(options.allow) }
117}
118
119export function isSecretPath(raw: string, rules: Rules): boolean {
120  const path = normalise(raw)
121  const segments = segmentsOf(path)
122  if (segments.length === 0 || rules.allow.some(glob => glob.matches(path, segments))) return false
123
124  return isBuiltinSecret(segments) || rules.extra.some(glob => glob.matches(path, segments))
125}
126
127/** A path, or a shell glob tried against typical secret names: `.env*`, `~/.ssh/*`. */
128export function isSecretWord(word: string, rules: Rules): boolean {
129  const path = normalise(word)
130  const slash = path.lastIndexOf('/')
131  const folder = path.slice(0, slash + 1)
132  const name = path.slice(slash + 1)
133  if (!hasWildcard(name)) return isSecretPath(path, rules)
134  if (/^[*?]*$/.test(name)) return isSecretPath(`${folder}x`, rules)
135  const re = new RegExp(`^${globSource(name)}$`)
136  const samples = [...SAMPLES, ...rules.extra.map(glob => glob.sample)]
137
138  return samples.some(sample => re.test(sample) && isSecretPath(folder + sample, rules))
139}
140
141/**
142 * One simple command. `nested`: its words that run a command line inside double
143 * quotes (`"$(cat .env)"`). `call`: it ended on `(` or `$(`, whose content is its argument.
144 */
145type Segment = { words: string[]; inputs: string[]; nested: string[]; call: boolean }
146
147/**
148 * Splits a command line into simple commands at `; & | ( ) $(`, newlines and
149 * standalone braces, keeping each one's words (quotes removed) and the files `<` reads.
150 * Here-document bodies are text and are skipped.
151 * `escape` is the shell's escape character: `\` for sh, a backtick for PowerShell.
152 */
153function parse(command: string, escape: string): Segment[] {
154  const segments: Segment[] = []
155  const empty = (): Segment => ({ words: [], inputs: [], nested: [], call: false })
156  let current = empty()
157  let word = ''
158  let hasWord = false
159  let runs = false
160  let quote = ''
161  let redirect: 'in' | 'out' | 'heredoc' | undefined
162  const heredocs: string[] = []
163
164  const endWord = () => {
165    if (!hasWord) return
166    if (redirect === 'in') current.inputs.push(word)
167    else if (redirect === 'heredoc') heredocs.push(word)
168    else if (redirect === undefined) current.words.push(word)
169    if (runs) current.nested.push(word)
170    redirect = undefined
171    word = ''
172    hasWord = false
173    runs = false
174  }
175  const endSegment = (call = false) => {
176    endWord()
177    redirect = undefined
178    current.call = call
179    if (current.words.length > 0 || current.inputs.length > 0 || current.nested.length > 0) segments.push(current)
180    current = empty()
181  }
182  // Index of the newline that ends a here-document begun after `from`, or the end.
183  const bodyEnd = (from: number, delimiter: string) => {
184    let start = from + 1
185    for (;;) {
186      const end = command.indexOf('\n', start)
187      if (end === -1) return command.length
188      if (command.slice(start, end).replace(/^\t+/, '') === delimiter) return end
189      start = end + 1
190    }
191  }
192
193  for (let i = 0; i < command.length; i++) {
194    const c = command[i] ?? ''
195    const following = command[i + 1] ?? ''
196    if (c === escape && (quote === '"' || quote === '') && /["'`$\\\s;&|<>()]/.test(following)) {
197      word += following
198      hasWord = true
199      i++
200    } else if (quote !== '') {
201      if (c === quote) quote = ''
202      else word += c
203      // Inside double quotes `$(...)` and backticks still run; single quotes keep text literal.
204      runs ||= quote === '"' && ((c === '$' && following === '(') || (c === '`' && escape !== '`'))
205    } else if (c === '"' || c === "'") {
206      quote = c
207      hasWord = true
208    } else if (c === '#' && !hasWord) {
209      while (i + 1 < command.length && command[i + 1] !== '\n') i++
210    } else if (c === '(' || (c === '$' && following === '(')) {
211      endSegment(true)
212      if (c === '$') i++
213    } else if (c === '\n' || ';&|)'.includes(c) || (c === '`' && escape !== '`')) {
214      endSegment()
215      if (c === '\n') for (const delimiter of heredocs.splice(0)) i = bodyEnd(i, delimiter)
216    } else if ((c === '{' || c === '}') && !hasWord && /^$|[\s;&|)]/.test(following)) {
217      // `{ cat .env; }` and PowerShell's `{ gc .env }` hold commands; `{}` and `{a,b}` stay words.
218      endSegment()
219    } else if (/\s/.test(c)) {
220      endWord()
221    } else if (c === '<' || c === '>') {
222      // `<` reads a file, `<<` opens a here-document, `<<<` is a here-string, `>` writes.
223      endWord()
224      let last = i
225      while (command[last + 1] === c) last++
226      const count = last - i + 1
227      redirect = c === '>' || count > 2 ? 'out' : count === 2 ? 'heredoc' : 'in'
228      i = command[last + 1] === '&' || (redirect === 'heredoc' && command[last + 1] === '-') ? last + 1 : last
229    } else {
230      word += c
231      hasWord = true
232    }
233  }
234  endSegment()
235
236  return segments
237}
238
239const commandName = (word: string) => (word.split(/[\\/]/).at(-1) ?? '').toLowerCase().replace(/\.exe$/, '')
240
241/** Where the command starts at or after `from`: past `NAME=value`, keywords and wrappers such as `sudo -u root`. */
242function commandStart(words: readonly string[], from = 0): number {
243  let i = from
244  for (;;) {
245    const word = words[i] ?? ''
246    const wrapper = WRAPPERS.get(commandName(word))
247    if (/^[A-Za-z_]\w*=/.test(word)) {
248      i++
249      continue
250    }
251    if (wrapper === undefined) return i
252    let operands = wrapper.operands ?? 0
253    i++
254    while (i < words.length) {
255      const flag = words[i] ?? ''
256      if (flag.startsWith('-')) i += wrapper.valued?.includes(flag) ? 2 : 1
257      else if (operands > 0) {
258        operands--
259        i++
260      } else break
261    }
262  }
263}
264
265/** The word that reads files: the command, a git subcommand, or what `-exec` runs; -1 if none. */
266function readerAt(words: readonly string[]): number {
267  const starts = [0, ...words.flatMap((word, i) => (EXEC_FLAGS.has(word) ? [i + 1] : []))]
268
269  for (const start of starts) {
270    let at = commandStart(words, start)
271    const name = commandName(words[at] ?? '')
272    if (READERS.has(name)) return at
273    if (name !== 'git') continue
274    at++
275    while ((words[at] ?? '').startsWith('-')) at += GIT_VALUED.has(words[at] ?? '') ? 2 : 1
276    if (GIT_READS.has((words[at] ?? '').toLowerCase())) return at
277  }
278
279  return -1
280}
281
282/** The arguments of a reading command that may name what it reads. */
283function readArgs(name: string, args: readonly string[]): string[] {
284  const positional = args.flatMap((arg, i) => (arg.startsWith('-') ? [] : [i]))
285  const after = (flags: ReadonlySet<string>) => args.flatMap((arg, i) => (isFlag(flags, arg) ? [i + 1] : []))
286  // A grep's pattern is no file (`grep -rn ".env" src`); a copy's destination is written (`cp .env.example .env`).
287  const patterns = after(PATTERN_FLAGS)
288  const destinations = after(DESTINATION_FLAGS)
289  const grepSkip = patterns.length > 0 || args.some(arg => isFlag(FILE_FLAGS, arg)) ? patterns : positional.slice(0, 1)
290  const copySkip = destinations.length > 0 ? destinations : positional.slice(-1)
291  const skipped = new Set([
292    ...(GREPS.has(name) ? grepSkip : []),
293    ...(COPIERS.has(name) && positional.length > 1 ? copySkip : []),
294  ])
295
296  return args.filter((_, i) => !skipped.has(i))
297}
298
299/** Words of one simple command that may name a file it reads. */
300function readTargets(words: readonly string[], previous: readonly string[]): string[] {
301  const prefix = words.slice(0, commandStart(words))
302  // `xargs -a .env echo` reads its argument file, whatever it runs.
303  const argFiles = prefix.some(word => commandName(word) === 'xargs')
304    ? prefix.filter((word, i) => /^(-a|--arg-file)$/.test(prefix[i - 1] ?? '') || word.startsWith('--arg-file='))
305    : []
306  const at = readerAt(words)
307  if (at === -1) return argFiles
308  const before = words.slice(0, at)
309  const args = readArgs(commandName(words[at] ?? ''), words.slice(at + 1))
310  // `find . -name .env -exec cat {} +` and `... | xargs cat` read what came before.
311  const isExec = before.some(word => EXEC_FLAGS.has(word))
312  const isXargs = before.some(word => commandName(word) === 'xargs')
313
314  return [...argFiles, ...args, ...(isExec ? before : []), ...(isXargs ? previous : [])]
315}
316
317/** A reader whose argument is the call that follows: `Get-Content (Join-Path ...)`, `[IO.File]::ReadAllText('.env')`. */
318function readsCall(segment: Segment | undefined): boolean {
319  if (segment?.call !== true) return false
320  const last = segment.words.at(-1) ?? ''
321  const at = readerAt(segment.words)
322  if (/io\.file\]::(read|open|copy|move)/i.test(last)) return true
323
324  return at !== -1 && (at === segment.words.length - 1 || (last.startsWith('-') && !isFlag(PATTERN_FLAGS, last)))
325}
326
327// Pieces of a word that may be a path: `open('.env')`, `--env-file=.env`, `-F f=@id_rsa`, `{a,.env}`; `${HOME}` stays whole.
328const pieces = (word: string) => word.match(/(?:\$\{[^}]*\}|[^\s"'`()[\]{},;=@<>|&])+/g) ?? []
329
330/** The first secret-looking path a shell command reads, as written; undefined if none. */
331export function shellSecret(command: string, rules: Rules, escape = '\\', depth = 0): string | undefined {
332  const segments = parse(command, escape)
333
334  for (const [index, segment] of segments.entries()) {
335    const { words, inputs, nested } = segment
336    const previous = segments[index - 1]
337    const targets = [...inputs, ...readTargets(words, previous?.words ?? []), ...(readsCall(previous) ? words : [])]
338    const hit = targets.flatMap(pieces).find(piece => isSecretWord(piece, rules))
339    if (hit !== undefined) return hit
340
341    // Command lines a shell runs: `bash -c "cat .env"`, `cmd /c type .env`, `ssh host 'head .env'`, `echo "$(cat .env)"`.
342    const program = words[commandStart(words)] ?? ''
343    const lines = [
344      ...words.flatMap((_, i) => (RUN_FLAGS.test(words[i - 1] ?? '') ? [words.slice(i).join(' ')] : [])),
345      ...(/\s/.test(program) ? [program] : []),
346      ...nested,
347    ]
348    for (const line of depth < 3 ? lines : []) {
349      const inner = shellSecret(line, rules, escape, depth + 1)
350      if (inner !== undefined) return inner
351    }
352  }
353
354  return undefined
355}
356
357/** The secret-looking path a tool call would touch, as written; undefined if none. */
358export function findSecret(e: Readonly<Record<string, unknown>>, rules: Rules): string | undefined {
359  const tool = String(e.tool)
360  const text = (key: string) => {
361    const value = e[key]
362    return typeof value === 'string' ? value : undefined
363  }
364
365  const escape = SHELL_ESCAPES.get(tool)
366  if (escape !== undefined) {
367    // A Monitor on a WebSocket has no command and reads no file.
368    const command = text('command')
369    return command === undefined ? undefined : shellSecret(command, rules, escape)
370  }
371
372  return (FILE_FIELDS.get(tool) ?? []).map(text).find(path => path !== undefined && isSecretWord(path, rules))
373}
374
types/index.d.ts 9 lines
1/** How many tool calls secret-guard refused this session. */
2export type SecretGuardBlocked = number
3
4declare module 'claude-code' {
5  interface PluginState {
6    'secret-guard': { blocked: number }
7  }
8}
9