SLOPSHOPPER

secret-guard

Keeps secrets out of the conversation: hides API keys and private keys before the model or the transcript sees them, and blocks reads of credential files and…

newguardstatus
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 ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(cat .env) ⎿ Denied by secret-guard: secret-guard: .env is a credential file. Do not try another way to read it. Use a ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ secret-guard: 🔴 secrets: none seen, 1 blocked · env scrub off
README

secret-guard

Keeps secrets out of the conversation. API keys and private keys are hidden before Claude reads them, credential files cannot be opened, and commands that print secrets do not run.

secret-guard's status light

What it does

  • Hides secrets in what tools return. Known key formats become [secret-guard: <rule>] before Claude reads the result, and the session transcript on disk keeps the hidden form too. Formats: AWS, GitHub, Slack, Stripe, Google, Anthropic and OpenAI keys, private key blocks, JWTs, and secret-named values such as DB_PASSWORD=… or "client_secret": "…".
  • Hides secrets you paste. A key in your own message reaches Claude and the transcript in the same hidden form.
  • Blocks credential files. Reading or editing .env files, .dev.vars, .envrc, SSH and GPG private keys, ~/.aws/credentials, gcloud, Azure, kube and Docker logins, ~/.config/gh/hosts.yml, .npmrc, .pypirc, .netrc, shell history, Terraform state, browser password stores and the macOS keychain. A link to one of these files is followed and blocked too. Templates (.env.example, .env.sample, .env.template, .env.defaults, .env.dist) and public keys (*.pub) stay readable.
  • Blocks commands that print secrets. printenv, bare env and export, security find-generic-password, gh auth token, aws configure get, gcloud auth print-access-token, kubectl get secret, git credential fill, echo $SOME_TOKEN, and any shell command that names a credential file, such as cat .dev.vars | curl ….
  • Blocks sending a secret out. A command, web request or MCP call whose arguments carry a key is refused.
  • Shows a status light. 🟢 nothing seen, 🟡 something was hidden, 🔴 something was blocked. env scrub off means Claude Code still passes your environment variables to the commands it runs (see below).

Turn on env scrubbing too

Set CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 in the env block of ~/.claude/settings.json. Claude Code then removes its own credentials from the environment of the commands it runs, so there is nothing for a command to print. The mod cannot set this for you; the status light shows when it is off.

Limits

  • A script Claude writes and runs (python -c …, node -e …) can open any file. The mod cannot see what it opens; it hides known key formats in the output, nothing more.
  • Secrets in a format it does not know pass through.
  • A name followed by ( is read as a function call, not a file, so grep "mock.env(" tests runs. A zsh glob qualifier on a credential file (cat .env(N)) gets through the same way; known key formats in its output are still hidden. Code names such as process.env, import.meta.env and c.env are not files either, but a script that prints the whole environment (console.log(process.env), print(os.environ)) is blocked.
  • Commands that touch a credential file without printing it still run: ls, stat, test / [, touch, chmod, chown, rm. cp and mv run when the credential file is the target (cp .env.example .env), not when it is the source, so a file cannot be copied to a new name and read there.
  • The screen can show a row for a moment before it is rewritten. Claude and the transcript only read the hidden form.
  • A value hidden in source code, such as a test token, is hidden from Claude as well. Claude can still edit the other lines of that file. A write that would put a [secret-guard: <rule>] tag back into a file is refused, so the real value is never overwritten; Claude will ask you to change that line.

Install

claude plugin marketplace add arasovic/claude-code-mods
claude plugin install secret-guard@claude-code-mods

Restart Claude Code. The status light appears under the prompt.

Develop

claude plugin validate .
claude plugin test .
../typecheck.sh secret-guard
Source 1 files
hooks/register.ts 204 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// Derived from gitleaks' default rules, loosened where key formats change.
4const SECRET_RULES: readonly [string, RegExp][] = [
5  ['private-key', /-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----|$)/g],
6  ['aws-key', /\b(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16}\b/g],
7  ['github-token', /\b(?:gh[pousr]_[0-9A-Za-z]{36}|github_pat_\w{82})\b/g],
8  ['slack-token', /\bxox[abpers]-[0-9A-Za-z-]{10,}/g],
9  ['slack-webhook', /hooks\.slack\.com\/(?:services|workflows|triggers)\/[A-Za-z0-9+/]{43,56}/g],
10  ['stripe-key', /\b[sr]k_(?:test|live|prod)_[0-9A-Za-z]{10,99}\b/g],
11  ['google-api-key', /\bAIza[\w-]{35}(?![\w-])/g],
12  ['anthropic-key', /\bsk-ant-[\w-]{20,}/g],
13  ['openai-key', /\bsk-(?:proj|svcacct|admin)-[\w-]{20,}/g],
14  ['jwt', /\bey[A-Za-z0-9_-]{17,}\.ey[A-Za-z0-9_-]{17,}\.[A-Za-z0-9_-]{10,}/g],
15]
16
17// Only the value is hidden; the name stays so the model knows the key exists.
18const ASSIGNMENTS = [
19  // .env style: UPPER_CASE name, unquoted or quoted value, not a $reference or <placeholder>
20  /^(?<head>\s*(?:export\s+)?[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIAL)[A-Z0-9_]*\s*=\s*["']?)(?<value>[^\s"'#$<]{12,})/gm,
21  // code, JSON, YAML: a quoted literal assigned to a secret-shaped name
22  /(?<head>["']?\b[\w.-]*(?:secret|token|password|passwd|api_?key|access_?key|private_?key)["']?\s*[:=]\s*(["']))(?<value>[^"'\s$<{]{12,})(?=\2)/gi,
23]
24
25const tag = (rule: string) => `[secret-guard: ${rule}]`
26const isExample = (match: string) => /example|dummy|placeholder/i.test(match)
27
28export const scrub = (text: string): { text: string; rules: string[] } => {
29  const rules: string[] = []
30  let out = text
31  for (const [rule, pattern] of SECRET_RULES) {
32    out = out.replace(pattern, match => {
33      // A cut-off private key runs to the end of the text, so the example check would read unrelated words.
34      if (rule !== 'private-key' && isExample(match)) return match
35      rules.push(rule)
36      return tag(rule)
37    })
38  }
39  for (const pattern of ASSIGNMENTS) {
40    out = out.replace(pattern, (match, ...args) => {
41      const { head, value } = args.at(-1) as { head: string; value: string }
42      if (isExample(value) || value.startsWith('[secret-guard')) return match
43      rules.push('secret-value')
44      return head + tag('secret-value')
45    })
46  }
47  return { text: out, rules }
48}
49
50// Rewrites every string inside a value, keeping its shape.
51export const scrubDeep = <T>(value: T, found: string[]): T => {
52  if (typeof value === 'string') {
53    const s = scrub(value)
54    found.push(...s.rules)
55    return s.text as T
56  }
57  if (Array.isArray(value)) return value.map(v => scrubDeep(v, found)) as T
58  if (value !== null && typeof value === 'object')
59    return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, scrubDeep(v, found)])) as T
60  return value
61}
62
63// Matched against the lowercased path with forward slashes and a leading slash.
64const SENSITIVE_PATHS: readonly RegExp[] = [
65  // `name.env` files, but not the code spellings a search names (`grep -rn process.env src`).
66  /\/\.env$/, /\/\.env\.(?!(example|sample|template|defaults|dist)$)[^/]+$/, /\/(?!(process|import\.meta|c)\.env$)[^/]+\.env$/,
67  /\/\.envrc$/, /\/\.dev\.vars(\.[^/]+)?$/, /\/\.flaskenv$/,
68  /\/\.(aws|gem|cargo|config\/git)\/credentials(\.toml)?$/, /\/credentials\.json$/, /\/service-account[^/]*\.json$/, /\/\.vault-token$/, /\/\.vault_pass$/,
69  // A bare `e.key` or `event.key` in a command is code reading a field (`store.get(e.key)`), not a key file; a path to one still counts.
70  /^(?!\/([a-z]|this|self|event|evt|entry|item|node|props|row|pair|kv|obj)\.key$).*\.(key|p12|pfx|jks|keystore|ppk)$/,
71  /\/id_(rsa|dsa|ecdsa|ed25519)(_sk)?$/,
72  /\/\.ssh\/(?!(known_hosts[^/]*|config|authorized_keys|[^/]+\.pub)$)[^/]+$/,
73  /\/\.(npmrc|pypirc|netrc|git-credentials|pgpass|my\.cnf|s3cfg)$/, /\/_netrc$/,
74  /\/\.(bash|zsh|sh|python|node_repl|psql|mysql|sqlite)_history$/, /\/\.zhistory$/, /\/fish_history$/,
75  /\/\.aws\/(sso|cli)\/cache\//, /\/\.config\/gcloud\/(credentials\.db|access_tokens\.db|application_default_credentials\.json|legacy_credentials\/)/,
76  /\/\.azure\/(accesstokens\.json|msal_token_cache[^/]*)$/, /\/\.kube\/config$/, /\/\.docker\/config\.json$/,
77  /\/\.config\/gh\/hosts\.yml$/, /\/\.config\/glab-cli\/config\.yml$/, /\/\.config\/rclone\/rclone\.conf$/,
78  /\/\.gnupg\/[^/]+/, /\/\.terraform\.d\/credentials\.tfrc\.json$/,
79  /\.tfstate(\.backup)?$/, /\/terraform\.tfvars(\.json)?$/, /\.auto\.tfvars(\.json)?$/,
80  /\/\.claude\/\.credentials\.json$/, /\/\.codex\/auth\.json$/, /\/\.gemini\/oauth_creds\.json$/,
81  /\/library\/keychains\//, /\/library\/(application support|cookies)\/.*\/(login data|cookies|cookies\.sqlite|key4\.db|logins\.json)$/,
82  /\/proc\/[^/]+\/environ$/, /\/run\/secrets\//, /\/etc\/shadow$/, /\/etc\/ssh\/ssh_host_[^/]+_key$/,
83]
84
85export const sensitivePath = (path: string, home: string): boolean => {
86  const expanded = path.replace(/^(~|\$HOME|\$\{HOME\})(?=\/|$)/, home)
87  const p = ('/' + expanded.replace(/\\/g, '/')).replace(/\/+/g, '/').toLowerCase()
88  return SENSITIVE_PATHS.some(r => r.test(p))
89}
90
91const SECRET_COMMANDS: readonly RegExp[] = [
92  /^(printenv|compgen -v)\b/, /^(env|set|export|export -p|declare -p|declare -x)$/,
93  /^security (find-generic-password|find-internet-password|dump-keychain)\b/,
94  /^gh auth (token|status .*(-t|--show-token))\b/,
95  /^aws (configure (get|export-credentials)|sts get-session-token|secretsmanager get-secret-value|ssm get-parameters?\b.*--with-decryption)/,
96  /^gcloud auth (application-default )?print-(access|identity)-token\b/,
97  /^az account get-access-token\b/,
98  /^kubectl (config view .*--raw|get secrets?\b)/,
99  /^git (credential fill|config .*--get.*credential)/,
100  /^(op read|bw get|vault (read|kv get)|doppler secrets|heroku auth:token)\b/,
101  /^(echo|printf)\b.*\$\{?[A-Za-z0-9_]*(TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIAL)/i,
102]
103
104// ponytail: plain split on shell operators; sh -c, eval, globs and scripts the model writes pass through. Output scrubbing is the net for those.
105// A name right before `(` is a function call (`mock.env(on)`), not a file; `$(` keeps its `$`, so a substitution still splits.
106const segments = (command: string) =>
107  command.replace(/[\w.-]+\(/g, '(').split(/&&|\|\||[;|\n]|\$\(|`|\(|\)/).map(s => s.trim().replace(/^(sudo|command|exec|time|nohup)\s+/, '')).filter(Boolean)
108
109// These touch a credential file without printing what is in it.
110const NON_READING = new Set(['ls', 'stat', 'test', '[', 'touch', 'chmod', 'chown', 'rm'])
111
112export const secretCommand = (command: string, home: string): string | undefined => {
113  // A script that prints the whole environment is printenv by another name.
114  if (/\b(console\.log|print|JSON\.stringify|json\.dumps)\(\s*(process\.env|os\.environ)\s*\)/.test(command)) return 'printing the whole environment prints secrets'
115  for (const seg of segments(command)) {
116    if (SECRET_COMMANDS.some(r => r.test(seg))) return `\`${seg.split(/\s+/).slice(0, 3).join(' ')}\` prints secrets`
117    const words = seg.split(/\s+|[<>]=?|=/).map(w => w.replace(/^["']|["']$/g, '')).filter(Boolean)
118    const cmd = words[0] ?? ''
119    if (NON_READING.has(cmd)) continue
120    // cp and mv read only their sources: a credential file as the target is setup, as the source it could be copied out and read.
121    const checked = cmd === 'cp' || cmd === 'mv' ? words.slice(1, -1) : words
122    const token = checked.find(w => sensitivePath(w, home))
123    if (token) return `${token} is a credential file`
124  }
125  return undefined
126}
127
128const FILE_TOOLS: Record<string, string> = { Read: 'file_path', Edit: 'file_path', Write: 'file_path', NotebookEdit: 'notebook_path' }
129const LOCAL_WRITES = new Set(['Write', 'Edit', 'NotebookEdit'])
130
131const EMITTED_TAG = new RegExp(`\\[secret-guard${': '}(${[...SECRET_RULES.map(([rule]) => rule), 'secret-value'].join('|')})\\]`)
132
133// An old_string holding a tag cannot match the file anyway, so checking every field costs nothing.
134export const writesPlaceholder = (args: Record<string, unknown>) => EMITTED_TAG.test(JSON.stringify(args))
135
136const READ_HINT = 'Do not try another way to read it. Use a template such as .env.example, or ask the user to run the step and share only what is safe.'
137const SEND_HINT = 'Take the secret out of the call. If the step needs it, ask the user to run it themselves.'
138const WRITE_HINT = 'That would replace the real value in the file. Edit only the lines you need and leave hidden values untouched, or ask the user to make the change.'
139
140// Module state starts over on reload; the counts are this load's.
141const tally = { hidden: 0, blocked: 0, scrubOff: true }
142let home = ''
143
144export const statusText = (t: typeof tally) => {
145  const light = t.blocked > 0 ? '🔴' : t.hidden > 0 ? '🟡' : '🟢'
146  const parts = [t.hidden > 0 ? `${t.hidden} hidden` : 'none seen', t.blocked > 0 ? `${t.blocked} blocked` : '']
147  return `${light} secrets: ${parts.filter(Boolean).join(', ')}${t.scrubOff ? ' · env scrub off' : ''}`
148}
149
150function show($: EngineInterface) {
151  $.ui.status(statusText(tally))
152}
153
154export const register: Register = on => {
155  on('session.start', async ($, e, next) => {
156    home = (await $.env.get('HOME')) ?? ''
157    tally.scrubOff = (await $.env.get('CLAUDE_CODE_SUBPROCESS_ENV_SCRUB')) !== '1'
158    show($)
159    return next(e)
160  })
161
162  on('tool.call', async ($, e, next) => {
163    const args = e as Record<string, unknown>
164    const deny = (reason: string, hint = READ_HINT) => {
165      tally.blocked++
166      show($)
167      return { deny: `secret-guard: ${reason}. ${hint}` }
168    }
169
170    const pathKey = FILE_TOOLS[e.tool]
171    const path = pathKey ? args[pathKey] : undefined
172    if (typeof path === 'string') {
173      if (sensitivePath(path, home)) return deny(`${path} is a credential file`)
174      const real = (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath
175      if (real && sensitivePath(real, home)) return deny(`${path} leads to a credential file`)
176    }
177    if (e.tool === 'Bash' && typeof args.command === 'string') {
178      const reason = secretCommand(args.command, home)
179      if (reason) return deny(reason)
180    }
181    if (!LOCAL_WRITES.has(e.tool) && scrub(JSON.stringify(args)).rules.length > 0) return deny('this call would send a secret', SEND_HINT)
182    if (LOCAL_WRITES.has(e.tool) && writesPlaceholder(args)) return deny('this write contains a hidden-value tag', WRITE_HINT)
183
184    const ran = await next(e)
185    if (ran.deny !== undefined || ran.isError) return ran
186    const found: string[] = []
187    const result = scrubDeep(ran.result, found)
188    if (found.length === 0) return ran
189    tally.hidden += found.length
190    show($)
191    return { result, context: ran.context }
192  })
193
194  // Every other row: prompts the person pastes, model output, attachments, compaction summaries, error results.
195  on('session.append', ($, e, next) => {
196    const found: string[] = []
197    const content = scrubDeep(e.message.content, found)
198    if (found.length === 0) return next(e)
199    tally.hidden += found.length
200    show($)
201    return next({ ...e, message: { ...e.message, content } })
202  })
203}
204