Permission allowlist caches Bash command strings with inline secrets — settings.json persists PINs / tokens / passwords across sessions
Description
When a user approves a Bash command containing an inline secret (an env-var assignment like SECRETS_PIN=123456 openssl ..., a --token <hex> argument, or any other inline credential), Claude Code writes the literal command string into permissions.allow in the project's .claude/settings.json. Future sessions read settings.json at startup. The allowlist feature, intended to reduce permission prompts, instead persists the very secret it was protecting.
This file is often checked into version control. If .claude/settings.json is committed (intentionally or via a wildcard like git add .), the leak persists in git history even after redaction — only git filter-branch or BFG fully expunges it.
Reproduction
- User runs a Bash command with an inline secret:
``bash``
SECRETS_PIN=123456 openssl enc -aes-256-cbc ...
- Claude Code prompts for permission. User approves.
- Inspect the project's
.claude/settings.json:
``json``
"permissions": {
"allow": [
"Bash(SECRETS_PIN=123456 openssl enc -aes-256-cbc -pbkdf2 ...)"
]
}
- The literal PIN is now persisted on disk, readable by anything with file-system access, and auto-loaded into context at the start of every future session in this project.
Actual incident
A user pasted their master vault PIN inline into chat to test an autonomous vault-storage workflow. Three openssl commands using SECRETS_PIN=<pin> were approved during the session. All three were written verbatim into <project>/.claude/settings.json. The PIN landed in four files total:
.claude/settings.jsonallowlist (this issue)- The Claude Code project transcript JSONL at
~/.claude/projects/<project-slug>/<session-id>.jsonl - A persona-state JSONL at
~/.claude/persona-state/<session-id>.jsonl - Daily-log capture written by a project hook
Files 1-3 are owned by Claude Code itself. The user redacted all four manually after-the-fact via sed, but local-disk redaction does NOT undo any cloud-backup sync (Time Machine / iCloud Drive / Dropbox / GitHub if checked in).
Why this is high-impact
- It defeats the user's mental model of the allowlist. Users approve a command thinking they're whitelisting a pattern; instead they're whitelisting a concrete string with secrets baked in.
- Reduce-prompts feature becomes a leak vector. The very mechanism designed to make Claude Code more usable is the mechanism that persists the secret.
- Affects every project, not just the one where the user typed the secret. Project-local
settings.jsonfiles are often committed; the secret crosses into git history. - Silent failure mode. No warning at approval time that the command contains a likely secret; no mention that the literal string will be persisted.
Suggested fix options (designer's choice)
In order of cheapest-to-most-thorough:
(a) Allowlist normaliser — mask KEY=VALUE pairs whose KEY matches secret-name heuristics
Before writing to permissions.allow, run the command string through a normaliser:
SECRET_KEY_PATTERNS = [r'(?i)(PIN|PASSWORD|TOKEN|KEY|SECRET|PASSPHRASE|API_KEY)']
# Match KEY=VALUE pairs where KEY matches; replace VALUE with <REDACTED>
normalised = re.sub(
r'\b((?:' + '|'.join(SECRET_KEY_PATTERNS) + r')[A-Z_]*?)=\S+',
r'\1=<REDACTED>',
command
)
Cached entries remain readable (so future approval prompts can still pattern-match) but the secret is gone.
(b) Opt-in :NOCACHE flag
Let the user mark a command as non-cacheable at approval time:
Allow: Bash(SECRETS_PIN=$PIN openssl enc ...) :NOCACHE
Approval is one-shot; nothing is written to settings.json. Default behaviour can stay the same; this is just an escape hatch for commands the user knows contain secrets.
(c) Hash-based allowlist with sensitivity bit
Cache the SHA-256 of the command for matching, not the literal string. For sensitive commands (detected by heuristic), require fresh approval each invocation regardless of hash match.
(d) Pre-write secret detector with user prompt
Before writing any allowlist entry, scan the command for secret-like patterns (high-entropy strings, KEY=VALUE assignments with secret-y key names, hex runs ≥ 32 chars). If detected, prompt the user:
"This command appears to contain a secret. Allow it for this session only? (Y/n)"
Combine with (a) as a belt-and-braces approach: even if the user clicks Yes, normalise before writing.
Minimum bar
At minimum, (a) — even without (b)(c)(d) — prevents the verbatim leak. It's a single regex pass on the write path; behaviour-preserving for non-secret commands.
Impact
- Severity: High for users who type credentials inline (vault unlock workflows, ad-hoc
openssl, custom encryption, API tokens passed as Bash args). - Severity: Medium for users who only use environment variables loaded from
.envfiles (the secrets never enter the command string). - Privacy regression for any user whose project
settings.jsonis committed to a shared repo.
Related
The same incident also implicates the transcript writer (file 2) and persona-state writer (file 3) — same underlying class of problem (no pre-write secret filter at any write path Claude Code controls). If a single PR addresses the broader pattern (a write-path secret filter applied across settings.json, the project transcript, and persona-state JSONL writes), all three leak vectors close together.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗