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…

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: <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": "…"..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.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 ….env scrub off means Claude Code still passes your environment variables to the commands it runs (see below).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.
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.( 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.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.[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.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.
claude plugin validate .
claude plugin test .
../typecheck.sh secret-guardhooks/register.ts 204 lines1import 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