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