SLOPSHOPPER

secret-guard

Masks secrets in tool results and blocks writing or committing them.

newguardcommandtoaststatus
v0.1.0MITupdated 2026-10-26namtrdev/claude-mods/secret-guard
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) │ secret-guard hid 1 secret in Read output │ ⎿ Read 6 lines ╰──────────────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /secret-guard ⎿ secret-guard: Secret guard is OFF. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ secret-guard: secret guard OFF
README

Secret Guard

Masks secrets in tool results, refuses to write keys into source files and blocks git commits whose staged changes contain secrets. Files named .env are allowed.

Install

/plugin marketplace add namtrdev/claude-mods
/plugin install secret-guard@jackie-mods

Use

Commands: /secret-guard.

Built for Claude Code mods (hook modules). Nothing here runs outside your machine: the mod reads session events and draws its own interface.

Options

You set these when you install the mod, and can change them later in the Claude Code config menu.

  • Mask secrets in tool results (maskResults, default true): Hide secrets in what Read, Bash and other tools return to the model.
  • When a write would hold a secret (onWrite, default block): block refuses the write; warn shows a notice and lets it through.
  • Check staged changes before git commit (guardCommits, default true): Refuse git commit when the staged changes hold a secret.
  • Extra files allowed to hold secrets (extraAllowedFiles, default empty): Regular expressions for file paths, comma separated. Files named .env are always allowed. Example: secrets\.yaml$,\.local\.json$

License

MIT, Copyright (c) 2026 Jackie.

Source 2 files
hooks/register.tsx 143 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4const isOn = atom({ plugin: 'secret-guard', key: 'isOn' } as const, true)
5const masked = atom({ plugin: 'secret-guard', key: 'masked' } as const, 0)
6
7const RULES: { label: string; re: RegExp }[] = [
8  { label: 'private-key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
9  { label: 'anthropic-key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
10  { label: 'api-key', re: /\bsk-[A-Za-z0-9_-]{20,}/g },
11  { label: 'github-token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}|\bgithub_pat_[A-Za-z0-9_]{30,}/g },
12  { label: 'aws-key', re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g },
13  { label: 'slack-token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
14  { label: 'google-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
15  { label: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
16  { label: 'bearer-token', re: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/g },
17  { label: 'secret', re: /(?<=\b(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key)\s*[=:]\s*["']?)[^\s"']{4,}/gi },
18]
19
20// Files meant to hold secrets.
21const ALLOWED = /(^|\/)\.env(\.[\w.-]+)?$/
22
23const labelsIn = (text: string) => {
24  const found: string[] = []
25  for (const rule of RULES) {
26    for (const _ of text.matchAll(rule.re)) found.push(rule.label)
27  }
28  return found
29}
30
31const maskText = (text: string, tally: { n: number }) => {
32  let out = text
33  for (const rule of RULES) {
34    out = out.replace(rule.re, () => {
35      tally.n += 1
36      return rule.label === 'secret' ? '[hidden:secret]' : `[hidden:${rule.label}]`
37    })
38  }
39  return out
40}
41
42const deepMask = (value: unknown, tally: { n: number }): unknown => {
43  if (typeof value === 'string') return maskText(value, tally)
44  if (Array.isArray(value)) return value.map(one => deepMask(one, tally))
45  if (value !== null && typeof value === 'object') {
46    return Object.fromEntries(
47      Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, deepMask(v, tally)]),
48    )
49  }
50  return value
51}
52
53const parse = (raw: unknown) =>
54  String(raw ?? '')
55    .split(',')
56    .map(one => one.trim())
57    .filter(one => one !== '')
58    .flatMap(one => {
59      try {
60        return [new RegExp(one)]
61      } catch {
62        return []
63      }
64    })
65
66export const register: Register = (on, options) => {
67  const extraAllowed = parse(options.extraAllowedFiles)
68  const warnOnly = options.onWrite === 'warn'
69  const guardCommits = options.guardCommits !== false
70  const maskResults = options.maskResults !== false
71
72  on('session.start', async ($, e, next) => {
73    await $.command.register({
74      name: 'secret-guard',
75      description: 'Turn the secret guard (tool results, writes, commits) on or off',
76    })
77
78    return next(e)
79  })
80
81  on('command.run', { command: 'secret-guard' }, async $ => {
82    const was = await read($, isOn)
83    await update($, isOn, () => !was)
84    $.ui.status(was ? 'secret guard OFF' : undefined)
85
86    return { text: was ? 'Secret guard is OFF.' : 'Secret guard is ON.' }
87  })
88
89  on('tool.call', async ($, e, next) => {
90    if (!(await read($, isOn))) {
91      return next(e)
92    }
93    const input = e as unknown as Record<string, unknown>
94
95    if (e.tool === 'Write' || e.tool === 'Edit' || e.tool === 'MultiEdit') {
96      const path = String(input.file_path ?? '')
97      const edits = Array.isArray(input.edits) ? (input.edits as { new_string?: unknown }[]) : []
98      const body = [input.content, input.new_string, ...edits.map(one => one.new_string)]
99        .filter((one): one is string => typeof one === 'string')
100        .join('\n')
101      const found = ALLOWED.test(path) || extraAllowed.some(re => re.test(path)) ? [] : labelsIn(body)
102      if (found.length > 0) {
103        const why = `secret-guard: ${path} would contain ${[...new Set(found)].join(', ')}.`
104        if (warnOnly) {
105          $.ui.toast(why)
106        } else {
107          return { deny: `${why} Read it from an environment variable or a .env file instead.` }
108        }
109      }
110    }
111
112    if (guardCommits && e.tool === 'Bash' && /\bgit\s+commit\b/.test(String(input.command ?? ''))) {
113      try {
114        const staged = await $.tool.call({ tool: 'Bash', command: 'git diff --cached' })
115        const found = labelsIn(String((staged as { text?: string }).text ?? ''))
116        if (found.length > 0) {
117          return {
118            deny: `secret-guard: staged changes contain ${[...new Set(found)].join(', ')}. Unstage them before committing.`,
119          }
120        }
121      } catch {
122        // No repository or git unavailable: nothing to check.
123      }
124    }
125
126    const ran = await next(e)
127    if (!maskResults || ran.deny !== undefined || ran.result === undefined) {
128      return ran
129    }
130    const tally = { n: 0 }
131    const clean = deepMask(ran.result, tally)
132    if (tally.n === 0) {
133      return ran
134    }
135    await update($, masked, n => n + tally.n)
136    $.ui.toast(`secret-guard hid ${tally.n} secret${tally.n > 1 ? 's' : ''} in ${e.tool} output`)
137
138    return ran.context === undefined
139      ? { result: clean as never }
140      : { result: clean as never, context: ran.context }
141  })
142}
143
types/index.d.ts 8 lines
1export type GuardCount = number
2
3declare module 'claude-code' {
4  interface PluginState {
5    'secret-guard': { isOn: boolean; masked: GuardCount }
6  }
7}
8