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.
This is a fork of secret-guard from arasovic/claude-code-mods by Mehmet Aras, MIT licensed (see LICENSE). It adds fewer false refusals on quoted search patterns and jq filters, keeps values of placeholder-named variables visible, and refuses any tool call that would write a hidden-value tag back to disk.

[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=…, REDISCLI_AUTH=… or "client_secret": "…", and the password in a connection URL such as redis://:…@host. A variable named as a placeholder (PLACEHOLDER_SECRET, DUMMY_TOKEN) keeps its value visible..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, Chrome, Chromium, Brave, Edge and Firefox cookie and password stores (macOS, Linux and Windows profiles, quoted paths with spaces included), KeePass and pass files, Bitwarden desktop and CLI data and the local 1Password database (macOS, Linux and Windows), 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, password manager reads (bw get, bw list items, bw export, op read, op inject, op item get --reveal) and the commands that print a vault session key (bw unlock, op signin --raw), and any shell command that names a credential file, such as cat .dev.vars | curl …. A NAME=value or env prefix, a full path or extra spaces do not hide a command.[secret-guard: …] tag (a Write or Edit, a shell command, an MCP write tool) is refused, since it would replace the real value in a file. Shell quoting and escaping are undone before the check.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.grep -rn '\.env' . runs. Outside quotes it escapes the next character, so cat \.env is still blocked. A backslash separates directories only in a Windows path (C:\…, \\server\…).git commit -m "copy .env.example, then edit" runs. After . or / it stays, so grep -r token ~/.ssh/. is still blocked.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.<<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.python, python3 or node script from a quoted heredoc is not shell, so a command name in its text (note = "a; printenv") runs, and so does code that names a file without quotes (store.get(field.key)). The script is checked by the strings in it: one that is a credential path ('.env', f'{home}/.aws/credentials', the 'id_rsa' of os.path.join(home, '.ssh', 'id_rsa')) is blocked, while a sentence that mentions one ("env_file: .env") runs. This holds only when the script has none of the words system, popen, subprocess, spawn, exec, child_process or pty, and under the same opener-line rule as for cat, so python3 - <<'EOF' | sh is still checked in full.print(os.environ), json.dumps(os.environ), console.log(process.env) or JSON.stringify(process.env). Other forms, such as print(dict(os.environ)), are not.\ 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.
/ in it, is not hidden. A percent-encoded / (%2F) is fine. Dev passwords such as postgres:postgres@ are hidden, so a shell command that holds one is refused.( 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.jq reads its first argument as a filter, so jq '.env.PATH' settings.json runs. The files it reads are still checked, including --rawfile and --slurpfile values, redirects, and the filter file of -f. Other tools with a filter argument, such as yq, are not read this way.ls, stat, test / [, touch, chmod, chown, rm, ssh-keygen, and ssh, scp or sftp with the key given by -i or IdentityFile. 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.env | grep -c TOKEN, grep -c '^API_KEY=' .env, wc), or it cuts the values off (cut -d= -f1, sed 's/=.*/=x/') from lines a grep anchored at the line start kept (env | grep '^AWS_' | cut -d= -f1). Without the anchored grep the cut is not enough, since a value that runs over several lines continues on lines with no =. Only grep, cut, sed, sort, uniq, head, tail, wc and tr may follow the read.git or gh message, title or body (-m, --body, --title, --notes) names files without reading them, so gh issue create --body "set PORT in .env" runs. So does git config --get credential.helper, which prints the helper's name.[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 lbzepoqo/claude-plugins
claude plugin install secret-guard@lbzepoqo
Restart Claude Code. The status light appears under the prompt.
claude plugin validate .
claude plugin test .hooks/register.ts 336 lines1import type { EngineInterface, Register } from 'claude-code'
2import { commandPipelines, pathWords, quotedWords, scriptLiterals, shellWords, withoutDataHeredocs, withoutJqFilter, writesFile } 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 // AUTH and PASS count only as the last whole word (REDISCLI_AUTH, DB_PASS), so AUTHOR, BYPASS and GOOGLE_OAUTH stay.
22 /^(?<head>\s*(?:export\s+)?(?:[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIAL)[A-Z0-9_]*|(?:[A-Z0-9_]*_)?(?:AUTH|PASS))\s*=\s*["']?)(?<value>[^\s"'#$<]{12,})/gm,
23 // code, JSON, YAML: a quoted literal assigned to a secret-shaped name
24 /(?<head>["']?\b[\w.-]*(?:secret|token|password|passwd|api_?key|access_?key|private_?key)["']?\s*[:=]\s*(["']))(?<value>[^"'\s$<{]{12,})(?=\2)/gi,
25 // the password in a connection URL: redis://:pass@host, postgres://user:REDACTED@host; it runs to the last @
26 /(?<head>\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]*:)(?<value>[^\s/$<{[][^\s/]{7,})(?=@)/gi,
27]
28
29const tag = (rule: string) => `[secret-guard: ${rule}]`
30const isExample = (match: string) => /example|dummy|placeholder/i.test(match)
31// A variable named as a placeholder (PLACEHOLDER_SECRET, DUMMY_TOKEN). Whole words only, and never the user name of a
32// connection URL: words such as mock, sample or example also name real services and accounts.
33const isPlaceholderName = (head: string) => !head.includes('://') && /(?:^|[^a-z])(placeholder|dummy)(?:[^a-z]|$)/i.test(head)
34
35export const scrub = (text: string): { text: string; rules: string[] } => {
36 const rules: string[] = []
37 let out = text
38 for (const [rule, pattern] of SECRET_RULES) {
39 out = out.replace(pattern, match => {
40 // A cut-off private key runs to the end of the text, so the example check would read unrelated words.
41 if (rule !== 'private-key' && isExample(match)) return match
42 rules.push(rule)
43 return tag(rule)
44 })
45 }
46 for (const pattern of ASSIGNMENTS) {
47 out = out.replace(pattern, (match, ...args) => {
48 const { head, value } = args.at(-1) as { head: string; value: string }
49 if (isExample(value) || isPlaceholderName(head) || value.startsWith('[secret-guard')) return match
50 rules.push('secret-value')
51 return head + tag('secret-value')
52 })
53 }
54 return { text: out, rules }
55}
56
57// Rewrites every string inside a value, keeping its shape.
58export const scrubDeep = <T>(value: T, found: string[]): T => {
59 if (typeof value === 'string') {
60 const s = scrub(value)
61 found.push(...s.rules)
62 return s.text as T
63 }
64 if (Array.isArray(value)) return value.map(v => scrubDeep(v, found)) as T
65 if (value !== null && typeof value === 'object')
66 return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, scrubDeep(v, found)])) as T
67 return value
68}
69
70// Browser profiles and password-manager data, whose paths can hold spaces (`Application Support`, `User Data`, `Bitwarden CLI`,
71// a Windows user name). Local State holds the key that decrypts Chromium's cookies and passwords. A vault folder counts as a
72// whole, so `tar` or `cp -r` of it is caught.
73const SPACED_PATHS: readonly RegExp[] = [
74 /\/library\/(application support|cookies)\/.*\/(login data|cookies|cookies\.sqlite|key4\.db|logins\.json)$/,
75 /\/(google-chrome[^/]*|chromium|chrome|brave-browser|bravesoftware|microsoft-edge[^/]*|edge|firefox|mozilla)\/.*\/(login data[^/]*|(extension )?cookies(-journal)?|web data(-journal)?|local state|vpn tokens(-journal)?|cookies\.sqlite(-shm|-wal)?|key4\.db|logins(-backup)?\.(json|db)|formhistory\.sqlite)$/,
76 /\/(\.config|appdata\/roaming|library\/application support)\/bitwarden( cli)?(\/|$)/, /\/bitwarden-appdata(\/|$)/,
77 /\/packages\/8bitsolutionsllc\.bitwardendesktop_[^/]*(\/|$)/,
78 /\/appdata\/local\/1password(\/data)?(\/[^/]*\.sqlite[^/]*)?\/?$/, /\/appdata\/local\/1password\/data\//,
79 /\/library\/group containers\/2bua8c4s2c\.com\.(1password|agilebits)(\/|$)/,
80]
81
82// Matched against the lowercased path with forward slashes and a leading slash.
83const SENSITIVE_PATHS: readonly RegExp[] = [
84 // `name.env` files, but not the code spellings a search names (`grep -rn process.env src`).
85 /\/\.env$/, /\/\.env\.(?!(example|sample|template|defaults|dist)$)[^/]+$/, /\/(?!(process|import\.meta|c)\.env$)[^/\\]+\.env$/,
86 /\/\.envrc$/, /\/\.dev\.vars(\.[^/]+)?$/, /\/\.flaskenv$/,
87 /\/\.(aws|gem|cargo|config\/git)\/credentials(\.toml)?$/, /\/credentials\.json$/, /\/service-account[^/]*\.json$/, /\/\.vault-token$/, /\/\.vault_pass$/,
88 // 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.
89 /^(?!\/([a-z]|this|self|event|evt|entry|item|node|props|row|pair|kv|obj)\.key$).*\.(key|p12|pfx|jks|keystore|ppk)$/,
90 /\/id_(rsa|dsa|ecdsa|ed25519)(_sk)?$/,
91 /\/\.ssh\/(?!(known_hosts[^/]*|config|authorized_keys|[^/]+\.pub)$)[^/]+$/,
92 /\/\.(npmrc|pypirc|netrc|git-credentials|pgpass|my\.cnf|s3cfg)$/, /\/_netrc$/,
93 /\/\.(bash|zsh|sh|python|node_repl|psql|mysql|sqlite)_history$/, /\/\.zhistory$/, /\/fish_history$/,
94 /\/\.aws\/(sso|cli)\/cache\//, /\/\.config\/gcloud\/(credentials\.db|access_tokens\.db|application_default_credentials\.json|legacy_credentials\/)/,
95 /\/\.azure\/(accesstokens\.json|msal_token_cache[^/]*)$/, /\/\.kube\/config(-[^/]+)?$/, /\/\.docker\/config\.json$/,
96 /\/\.config\/gh\/hosts\.yml$/, /\/\.config\/glab-cli\/config\.yml$/, /\/\.config\/rclone\/rclone\.conf$/,
97 /\/\.gnupg\/[^/]+/, /\/\.terraform\.d\/credentials\.tfrc\.json$/,
98 /\.tfstate(\.backup)?$/, /\/terraform\.tfvars(\.json)?$/, /\.auto\.tfvars(\.json)?$/,
99 /\/\.claude\/\.credentials\.json$/, /\/\.codex\/auth\.json$/, /\/\.gemini\/oauth_creds\.json$/,
100 /\/library\/keychains\//, ...SPACED_PATHS,
101 /\.kdbx$/, /\/(\.password-store|\.config\/1password|\.lastpass|\.bitwarden)(\/|$)/,
102 /\/(library\/containers\/com\.bitwarden\.desktop|\.var\/app\/com\.bitwarden\.desktop)(\/|$)/,
103 // Any `environ`: in `/proc/$(pgrep x)/environ` the `$(` splits the path, leaving `/environ`.
104 /\/environ$/, /\/run\/secrets\//, /\/etc\/shadow$/, /\/etc\/ssh\/ssh_host_[^/]+_key$/,
105]
106
107export const sensitivePath = (path: string, home: string, rules = SENSITIVE_PATHS): boolean => {
108 // `$APPDATA` and `$LOCALAPPDATA` (Git Bash) and their `%…%` forms become Windows paths, so the AppData rules see them.
109 const expanded = path.replace(/^(~|\$HOME|\$\{HOME\})(?=\/|$)/, home)
110 .replace(/^(\$LOCALAPPDATA|\$\{LOCALAPPDATA\}|%LOCALAPPDATA%)(?=[\\/]|$)/i, 'C:\\AppData\\Local')
111 .replace(/^(\$APPDATA|\$\{APPDATA\}|%APPDATA%)(?=[\\/]|$)/i, 'C:\\AppData\\Roaming')
112 // A backslash separates directories only in a Windows path; elsewhere it is part of a search pattern (`grep '\.env'`).
113 const isWindowsPath = /^(?:[a-z]:\\|\\\\)/i.test(expanded)
114 const p = ('/' + (isWindowsPath ? expanded.replace(/\\/g, '/') : expanded)).replace(/\/+/g, '/').toLowerCase()
115 return rules.some(r => r.test(p))
116}
117
118const SECRET_COMMANDS: readonly RegExp[] = [
119 /^(printenv|compgen -v)\b/, /^(env|set|export|export -p|declare -p|declare -x)$/,
120 /^security (find-generic-password|find-internet-password|dump-keychain)\b/,
121 /^gh auth (token|status .*(-t|--show-token))\b/,
122 /^aws (configure (get|export-credentials)|sts get-session-token|secretsmanager get-secret-value|ssm get-parameters?\b.*--with-decryption)/,
123 /^gcloud auth (application-default )?print-(access|identity)-token\b/,
124 /^az account get-access-token\b/,
125 /^kubectl (config view .*--raw|get secrets?\b)/,
126 // credential.helper names the helper program, not a secret.
127 /^git (credential fill|config .*--get.*credential(?!\.helper\b))/,
128 /^(op read|vault (read|kv get)|doppler secrets|heroku auth:token)\b/,
129 // Global options can come before the subcommand (`bw --session x list items`), so the words are looked for anywhere.
130 // A session key is as good as the vault, so the commands that print one are refused too.
131 /^bw\b(?=.* (get|export|receive)\b|.* list\b.* items\b|.* unlock\b)/,
132 /^op\b(?=.* (inject|signin\b.* --raw)\b|.* document get\b|.* items? get\b.* --(reveal|otp|format)\b)/,
133 /^(echo|printf)\b.*\$\{?[A-Za-z0-9_]*(TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIAL)/i,
134]
135
136// Prose names files too (`copy .env.example, then`). Trailing punctuation after a name is dropped, but not after `.` or `/`,
137// so `~/.ssh/.` still names the folder.
138const withoutTrailingPunctuation = (word: string) => word.replace(/(?<=[^./])[.,;:!?)]+$/, '')
139
140// These touch a credential file without printing what is in it.
141// The command a segment runs, as the rules above read it: single spaces, without leading `NAME=value` assignments or
142// `env`, and with a path or a package runner reduced to the tool's name (`/usr/bin/bw`, `npx @bitwarden/cli`).
143const ASSIGNMENT = String.raw`[A-Za-z_][A-Za-z0-9_]*=(?:'[^']*'|"[^"]*"|\S)*`
144const LEADING = new RegExp(String.raw`^(?:${ASSIGNMENT}|env(?: (?:-[iv0]+|-u \S+|--?\S+=\S*|--))*(?= )|sudo|command|exec|time|nohup) `)
145export const commandHead = (segment: string): string => {
146 let head = segment.replace(/\s+/g, ' ').trim()
147 for (let previous = ''; previous !== head; ) {
148 previous = head
149 head = head.replace(LEADING, '')
150 }
151 return head.replace(/^(?:(?:npx|bunx|pnpm dlx)(?: -y)? @bitwarden\/cli|\S*\/(?=\S))/, match => (match.includes('@bitwarden') ? 'bw' : ''))
152}
153
154// ssh-keygen never prints a private key.
155const NON_READING = new Set(['ls', 'stat', 'test', '[', 'touch', 'chmod', 'chown', 'rm', 'ssh-keygen'])
156const LISTS_ENVIRONMENT = /^(printenv|env|set|export|export -p|declare -p|declare -x|compgen -v)$/
157// ssh and scp read the key file given with -i or IdentityFile, and print none of it.
158const SSH_CLIENTS = new Set(['ssh', 'scp', 'sftp'])
159const isKeyOption = (word: string | undefined) => word === '-i' || word === 'IdentityFile'
160// The text of a message, title or body names files without reading them. Only these commands take one, and in git only -m
161// does: `git commit -t` reads a template file, and `git show -m` takes no value.
162const GIT_PROSE = /^git (?:commit|tag|merge|notes(?: \S+)?|stash(?: push| save)?)\b/
163const GH_PROSE = /^gh (?:issue|pr|release) (?:create|comment|edit|review|close)\b/
164const QUOTED_TEXT = String.raw`(?:"(?:[^"\\$\x60]|\\.)*"|'[^']*')`
165const GIT_PROSE_OPTION = new RegExp(String.raw`(?<=\s(?:-m|--message)[\s=])${QUOTED_TEXT}`, 'g')
166const GH_PROSE_OPTION = new RegExp(String.raw`(?<=\s(?:-b|-t|--body|--title|--notes)[\s=])${QUOTED_TEXT}`, 'g')
167const withoutProse = (segment: string, head: string) =>
168 GIT_PROSE.test(head) ? segment.replace(GIT_PROSE_OPTION, '""') : GH_PROSE.test(head) ? segment.replace(GH_PROSE_OPTION, '""') : segment
169
170// A grep's options and patterns as GNU grep reads them: options may follow the pattern, and `--` ends them. A pattern file
171// counts as a pattern that matches anything.
172const grepParts = (words: string[]) => {
173 const options: string[] = []
174 const patterns: string[] = []
175 const operands: string[] = []
176 let isOptionsEnd = false
177 for (let index = 1; index < words.length; index++) {
178 const word = words[index] ?? ''
179 if (!isOptionsEnd && word === '--') isOptionsEnd = true
180 else if (!isOptionsEnd && ['-e', '--regexp', '-f', '--file'].includes(word)) patterns.push(/f/.test(word) ? '' : (words[++index] ?? ''))
181 else if (!isOptionsEnd && word.length > 1 && word.startsWith('-')) options.push(word)
182 else operands.push(word)
183 }
184 return { options, patterns: patterns.length > 0 ? patterns : operands.slice(0, 1) }
185}
186
187// A pipeline from a listing or a credential file read prints no value when it ends in a count, or when it cuts values off
188// at `=` after a grep anchored at the line start kept only `NAME=` lines: a value that runs over several lines continues on
189// lines without an `=`, which the cut would print whole. Only stages that pass whole lines may come between, and nothing
190// may go to a file.
191const COUNT_OPTIONS = /^(?:-[a-zA-Z]*[cqlL]|--(?:count|quiet|silent|files-with(?:out)?-match(?:es)?)$)/
192const counts = (words: string[]) => words[0] === 'wc' || (words[0] === 'grep' && grepParts(words).options.some(option => COUNT_OPTIONS.test(option)))
193const keepsLines = (words: string[]) => {
194 const options = words.slice(1).filter(word => word.startsWith('-'))
195 if (words[0] === 'grep') return !grepParts(words).options.some(option => /^-[a-zA-Z]*o|^--only/.test(option))
196 if (words[0] === 'sort') return !options.some(option => /^-[a-zA-Z]*o|^--output/.test(option))
197 if (words[0] === 'head' || words[0] === 'tail') return !options.some(option => /^-[a-zA-Z]*c|^--bytes/.test(option))
198 return words[0] === 'uniq' || words[0] === 'wc'
199}
200const anchoredGrep = (words: string[]) => {
201 if (words[0] !== 'grep') return false
202 const { options, patterns } = grepParts(words)
203 // -F reads `^` literally; context, inverted and other options can pass lines the pattern did not match.
204 return options.every(option => /^-[EiwP]+$|^--(?:ignore-case|extended-regexp)$/.test(option)) && patterns.length > 0 &&
205 patterns.every(pattern => pattern.startsWith('^') && !pattern.replace(/\\?\([^()]*\\?\)/g, '').includes('|'))
206}
207// In sed's replacement `&` and `\0` put the match back, so neither may appear there.
208const DROPS_VALUES = /^(?:cut (?!.*--complement)(?=.*-d ?(['"]?)=\1(?: |$))(?=.*-f ?1(?: |$))|sed (?:-[Er] )?(['"]?)s([/#|])=\.\*\$?\3[^'"\s&\\]*\3g?\2$)/
209const printsNoValue = (stages: string[]) => {
210 if (stages.some(writesFile)) return false
211 const heads = stages.map(commandHead)
212 const words = heads.map(head => shellWords(head) ?? [])
213 const drop = heads.findIndex((head, index) => index > 0 && DROPS_VALUES.test(head))
214 if (!words.every((stageWords, index) => index === 0 || index === drop || keepsLines(stageWords))) return false
215 return counts(words.at(-1) ?? []) || (drop > 0 && words.slice(0, drop).some(anchoredGrep))
216}
217// The commands that only print a file, so the pipeline after them is all that decides what shows.
218const PLAIN_READERS = new Set(['cat', 'grep', 'head', 'tail'])
219
220export const secretCommand = (command: string, home: string): string | undefined => {
221 // A script that prints the whole environment is printenv by another name.
222 if (/\b(console\.log|print|JSON\.stringify|json\.dumps)\(\s*(process\.env|os\.environ)\s*\)/.test(withoutDataHeredocs(command)))
223 return 'printing the whole environment prints secrets'
224 // A python or node script that starts no process is not shell, so only the files it names in strings are checked.
225 const literal = scriptLiterals(command).find(word => sensitivePath(word, home, /\s/.test(word) ? SPACED_PATHS : SENSITIVE_PATHS))
226 if (literal) return `${literal} is a credential file`
227 for (const pipeline of commandPipelines(withoutDataHeredocs(command, { dropScripts: true }))) {
228 for (const [index, segment] of pipeline.entries()) {
229 const head = commandHead(segment)
230 const isNameOnly = () => printsNoValue(pipeline.slice(index))
231 if (SECRET_COMMANDS.some(rule => rule.test(head)) && !(LISTS_ENVIRONMENT.test(head) && isNameOnly()))
232 return `\`${head.split(' ').slice(0, 3).join(' ')}\` prints secrets`
233 const reads = withoutProse(segment, head)
234 const words = pathWords(withoutJqFilter(reads))
235 const cmd = words[0] ?? ''
236 if (NON_READING.has(cmd)) continue
237 // 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.
238 const sources = cmd === 'cp' || cmd === 'mv' ? words.slice(1, -1) : words
239 // A word with `|` is a search pattern's alternation (`grep -E 'a|process.env'`) or quoted shell text, so each part is checked.
240 // A part that starts with a backslash is a pattern's escape (`'\\.env|env'`), not a UNC path.
241 const checked = sources
242 .filter((word, wordIndex) => !(SSH_CLIENTS.has(cmd) && (isKeyOption(sources[wordIndex - 1]) || /^-i./.test(word))))
243 .flatMap(word => (word.includes('|') ? word.split('|').filter(part => !part.startsWith('\\')) : [word]))
244 const token = checked.find(word => sensitivePath(withoutTrailingPunctuation(word), home))
245 if (token && !(PLAIN_READERS.has(cmd) && isNameOnly())) return `${token} is a credential file`
246 // A quoted sentence ends like many paths (`-m "~/.ssh/config: add a host"`), so a whole quoted word is held only to
247 // the rules for paths with spaces, the ones that need it.
248 const quotedPath = quotedWords(reads).find(word => sensitivePath(word, home, SPACED_PATHS))
249 if (quotedPath) return `${quotedPath} is a credential file`
250 }
251 }
252 return undefined
253}
254
255const FILE_TOOLS: Record<string, string> = { Read: 'file_path', Edit: 'file_path', Write: 'file_path', NotebookEdit: 'notebook_path' }
256const LOCAL_WRITES = new Set(['Write', 'Edit', 'NotebookEdit'])
257
258// A tag with its space removed, matched against arguments with quotes, backslashes and whitespace removed.
259const EMITTED_TAG = new RegExp(`\\[secret-guard:(${[...SECRET_RULES.map(([rule]) => rule), 'secret-value'].join('|')})\\]`)
260
261// An old_string holding a tag cannot match the file anyway, so checking every field costs nothing. Code joins a
262// tag split into pieces ('[secret-guard: ''x]', "[secret-guard: " "x]", a backslash-newline, sed's \[), so the
263// pieces are joined here too: JSON escapes such as \n go first, then quotes, backslashes and whitespace.
264export const writesPlaceholder = (args: Record<string, unknown>) =>
265 EMITTED_TAG.test(JSON.stringify({ ...args, description: undefined }).replace(/\\[nrt]/g, '').replace(/[\\'"\s]/g, ''))
266
267const 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.'
268const SEND_HINT = 'Take the secret out of the call. If the step needs it, ask the user to run it themselves.'
269const 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.'
270
271// Module state starts over on reload; the counts are this load's.
272const tally = { hidden: 0, blocked: 0, scrubOff: true }
273let home = ''
274
275export const statusText = (t: typeof tally) => {
276 const light = t.blocked > 0 ? '🔴' : t.hidden > 0 ? '🟡' : '🟢'
277 const parts = [t.hidden > 0 ? `${t.hidden} hidden` : 'none seen', t.blocked > 0 ? `${t.blocked} blocked` : '']
278 return `${light} secrets: ${parts.filter(Boolean).join(', ')}${t.scrubOff ? ' · env scrub off' : ''}`
279}
280
281function show($: EngineInterface) {
282 $.ui.status(statusText(tally))
283}
284
285export const register: Register = on => {
286 on('session.start', async ($, e, next) => {
287 home = (await $.env.get('HOME')) ?? ''
288 tally.scrubOff = (await $.env.get('CLAUDE_CODE_SUBPROCESS_ENV_SCRUB')) !== '1'
289 show($)
290 return next(e)
291 })
292
293 on('tool.call', async ($, e, next) => {
294 const args = e as Record<string, unknown>
295 const deny = (reason: string, hint = READ_HINT) => {
296 tally.blocked++
297 show($)
298 return { deny: `secret-guard: ${reason}. ${hint}` }
299 }
300
301 const pathKey = FILE_TOOLS[e.tool]
302 const path = pathKey ? args[pathKey] : undefined
303 if (typeof path === 'string') {
304 if (sensitivePath(path, home)) return deny(`${path} is a credential file`)
305 const real = (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath
306 if (real && sensitivePath(real, home)) return deny(`${path} leads to a credential file`)
307 }
308 if (e.tool === 'Bash' && typeof args.command === 'string') {
309 const reason = secretCommand(args.command, home)
310 if (reason) return deny(reason)
311 }
312 if (!LOCAL_WRITES.has(e.tool) && scrub(JSON.stringify(args)).rules.length > 0) return deny('this call would send a secret', SEND_HINT)
313 // Any tool can write a file (a shell redirect, an MCP write tool), and would put the tag on disk in place of the value.
314 if (writesPlaceholder(args)) return deny('this call carries a hidden-value tag', WRITE_HINT)
315
316 const ran = await next(e)
317 if (ran.deny !== undefined || ran.isError) return ran
318 const found: string[] = []
319 const result = scrubDeep(ran.result, found)
320 if (found.length === 0) return ran
321 tally.hidden += found.length
322 show($)
323 return { result, context: ran.context }
324 })
325
326 // Every other row: prompts the person pastes, model output, attachments, compaction summaries, error results.
327 on('session.append', ($, e, next) => {
328 const found: string[] = []
329 const content = scrubDeep(e.message.content, found)
330 if (found.length === 0) return next(e)
331 tally.hidden += found.length
332 show($)
333 return next({ ...e, message: { ...e.message, content } })
334 })
335}
336hooks/shell-read.ts 260 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[] => commandPipelines(command).flat()
6
7// The same segments, grouped by the pipelines they form: `env | cut -d= -f1; ls` is [['env', 'cut -d= -f1'], ['ls']].
8export const commandPipelines = (command: string): string[][] => {
9 const text = command.replace(/[\w.-]+\(/g, '(')
10 const pipelines: string[][] = []
11 let pipeline: string[] = []
12 const resumeQuotes: ('"' | null)[] = []
13 let current = ''
14 let quote: '"' | "'" | "$'" | null = null
15 let isBacktickInQuote = false
16 const endSegment = (isPiped = false) => {
17 pipeline.push(current)
18 current = ''
19 if (isPiped) return
20 pipelines.push(pipeline)
21 pipeline = []
22 }
23 for (let index = 0; index < text.length; index++) {
24 const char = text[index] ?? ''
25 const pair = text.slice(index, index + 2)
26 if (quote === "'") {
27 if (char === "'") quote = null
28 current += char
29 continue
30 }
31 // In `$'…'` a backslash escapes the next character, so `$'it\'s'` closes on its last quote.
32 if (quote === "$'") {
33 if (char === "'") quote = null
34 current += char === '\\' ? pair : char
35 if (char === '\\') index++
36 continue
37 }
38 if (char === '\\') {
39 // A backslash-newline joins two lines into one, as bash does.
40 if (pair !== '\\\n') current += pair
41 index++
42 continue
43 }
44 if (quote === '"') {
45 if (pair === '$(') {
46 endSegment()
47 resumeQuotes.push('"')
48 quote = null
49 index++
50 } else if (char === '`') {
51 endSegment()
52 isBacktickInQuote = true
53 quote = null
54 } else {
55 if (char === '"') quote = null
56 current += char
57 }
58 continue
59 }
60 if (char === '#' && /^$|[\s;&|()`<>]/.test(text[index - 1] ?? '')) {
61 // A comment runs to the end of the line, so a quote in it (`# don't`) opens nothing.
62 const end = text.indexOf('\n', index)
63 const stop = end === -1 ? text.length : end
64 current += text.slice(index, stop)
65 index = stop - 1
66 } else if (pair === "$'") {
67 quote = "$'"
68 current += pair
69 index++
70 } else if (char === "'" || char === '"') {
71 quote = char
72 current += char
73 } else if (pair === '&&' || pair === '||') {
74 endSegment()
75 index++
76 } else if (pair === '$(' || char === '(') {
77 endSegment()
78 resumeQuotes.push(null)
79 if (pair === '$(') index++
80 } else if (char === ')') {
81 endSegment()
82 quote = resumeQuotes.pop() ?? null
83 } else if (char === '`') {
84 endSegment()
85 if (isBacktickInQuote) quote = '"'
86 isBacktickInQuote = false
87 } else if (';|\n'.includes(char)) {
88 endSegment(char === '|')
89 } else {
90 current += char
91 }
92 }
93 endSegment()
94 return pipelines
95 .map(stages => stages.map(part => part.trim().replace(/^(sudo|command|exec|time|nohup)\s+/, '')).filter(Boolean))
96 .filter(stages => stages.length > 0)
97}
98
99// `cat` or `tee` writing a heredoc out is data, but only with a quoted delimiter: in a `<<EOF` body `$(…)` and backticks still run.
100// The opener line holds that command alone, after plain `&&` steps at most, so in `echo tee; python3 - <<'EOF'` or
101// `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
102// not hold `$(` or a backtick, and a trailing `# comment` is allowed. `\x60` is a backtick, which String.raw cannot hold.
103const QUOTED = String.raw`'[^']*'|"(?:[^"\x60\\$]|\$(?!\())*"`
104const AND_STEP = String.raw`(?:[^<>|;&'"\x60\\()#]|${QUOTED})*`
105const FILE_NAME = String.raw`(?:[^\s<>|;&'"\x60\\()#]|${QUOTED})+`
106const heredocOpener = (writer: string) =>
107 new RegExp(String.raw`^(?:${AND_STEP}&&)*\s*(?:${writer})\s*<<-?\s*(['"])([\w.-]+)\1(?:\s*>>?\s*${FILE_NAME})?(?:\s+#.*)?\s*$`)
108const DATA_HEREDOC = heredocOpener(String.raw`cat(?:\s*>>?\s*${FILE_NAME})?|tee\s+(?:-a\s+)?${FILE_NAME}`)
109// A python or node script from a quoted heredoc is not shell, so a command name in its text does not run, unless the script
110// starts a process. The spawn words are matched anywhere in the body, so `filesystem` or `cursor.execute` keep it checked too.
111const SCRIPT_HEREDOC = heredocOpener(String.raw`(?:python3?|node)(?:\s+-)?`)
112const SPAWNS = /system|popen|subprocess|spawn|exec|child_process|\bpty\b/i
113const HEREDOC = /(?<!<)<<(?!<)-?\s*\\?(['"]?)([\w.-]+)\1/g
114// ponytail: counts brackets without reading quotes, so a stray `)` in an earlier quote can hide a group; a parser if that matters.
115const isGrouped = (text: string) => (text.match(/[({]/g)?.length ?? 0) > (text.match(/[)}]/g)?.length ?? 0)
116
117// Drops data heredoc bodies. Any other body is kept, and no line in it opens a data heredoc. Inside an open `{`, `(`, `<(` or `$(` the
118// output of `cat` can go to bash (`{ cat <<'EOF' … } | bash`), so a body there is not data. With `dropScripts`, script bodies that
119// start no process go too. Both views read the lines the same way and differ only in what is returned.
120export const withoutDataHeredocs = (command: string, { dropScripts = false } = {}): string => {
121 const { kept, scriptLines } = readHeredocs(command)
122 return kept.filter((_, index) => !dropScripts || !scriptLines.has(index)).join('\n')
123}
124
125const STRING_LITERAL = /(['"`])((?:(?!\1)[^\\\n]|\\.)*)\1/g
126
127// The string literals on the script lines that withoutDataHeredocs drops. A script names the files it opens in strings, so
128// these are what is left to check once its code is no longer read as shell.
129export const scriptLiterals = (command: string): string[] => {
130 const { raw, scriptLines } = readHeredocs(command)
131 return [...scriptLines].flatMap(index => [...(raw[index] ?? '').matchAll(STRING_LITERAL)].map(match => match[2] ?? ''))
132}
133
134// The command's lines without data heredoc bodies: `kept` with the quotes in other bodies blanked, `raw` as written, and the
135// indexes of the script body lines that start no process.
136const readHeredocs = (command: string) => {
137 const kept: string[] = []
138 const raw: string[] = []
139 const scriptLines = new Set<number>()
140 let endings: string[] = []
141 let isDataBody = false
142 let scriptStart = -1
143 for (const line of command.split('\n')) {
144 if (endings.length > 0) {
145 if (line.trim() === endings[0]) {
146 endings.shift()
147 if (scriptStart >= 0 && !SPAWNS.test(kept.slice(scriptStart).join('\n')))
148 for (let index = scriptStart; index < kept.length; index++) scriptLines.add(index)
149 scriptStart = -1
150 } else if (isDataBody) continue
151 // A body is not read with shell quotes: in `<<EOF` they are plain text around a running `$(…)`, and python's `'''it's'''`
152 // is an odd count. So quotes and `#` there must not hide what follows.
153 kept.push(line.replace(/['"#]/g, ' '))
154 raw.push(line)
155 continue
156 }
157 // After a trailing `\` the line continues the one before it, so its heredoc can belong to another command.
158 const isOpenerAlone = !kept.at(-1)?.endsWith('\\') && !isGrouped([...kept, line].join('\n'))
159 const opener = isOpenerAlone ? DATA_HEREDOC.exec(line) : null
160 const script = isOpenerAlone && !opener ? SCRIPT_HEREDOC.exec(line) : null
161 isDataBody = opener !== null
162 endings = opener ? [opener[2] ?? ''] : script ? [script[2] ?? ''] : [...line.matchAll(HEREDOC)].map(match => match[2] ?? '')
163 kept.push(line)
164 raw.push(line)
165 scriptStart = script ? kept.length : -1
166 }
167 return { kept, raw, scriptLines }
168}
169
170// Splits a segment into the words the path check reads. Whitespace, `<`, `>` and `=` split even inside quotes,
171// because a quoted string can hold a command (`bash -c "cat .env"`). Quotes are removed. A backslash outside
172// quotes escapes the next character, so `\.env` is `.env`; inside single quotes it stays, so `'\.env'` is a pattern.
173export const pathWords = (segment: string): string[] => {
174 const words: string[] = []
175 let current = ''
176 let quote: '"' | "'" | null = null
177 for (let index = 0; index < segment.length; index++) {
178 const char = segment[index] ?? ''
179 const next = segment[index + 1] ?? ''
180 if (/[\s<>=]/.test(char)) {
181 if (current) words.push(current)
182 current = ''
183 } else if (quote === null && (char === "'" || char === '"')) {
184 quote = char
185 } else if (char === quote) {
186 quote = null
187 } else if (char === '\\' && (quote === null || (quote === '"' && '$`"\\'.includes(next)))) {
188 current += next
189 index++
190 } else {
191 current += char
192 }
193 }
194 if (current) words.push(current)
195 return words
196}
197
198// Splits a segment into shell words, keeping their quotes. An operator such as `<`, `2>`, `<>` or `&` is a token of its own,
199// as it is in bash. Undefined when a quote is left open.
200const SHELL_TOKEN = /\d*[<>&|;()]+|(?:[^\s'"\\<>&|;()]|\\[\s\S]|'[^']*'|"(?:[^"\\]|\\[\s\S])*")+/g
201const OPERATOR = /^\d*[<>&|;()]/
202const shellTokens = (segment: string): string[] | undefined =>
203 segment.replace(SHELL_TOKEN, '').trim() === '' ? (segment.match(SHELL_TOKEN) ?? []) : undefined
204
205const QUOTED_PART = /'([^']*)'|"((?:[^"\\]|\\[\s\S])*)"/g
206
207// Whether a segment sends output to a file. `/dev/null` and a copy to another output (`2>&1`) do not count, and a quote left
208// open does, since it can hide one.
209export const writesFile = (segment: string): boolean => {
210 const tokens = shellTokens(segment)
211 if (!tokens) return true
212 return tokens.some((token, index) => OPERATOR.test(token) && token.includes('>') && !token.endsWith('&') && tokens[index + 1] !== '/dev/null')
213}
214
215// The shell words of a segment with their quotes removed; undefined when a quote is left open.
216export const shellWords = (segment: string): string[] | undefined =>
217 shellTokens(segment)?.map(token => token.replace(QUOTED_PART, (_, single, double) => single ?? double))
218
219// The shell words whose quotes hold whitespace, quotes removed, as one string each: `"User Data/Default/Login Data"` is
220// one path, which pathWords splits. A quoted command (`bash -c "cat 'a b/Login Data'"`) is read again for its own words.
221export const quotedWords = (segment: string): string[] => {
222 const words: string[] = []
223 for (const token of shellTokens(segment) ?? []) {
224 if (!/['"]/.test(token)) continue
225 const word = token.replace(QUOTED_PART, (_, single, double) => single ?? double)
226 if (/\s/.test(word)) words.push(word)
227 for (const [, single, double] of token.matchAll(QUOTED_PART)) words.push(...quotedWords(single ?? double ?? ''))
228 }
229 return words
230}
231
232// The jq options that take values, and how many.
233const JQ_OPTION_VALUES = new Map([['--indent', 1], ['-L', 1], ['--arg', 2], ['--argjson', 2], ['--slurpfile', 2], ['--rawfile', 2]])
234
235// jq reads its first argument that is not an option as a filter, so `jq '.env.PATH' settings.json` names a key, not a file.
236// The filter is dropped and every other word is still read: files, option values and redirect targets. Nothing is dropped with
237// `-f` (the filter comes from a file), with a filter that imports a module (that reads a JSON file), or after an operator other
238// than a redirect, such as a lone `&` that starts a second command.
239export const withoutJqFilter = (segment: string): string => {
240 if (!/^(\S*\/)?jq\s/.test(segment)) return segment
241 const tokens = shellTokens(segment)
242 if (!tokens) return segment
243 let isOptionsEnd = false
244 for (let index = 1; index < tokens.length; index++) {
245 const token = tokens[index] ?? ''
246 if (OPERATOR.test(token)) {
247 if (!/[<>]/.test(token)) return segment
248 index++
249 } else if (!isOptionsEnd && token.startsWith('-')) {
250 if (token === '--') isOptionsEnd = true
251 else if (token === '--from-file' || /^-[a-zA-Z]*f/.test(token)) return segment
252 else index += JQ_OPTION_VALUES.get(token) ?? 0
253 } else {
254 if (/\b(import|include)\b/.test(token)) return segment
255 return tokens.filter((_, tokenIndex) => tokenIndex !== index).join(' ')
256 }
257 }
258 return segment
259}
260