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
★ 2v0.1.17MITupdated 2026-10-21lbzepoqo/claude-plugins/plugins/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 ⏺ 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.

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'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=…, 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.
  • 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, 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.
  • 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, 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.
  • Keeps hidden values off disk. Any tool call that carries a [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.
  • 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.
  • The mod does not read quoted text as commands. So grep -E "(export )?" file runs.
  • A backslash in quotes is part of a search pattern, so 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\…).
  • Punctuation right after a file name is not part of it, so git commit -m "copy .env.example, then edit" runs. After . or / it stays, so grep -r token ~/.ssh/. is still blocked.
  • The mod treats a heredoc as data when 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.
  • The mod checks every other heredoc body. A <<EOF body runs any $(…) in it, so a credential file name (.env) in such a body is blocked.
  • The mod does not read quotes in a heredoc body that runs. So a quoted env or export there is blocked, as in grep -E "(export )?" f in a bash <<'EOF' body.
  • A 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.
  • A script that prints the whole environment is caught only as 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.
  • The mod splits commands with a simple reader, not a full shell parser. A command built to fool it gets through. Examples:
  • a heredoc end line with extra spaces, or with a \ on the line before it
  • a delimiter such as <<$END or <<A-B!
  • a << inside a quote or a comment
  • nested escaped backticks, or $(( '$(…)' ))
  • a function that redefines cat or tee
  • a quoted bracket before an open { or (, as in { echo ")" && cat <<'EOF' … } | bash, because the mod counts brackets without reading quotes

The mod still hides known key formats in the output.

  • Secrets in a format it does not know pass through.
  • A connection URL password shorter than 8 characters, or with a raw / 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.
  • 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.
  • 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.
  • Commands that touch a credential file without printing it still run: 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.
  • A listing or a credential file read runs when the pipeline prints no value: it ends in a count (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.
  • The text of a 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.
  • 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 lbzepoqo/claude-plugins
claude plugin install secret-guard@lbzepoqo

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

Develop

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.ts 336 lines
1import 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}
336
hooks/shell-read.ts 260 lines
1// 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