[security] Built-in secret redaction at jsonl write-time in ~/.claude/projects/
Summary
Claude Code persists every sub-agent transcript to ~/.claude/projects/<id>/*.jsonl and records tool stdout verbatim. If a shell command echoes a secret (echo \$API_KEY, gcloud iam service-accounts keys create out.json, cat creds.env, env-var dumps from printenv, etc.), the secret is now durably on disk in the session transcript with no built-in redaction.
This becomes a leak vector when:
- the transcript dir is checked into git (a config-sync repo or dotfile mirror)
- backups (Time Machine, Windows File History) sweep up the transcripts
- screen recording / screen sharing surfaces the transcript pane
- a future tool reads the transcript for context and emits the secret into a new artifact
Incident that motivated this
We hit this in production. 15 GitHub secret-scanning alerts on a private dotfile-sync repo (~/.claude/ mirrored to git). Eight distinct secret families surfaced inside sub-agent jsonl files:
| Family | Pattern |
|---|---|
| Anthropic API key | sk-ant-api03-… |
| OpenRouter API key | sk-or-v1-<64 hex> |
| Stripe webhook signing secret | whsec_… |
| Supabase Personal Access Token | sbp_<40 hex> |
| Telegram Bot Token | <bot_id>:<35+ char alpha> (one alert was \publicly_leaked=true\) |
| GCP Service Account JSON (full key) | \"type\":\"service_account\" … \"private_key\":\"-----BEGIN PRIVATE KEY-----…\" |
| GitHub fine-grained PAT | github_pat_… |
| GitHub OAuth access token | gho_… |
Root cause: the transcript writer doesn't run any redaction pass. We had to ship a SessionEnd hook + a pre-commit gitleaks rule set + git history rewrite to remediate. None of these layers would have been necessary if the transcript writer redacted at write time.
Proposed feature
Write-time secret redaction in the jsonl persistence layer.
- Opt-in via a Claude Code setting (e.g.
\"transcripts.redactSecrets\": true\). - Rule set: the same families above, extensible via a user-provided
~/.claude/secret-rules.toml\(compatible with the gitleaks format would be ideal). - Replacement marker:
\[REDACTED:<rule-id>:<sha256[0:8]>]\so the location and rule are visible without exposing the secret. The 8-char hash gives forensic locating (\"this transcript references the same secret as that transcript\") without enabling recovery. - Configurable: ship a default rule set covering common cloud/SaaS API keys; let users add custom rules.
Why write-time, not session-end
- Session-end runs after the transcript exists on disk. Anything that scrapes the file mid-session (backups firing on FS events, screen sharing, another tool reading
~/.claude/projects/) sees the raw secret. - Mid-session crashes leave the unredacted transcript in place indefinitely.
- The current session is the operator's most active surface — the highest-value window to protect.
A SessionEnd hook (which we built locally as a workaround) is strictly weaker than write-time. We'd switch immediately if write-time were available upstream.
Feasibility evidence
We shipped a working SessionEnd hook in ~250 LOC of Python (hooks/session-end/redact-transcripts.py in our config repo) that walks ~/.claude/projects/<id>/*.jsonl, applies the 8-rule regex set, replaces matches with [REDACTED:<rule>:<sha256_8>], and writes atomically via tmp + os.replace. Idempotent. 6 unit tests passing. Audit log to ~/.claude/audit/redactions.jsonl. Runtime <1ms/MB.
The same logic at write-time would be smaller and more reliable. The jsonl writer already has the message boundaries; per-line regex scrub before flush is trivially streamable.
Labels
- enhancement
- security
Context
No values exposed in this issue. Happy to share the redactor source or our incident postmortem if useful — DM works.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗