[Security] No sanctioned channel to hand Claude a secret — a map of 18 open requests and the minimal primitive that unblocks them
Preflight Checklist
- [x] I have searched existing requests and this feature hasn't been requested yet
- [x] This is a single feature request (not multiple features)
Problem Statement
Summary
Claude Code has no sanctioned way for a user to hand it a secret. The only channel available is the chat prompt, and the chat prompt is the transcript — which persists in three separate on-disk stores, is re-sent as context on every subsequent turn, and can reach five-year retention through /bug.
That single gap is generating a continuous stream of issues. 18 open issues, zero shipped, the oldest from February 2026, three filed in the last week alone. Each one treats a symptom; none can close the gap, because the gap is an input channel and only the core owns the input.
The ask is one tool: an interactive, masked secret prompt whose value never enters the transcript or the model's context. In the preferred form it adds no config, no providers, no persistence and no new storage — the value lives in process memory for the session and is gone at exit.
---
Why prevention is the only control that actually executes
This is not a convenience request, and it is not another wishlist item. It follows from a chain I would ask reviewers to break if they can:
- Users will keep handing secrets to agents. This is not carelessness to be trained away — it is the natural consequence of an agent that acts on your behalf. The moment a task needs a token, the user has one impulse and one available surface: the prompt box.
- Therefore prohibition does not work.
CLAUDE.mdrules, docs and warnings are advisory and lose to urgency. #82796 is a filed report of the model itself violating an explicit "never print secrets" instruction. Guidance is not a control. - So only two controls remain: prevent the write, or remediate afterwards.
- Remediation is rotation, and rotation does not happen. 64% of valid secrets leaked in 2022 were still unrevoked four years later. Rotation is tedious, it breaks running systems, and it requires the user to first realise they were exposed — which the current design actively hides, since the leak leaves no visible mark. Worse, once the value is in a transcript, the only honest advice is "rotate it", and the user has to re-enter the new secret through the same leaky channel. The remedy re-creates the problem.
- Therefore the only control that executes is preventing the write.
The design consequence is that the safe path has to be available at the moment of the impulse, and it has to cost the user nothing. A safe path that requires prior setup — a vault, a synced store, a provider account — is not available at that moment and loses to the prompt box every time. That is precisely why the primitive has to be a zero-configuration prompt inside the session, and why it cannot be another piece of infrastructure the user has to adopt first.
---
The pattern: 18 open issues, none shipped
Getting a secret in — different users, same missing primitive:
| Issue | Opened | What it asks for |
|---|---|---|
| #23642 | 2026-02-06 | 1Password op:// references in settings.json |
| #29910 | 2026-03-01 | built-in secrets management with third-party backends |
| #32733 | 2026-03-10 | secure secrets injection for Claude Code on the web |
| #73582 | 2026-07-02 | store credentials in the OS secret store, not plaintext |
| #77084 | 2026-07-13 | secret lockbox with broker injection — "agent can USE secrets, never READ them" |
| #88165 | 2026-08-20 | secrets locker so fields can be filled without the model seeing values (a11y) |
| #88757 | 2026-08-22 | envHelper to supply env vars for config expansion |
| #89706 | 2026-08-26 | opt-in to let Claude Code write secret values to credential stores |
| #90116 | 2026-08-27 | non-developers cannot get a working local env securely |
Closed without a solution: #38797 (masked input — auto-closed as a duplicate of #29910 three days after filing), #44158 (${input:id} masked prompts, not planned), #33654 (OS keychain for MCP config, not planned), #2695 (zero-trust env vars, not planned).
Keeping a secret from getting out — the same gap seen from the other side:
| Issue | Opened | What it reports |
|---|---|---|
| #44868, #58043, #59094 | Apr–May 2026 | agents read .env / credential files straight into the transcript — all three closed as not planned |
| #71654 | 2026-06-26 | has repro: live GitHub PAT and an admin Forgejo token written to transcripts; both burned |
| #72156 | 2026-06-28 | /feedback submissions need secret scrubbing before upload |
| #80153 | 2026-07-22 | redact sensitive env-var values from Bash output |
| #82351, #87838 | Jul–Aug 2026 | claude mcp get/list print secret headers in cleartext |
| #82796 | 2026-07-31 | model repeatedly violates an explicit "never print secrets" instruction |
| #88380 | 2026-08-20 | detect and offer to mask secrets in pasted text before submission |
| #90010 | 2026-08-27 | security-guidance echoes raw OAuth tokens from .credentials.json |
The count, stated precisely (verified against the GitHub API on 2026-08-28): 18 of the issues referenced above are open — nine on the inbound side, eight on the outbound side, plus #78344 below. Eight more were closed without a solution (#38797 as a duplicate; #44158, #33654, #2695, #29434, #44868, #58043, #59094 as not planned). None was closed as completed. Twenty-six issues, six months, zero mechanism.
Two observations. First, the outbound issues keep arriving because there is nothing upstream to stop the secret from being there in the first place. Second, the instruction-based mitigations fail by design: #82796 is literally a report that prose rules do not hold.
---
Root cause: every one of these is downstream of a missing input channel
The requests split cleanly in two. Outbound issues ask Claude Code to stop showing a secret it already has. Inbound issues ask for somewhere to put a secret in the first place. All the engineering attention so far has gone to the outbound side — redaction on /feedback upload, redaction in error reports, permission denials on .env reads.
But a secret only needs redacting because it was allowed in. And the way it gets in is the prompt box, because that is the only door in the building.
CLAUDE.md rules do not close it — they are advisory, they lose to urgency, and #82796 documents them losing. Hooks do not close it either (details under Alternatives). Only the core can, because the core owns the input field and the transcript.
---
The double bind: the unsanctioned path both leaks and fails
This is the part I would most like the team to look at, because it shows the gap costs users twice.
#78344: a user pasted their own Vercel API token into chat — the only way to give it to Claude — to add one environment variable to their own project. The auto-mode classifier then blocked five different legitimate ways to use it: an Edit to their own source file, a curl to Vercel's official API, npx vercel env add with the value piped from stdin, and a write to the standard Vercel CLI auth.json. All five denied with Blocked by classifier. A one-minute task took ninety, and, in the reporter's words, "zero safety gained".
So the current state is: the only available channel is the one that leaks, and after paying that price the user still cannot complete the task. The classifier is not wrong to be suspicious — a raw credential interpolated into a shell command is genuinely indistinguishable from exfiltration. It has nothing else to go on.
A secret broker fixes that too. A command that declares uses: ["VERCEL_TOKEN"] and receives the value through the environment is machine-distinguishable from a command with a credential pasted into its argument string. The permission layer finally has a signal it can allow on, instead of a shape it must deny. This is a benefit to the platform's own safety machinery, not just to user convenience.
---
Evidence: where a pasted secret actually lands
Verified on Claude Code 2.1.247, macOS. A secret pasted into chat is written to at least three places on disk, not one:
| Path | Contents |
|---|---|
| ~/.claude/projects/<project>/<session>.jsonl | session transcript, plaintext |
| ~/.claude/history.jsonl | prompt history — display and pastedContents fields |
| ~/.claude/paste-cache/*.txt | individual files holding pasted text |
Reproduce with a harmless marker rather than a real credential:
# paste MARKER_STRING into the chat prompt, then:
grep -rl "MARKER_STRING" ~/.claude/projects/ ~/.claude/history.jsonl ~/.claude/paste-cache/
I have not seen this noted in any of the 18 issues: deleting a session does not remove the secret from disk. A user who discovers the exposure and clears their history is left with copies in two other stores and false confidence that they cleaned up.
Retention, per current documentation: local transcripts are plaintext for 30 days by default (cleanupPeriodDays); /feedback, /bug and /share send a copy of the conversation to Anthropic with 5-year retention and optionally open a public GitHub issue; consumer accounts with model improvement enabled are also at 5 years. One /bug from a session containing a live token puts a working credential into five-year retention.
Classification: CWE-532 (Insertion of Sensitive Information into Log File — a transcript is functionally a log), CWE-312 (Cleartext Storage of Sensitive Information — the .jsonl files are plaintext), and OWASP LLM02:2025 Sensitive Information Disclosure, which names security credentials explicitly among the categories at risk.
A related weakness is what makes the workaround unsafe too: CWE-214, Invocation of Process Using Visible Sensitive Information — "a process is invoked with sensitive command-line arguments, environment variables, or other elements that can be seen by other processes on the operating system." Today the model's only option is to interpolate the credential into a shell command, which is exactly that pattern. The design below deliberately avoids the command line for this reason.
Scale, measured independently. GitGuardian's State of Secrets Sprawl 2026 reports "28.65 million new hardcoded secrets were added to public GitHub commits in 2025 alone, a 34% increase year over year", and AI-service secrets "reached 1,275,105, up 81% year over year".
Two figures from that report bear directly on this issue.
Claude Code is named in the data. The report states: "Claude Code-assisted commits showed a 3.2% secret-leak rate, versus a 1.5% baseline across all public GitHub commits." Commits made with this product leak secrets at more than twice the rate of GitHub as a whole. Whatever the mix of causes, that is an external, quantified signal about secret hygiene around this specific tool.
Rotation does not happen. The same report finds that of credentials confirmed valid in 2022, "nearly 70%" were still valid in January 2025, and "the validity rate was still above 64%" in January 2026. Telling users to rotate after the fact is not a control — four years later, most of them have not. Protection has to work before the paste.
Proposed Solution
Why this is not a duplicate of the issues above
I am asking specifically that this not be auto-closed against #29910, which is what happened to #38797 — the one prior issue that asked for exactly this primitive — three days after it was filed.
- #29910 proposes secrets-management infrastructure: a committed
secrets.json, pluggable backends (Doppler, 1Password, AWS/GCP), aclaude-secrets run --wrapper, persistence, team sharing. That is a peer to Doppler and direnv, and it is a large, long-lived surface with third-party integrations to maintain. - #77084 is a synced, cross-device lockbox aimed at multi-machine and headless
claude -pworkflows. - #88165 is a persistent locker for repeated form-filling, driven by an accessibility need.
- #88380 is detection of credential-shaped text in a paste — a safety net for when the user has already decided to paste.
This request is none of those. It is the input primitive all four assume but none of them can be reduced to: a masked field whose value goes to the execution environment and never to the transcript or the model. Its nearest relative is AskUserQuestion, not a secret manager. Nothing is configured in advance, nothing persists past the session, nothing is synced or shared, and no new config file is introduced.
The decisive distinction is buildability. #29910, #77084 and #88165 can all be built outside the core — a store, provider integrations and a run -- wrapper work fine as an MCP server or a standalone CLI, and several commenters on #29910 have built exactly that. A safe input channel cannot be built outside the core: the masked field and the transcript belong to Claude Code. Anything an external component collects has to pass through the session to get back in.
That is the line that decides what belongs in the core. This primitive falls on the core side; the infrastructure proposals do not.
It is also the cheap half. #29910 is quarters of work and a permanent integration-maintenance burden. This is a tool, an interactive secret prompt of the kind the CLI already ships — claude mcp add --client-secret is documented in --help as "Prompt for OAuth client secret (or set MCP_CLIENT_SECRET env var)", i.e. the CLI already declines to take that value on the command line — and an environment injection at spawn time.
---
This follows Anthropic's own guidance
From Securely deploying AI agents:
Rather than giving an agent direct access to an API key, you could run a proxy outside the agent's environment that injects the key into requests. The agent can make API calls, but it never sees the credential itself.
And in the least-privilege table: "Credentials — inject via proxy rather than exposing directly."
The same document notes that "a developer running Claude Code on their laptop has different requirements than a company processing customer data" — and then offers that developer nothing. Envoy, containers and TLS-terminating proxies do not apply to a laptop. The only channel actually available there is the chat prompt: the exact opposite of the documented recommendation.
This request applies the same principle at laptop scale — the agent uses the credential but never sees it — and it removes one leg of the lethal trifecta the documentation links to. With the value outside the context window, a successful prompt injection cannot make the model disclose what it never had.
Prior art: a commenter on #29910 documents that Manus already ships this pattern — an inline input card, the value shown as •••• beside its key name in the conversation, never transiting the model. The interaction is validated in production by a competing agent.
---
Proposed solution
Option 1 — preferred: memory only
AskUserSecret({ name, purpose }). The model calls it instead of asking in chat. The tool description carries the rule: never request a secret in the conversation, call this instead. (This part matters — without it the tool is dead weight, since the failure mode today is the model politely asking for a paste.)- A deliberately conspicuous panel. Same shape as
AskUserQuestion, but visually distinct, because handing over a credential should read as a notable event rather than a routine question. Input masked, terminal echo off. - The value is held in process memory (
Buffer) for the session. It never reaches the transcript, the prompt history, the paste-cache, or the model's context. - The model receives metadata only:
``json``
{ "name": "GITHUB_TOKEN", "stored": true, "length": 40, "fingerprint": "a3f1c8e2" }
Name, length and fingerprint are enough to tell secrets apart and confirm the right one is loaded.
- Injection at spawn time. The Bash tool declares
uses: ["GITHUB_TOKEN"]; Claude Code puts the value into the child process environment as it spawns. The transcript records the script text with the variable name. The secret is never interpolated into a command string — that would put it in the transcript before execution. - Zeroed at session end. The secret does not survive a restart. That is part of the protection, not a limitation.
╭─ 🔑 Claude is requesting a secret ─────────────────────────╮
│ │
│ Name: GITHUB_TOKEN │
│ Purpose: call api.github.com on your behalf │
│ Storage: this session only, wiped on exit │
│ │
│ Value: •••••••••••••••••••••••• │
│ │
│ This value will not appear in the conversation. │
│ │
│ [Enter] provide [Esc] decline │
╰────────────────────────────────────────────────────────────╯
Declining is a first-class outcome, not an error. The user is entitled to answer "you should not have asked — I will handle this myself":
[1] I will not share it — I will handle this myself
[2] This task does not need a secret
[3] I will use another approach (free text)
The model receives { "provided": false, "reason": "..." } and continues without it. The tool description should forbid re-asking after a decline, or the feature degrades into nagging.
Every step leaves a line in the feed, the way WebSearch does — audit by residue, at no extra cost:
● Secret received · GITHUB_TOKEN · 40 chars · fp a3f1c8e2 · session memory
● Secret used · GITHUB_TOKEN → bash (check.sh)
● Secrets wiped · 2 · session ended
Input requirements: echo disabled at the readline level; multi-line paste must work, since PEM keys and kubeconfigs are multi-line, so Enter inside a value must not submit; bracketed paste supported; Ctrl+C clears the buffer; the value is written to neither the prompt history nor the paste-cache.
Option 2 — adds session persistence
Option 1 plus an encrypted file under ~/.claude/session-secrets/<session-id>/ (mode 0600, AEAD, session key in the OS keychain, orphan sweep at startup) so secrets survive --resume.
One correctness note, since it is easy to get wrong: the guarantee comes from destroying the key, not from overwriting the file. On APFS, journaled ext4, any copy-on-write filesystem, or an SSD with wear leveling, overwriting in place does not destroy the original blocks — which is why man shred warns about journaling filesystems. Overwriting is hygiene; crypto-shredding is the guarantee.
Option 3 — minimum
Masked input passed to a single command, no storage between calls. Cheapest to build, still covers the most common case.
---
Security considerations
Non-goals, stated plainly — a feature that overpromises here would be worse than none:
- Not a secrets manager: nothing persists, nothing is managed, nothing is synced or shared.
- Does not protect against local malware running as the same user.
- Does not cover the return path: if a command prints the secret itself (
set -x,env,curl -v), it re-enters the context. That is the separate, already-filed problem (#80153, #88380, #71654). - Does not remove the need to rotate already-leaked secrets.
- Not for headless/CI;
-psessions should keep using environment variables. - Does not expand the model's authority. The value goes to the execution environment, not to the model. This is a change of channel, not a grant of new permissions — worth stating because adjacent requests (#88165) do ask for the broader capability of filling payment fields, and this proposal deliberately does not.
Risks worth naming:
- Social engineering. A tool the model uses to request secrets is also a phishing surface: a prompt injection could make the model open a trusted-looking panel. Mitigations: the value is never returned to the model under any outcome, so an injection learns only that a secret exists; the panel names the secret, its purpose and where it will be used; declining is one keystroke; every use is visible in the feed. The comparison is not against a perfect world but against today, where the same injection makes the model ask for the key in chat — and it leaks both to the attacker and into three on-disk stores. The feature strictly reduces the damage of that scenario rather than creating a new one.
- Behavioural side effect. Making the safe path easy may increase how often users hand over secrets at all. That trade is worth taking: those handovers are already happening, invisibly and permanently. Afterwards each one is explicit, visible in the feed, scoped to a session, and gone at exit.
- No new storage. Under Option 1 Claude Code stores nothing. The value lives as an environment variable of a child process, exactly as from a hand-typed
export. No new compliance surface. - Process environment is readable by the same user through
/proc/<pid>/environorps— this is CWE-214, and I am naming the residual weakness of my own proposal rather than leaving it for review to find. It is not a regression: it is true of anyexport, and strictly better than the status quo, where the credential goes on the command line and into the transcript. A stricter variant passes the value over a file descriptor.
Worth noting that the relevant guard already exists: after Microsoft Threat Intelligence showed the Read tool could reach /proc/self/environ in the GitHub Action, v2.1.128 hardened Read to unconditionally reject files under /proc/. Environment-based injection therefore builds on a boundary this product has already established, rather than requiring a new one.
- Memory is not absolute. Node.js does not guarantee erasure of strings, and swap or core dumps may capture the value. Hold it in a
Bufferand zero it explicitly. - Surface consistency. The tool is interactive by nature. IDE, web and Remote Control sessions should degrade with an explicit message rather than silently — note that Remote Control transcripts are stored server-side for cross-device sync, so the tool should be limited to local interactive sessions.
Alternative Solutions
op run,direnv,aws-vault, plain environment variables. All require setup ahead of time, and none of them exist at the moment of need, mid-session — which is exactly when the paste happens. This request neither replaces nor conflicts with them.- An MCP server. Cannot render a masked field in the TUI, and anything it collects still has to pass through the session to reach the model's tools.
- Hooks. I checked the hook reference rather than assuming.
PreToolUsecan rewrite a tool's arguments viaupdatedInput, so a hook can rewrite a command — but whatever it writes there becomes the tool input and is recorded, so the secret still lands in the transcript.PostToolUsehas noupdatedOutputcounterpart.SessionStartreturns onlyadditionalContext,initialUserMessage,sessionTitle,watchPathsandreloadSkills— nothing that sets environment variables. No hook event exposes any field that injects an environment variable into a Bash call. Several commenters on #29910 have built hook-based guards; the author of one of the most detailed replies there describes the approach as a hack he does not want, and notes that blocking every path by which an agent could read a secret is not realistic when it can run arbitrary shell commands. envinsettings.json. This is the one existing way to get a value into the Bash environment, and it is unsuitable for secrets on two counts: the value sits in a plaintext config file (CWE-312), and #71432 — closed as completed — established that these variables leak across trust boundaries into the Bash tool context in ways users do not expect. A per-session, in-memory channel is the opposite of a plaintext config entry.- Redacting secret-shaped strings. Helps the outbound path only, and cannot help before the value is typed. It is also the approach already tried on the
/feedbackupload path, which is why that path redacts and the transcript does not. - Instructions in
CLAUDE.md. Tried, industry-wide. #82796 is a report of the model violating exactly such an instruction; 64% non-revocation is the measured result of relying on user discipline.
Priority
Critical - Blocking my work
Feature Category
CLI commands and flags
Use Case Example
Today. Claude needs a GitHub token to check a workflow run and asks for it in chat. The user pastes a 40-character PAT. It is now in the transcript, in history.jsonl and in paste-cache/, and it is re-sent as context on every following turn. If the session later hits a bug and the user runs /bug, the token enters five-year retention and possibly a public issue. Per #78344, the user may then find the classifier blocking every way to actually use it. Per the industry number, the token is probably never rotated.
With this feature. Claude calls AskUserSecret({ name: "GITHUB_TOKEN", purpose: "call api.github.com on your behalf" }). A masked panel appears; the user pastes the token and sees only dots. The feed shows ● Secret received · GITHUB_TOKEN · 40 chars · fp a3f1c8e2 · session memory. Claude writes check.sh referencing $GITHUB_TOKEN, runs it with uses: ["GITHUB_TOKEN"], and reads the result. The transcript holds the script text and the variable name. The token exists only in one process's memory and is zeroed at exit. A later /bug carries nothing sensitive.
Additional Context
How these claims were verified
Every factual claim here is checkable, and I would rather state the method than ask for trust:
- The issue map — queried through the GitHub API on 2026-08-28: state,
stateReason, labels and creation dates for each issue cited. The distinction between "closed as duplicate", "closed as not planned" and "closed as completed" comes fromstateReason, not from reading titles. - The three on-disk locations — reproduced locally on Claude Code 2.1.247 (macOS) with the
greprecipe above, which anyone can run in under a minute. - Retention and storage figures — from the current Data usage and Security documentation, not from memory.
- Hook capabilities — read directly from the hooks reference rather than assumed; the field lists quoted under Alternatives are the documented ones.
- The existing masked prompt — confirmed from
claude mcp add --helpon the installed CLI. - Industry figures — quoted verbatim from GitGuardian's State of Secrets Sprawl 2026, including the Claude Code-specific leak rate.
- The
/prochardening — from Microsoft Security's published analysis of the Claude Code GitHub Action and Anthropic's v2.1.128 mitigation.
If any of these is wrong I would genuinely like to know, and will correct it.
Priority
High. 18 open issues over six months with nothing shipped, three of them filed in the last week; #71654 documents live credentials already burned; and #78344 shows users blocked from completing legitimate work after paying the leak cost.
Feature category
Interactive mode (TUI), with a security boundary.
---
Note on the reporting channel
This is a feature request about a missing mechanism, not a vulnerability report; nothing here describes an exploitable flaw in existing code, so per SECURITY.md it does not belong on HackerOne. I work in information security and am glad to help refine the threat model, review a design, or test an implementation.
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Disclosure up front: I work on Forkbench, a macOS app that ships roughly what option 3 describes. I'm not proposing it as a substitute for this — the argument that the core has to own the input channel is right, and a third-party tool can't close it. But we've had this shape in production for a few months and two findings seem worth adding.
1. The environment is not a safe destination for the value. The post rules out the command line on CWE-214, but
environhas the same property: it is readable by any process running as the same uid for the lifetime of the child, and it is inherited by every grandchild that child spawns. A credential helper, acurlinside a wrapper script, a runtime that shells out — they all inherit it, and none of them are in the security decision the user made at the prompt.What we landed on instead is a read-once file descriptor. The value is written to a file that is unlinked before the write, and the child gets
/dev/fd/Nin the variable rather than the secret. There is no path for another process to open, and tools that already take a credential as a file path work unchanged —PGPASSFILE,GOOGLE_APPLICATION_CREDENTIALS,KUBECONFIG,curl -K,git config --file,openssl -in,ssh-keygen -f. It's a small change to the proposal and it removes the same-uid reader class entirely. Where a tool genuinely only reads a variable, we kept an explicit opt-in mode that hands the value over and says so at the point of use, because the alternative is silently downgrading and not telling anyone.2. Keeping the value out of the transcript bounds the disclosure, not the authority. Once the child holds the value it can send it anywhere, and in practice the failure isn't the model printing a key — it's a command sending a live key to the wrong host, through a misconfigured endpoint or an instruction the agent picked up from a page it was asked to read. What made that finite for us was binding the credential to its destination at creation and enforcing it at the network edge: the process gets a stand-in, the real value is substituted only for that one destination, and Forkbench restricts which hosts a key may reach, and blocks and logs the rest. That's a different primitive from
AskUserSecret()and probably out of scope here, but it's why "never in the transcript" turned out not to be sufficient on its own.Two limits, so this isn't oversold. Some credentials have to be handed over intact — a database URL, a service-account JSON a tool opens as a file — and no descriptor trick changes that: a file the tool can open is a file the agent can open. And destination binding is containment for accidents and evidence after the fact, not control of the payload; a credential inside a request body to the host it belongs to is an allowed request.
Happy to write up either mechanism in more detail if it's useful for the design here. The architecture we ended up with is documented at https://forkbench.com/security.
@forkbench-app Thanks for this — it's the most concrete response the thread has had, and the disclosure up front is appreciated. Rather than argue from memory I built the mechanism you describe and measured it on two kernels. Two of your points are genuine additions and I'm folding them in. The descriptor itself holds on macOS and doesn't survive the port, and testing why led me to a better carrier for the stricter variant than the one currently in the proposal — an argument I'd credit to your comment.
What the comment gets right
Descendant inheritance is a real gap in my write-up. The security section names
/proc/<pid>/environandps, but never says the value is inherited by every grandchild the command spawns. That belongs in the proposal, and it is adopted below.Your implementation is not what's in question. Forkbench is macOS-only, and on macOS the mechanism does exactly what you say — measurements 1 and 2 confirm both properties. Everything below is about what happens when that design is lifted into a tool that also ships on Linux and Windows. That's a different question from whether your product works, and I don't want the two conflated.
Method
Linux 7.0 (x86_64, Yama
ptrace_scope=1) and macOS 26.3 (arm64). The payload is a marker string, never a live credential. "Sibling" means a process that is neither ancestor nor descendant of the holder, running as the same uid — the weakest possible attacker in your model, since it has no inherited state at all.| # | Measurement | Linux | macOS |
|---|---|---|---|
| 1 |
cat /dev/fd/Ntwice, unlinked file |[secret],[secret]— not read-once |[secret],[]— read-once holds || 2 |
stat("/dev/fd/N"), unlinked file | regular,0600| regular,0600|| 3 |
stat("/dev/fd/N"), pipe | — | fifo,0440|| 4 | 5 tools, unlinked file | ssh-keygen ✅ openssl ✅ git config ✅ curl -K ✅ PGPASSFILE ✅ | — |
| 5 | 5 tools, pipe | …✅✅✅✅ PGPASSFILE ❌ | — |
| 6 | great-grandchild (
sh→sh→sh) reads/dev/fd/N| reads the secret (both variants) | — || 7 | sibling reads
/proc/<pid>/environ| reads the secret (mode0400) | n/a — no/proc|| 8 | sibling opens
/proc/<pid>/fd/<N>afterunlink| reads the secret | n/a — no/proc|| 9 | the agent's own shell tool reads another process's unlinked fd | reads the secret; it lands in the transcript | n/a — no
/proc|| 10 | sibling drains a child's stdin pipe via
/proc/<pid>/fd/0| reads the secret | n/a — no/proc|The three claims, one at a time
"There is no path for another process to open"
This is the load-bearing claim, and it's true on macOS: there is no
/proc, and/dev/fdis a realfdescfsmount, so a descriptor is reachable only from inside the process holding it.On Linux
/dev/fdis a symlink to/proc/self/fd./proc/<pid>/fd/<N>is a path, any same-uid process may open it, and it still resolves after the file is unlinked (m8):(deleted)is the kernel confirming the name is gone. It serves the inode anyway. So against the adversary the mechanism exists to stop, m7 and m8 are the same result reached by two different paths, and the unlinking buys nothing.Measurement 9 is where this stops being academic. I started a process holding a secret in an unlinked file, then had Claude Code itself go get it:
The
Readtool refused — but not because of a guard: it resolves the symlink, finds(deleted), and reports "File does not exist". TheBashtool walked straight through, and the value is now in this session'sprojects/*.jsonlpermanently, because tool output is transcribed.I want to be careful about what that does and doesn't show. It is not a flaw in Forkbench, which never runs on Linux. And it is not that the descriptor fails a bar the environment clears — the environment doesn't clear it either. The point is narrower: the descriptor was offered specifically because it "removes the same-uid reader class entirely". On Linux it does not remove that class. The single advantage that justified its cost is the one that doesn't survive the port.
"The descriptor is read-once"
Measurement 1 shows read-once isn't a property of unlinking. It comes from macOS opening
/dev/fd/Nwithdup()semantics, sharing the file offset, so the second reader gets EOF. Linux re-opens the inode at offset 0 and yields the secret again.This matters more than it first appears, because read-once is what excludes descendants — and descendants are the threat you named ("a
curlinside a wrapper script, a runtime that shells out"). Measurement 6 confirms a great-grandchild threeshlevels down reads the value fine. So on Linux the inheritance property you correctly identified inenvironapplies to the descriptor too, and the thing that would have fixed it is the part that didn't port."Tools that take a credential as a file path work unchanged"
True for the unlinked-file variant (m4): all five worked,
PGPASSFILEincluded. But recovering read-once on Linux means using a pipe, and then (m5):libpq ignores the file entirely — no password is sent at all.
statreports a fifo (m3) to every tool that validates its credential file. That class is not small: libpq requires a plain file, and ssh refuses a key file outright on a permissions check (WARNING: UNPROTECTED PRIVATE KEY FILE!).So Linux forces a choice macOS never presents: plain-file compatibility or read-once, never both.
On Windows — narrower than I first assumed
I checked this rather than assert it, and I was wrong about the reason. Node's docs do not say extra descriptors can't be inherited on Windows; the only stated limitation there is that passing sockets isn't supported. Descriptor inheritance isn't the blocker.
The blocker is the path form. The mechanism works by handing the tool a string —
PGPASSFILE=/dev/fd/3— and/dev/fd/Nis a POSIX path namespace that has no Windows equivalent. A child can inherit a handle; there is no filesystem path to name it with in an environment variable. That's a smaller claim than "it doesn't work on Windows", and it's the accurate one.A portable carrier for the same requirement
Your requirement is a channel that is never in
argv, never inenviron, and consumed once. I think that requirement is right. It just doesn't need a new primitive, because the ecosystem settled on one for this exact problem: stdin.Measured on the same box, unmodified tools:
| Tool | Invocation | Result |
|---|---|---|
| curl |
curl -K -| ✅ config read from stdin || openssl |
openssl rsa -passin stdin| ✅RSA key ok|| ssh-add |
ssh-add -| ✅Identity added: (stdin)|| docker |
docker login --password-stdin| ✅ documented flag || git |
git config --file /dev/stdin| ✅ parsed || git |
git credential fill| ✅ stdin is the protocol || libpq |
PGPASSFILE| ❌ still wants a real file |And the provenance is not incidental. Docker's own CLI explains why the flag exists:
That is CWE-214, raised against
argvfor credentials, and the ecosystem's answer a decade ago was a stream — not a descriptor path.git credentialwent further and made stdin the entire protocol.What stdin gives that the descriptor doesn't:
And one thing stdin does not give, which I should say before you find it. I assumed a pipe closed the same-uid hole, then measured it instead of trusting the assumption (m10). A sibling can open the child's
/proc/<pid>/fd/0and drain the pipe before the child reads it:So stdin doesn't remove the same-uid reader class either. On Linux none of the three carriers do — not
environ, not the descriptor, not a pipe. That axis is closed to all of them, which is why the proposal names same-uid readers a non-goal: not because it is convenient, but because no in-process channel on this platform can deliver it, and a feature implying otherwise would be overpromising.Which is the useful part. With that axis closed to everyone, the carriers can be chosen on the axes where they actually differ: transcript exposure (equal, and adequate in all three), portability (stdin), read-once (stdin everywhere; the descriptor only on macOS), ecosystem support (stdin), and who picks the channel — which is the environment, for the reason below.
It doesn't cover everything:
PGPASSFILEstill wants a real file, which is the same honest limit you named about credentials that must be handed over intact. But it degrades to the same place with no per-platform security semantics to document, review, or get wrong.If a stricter variant belongs in this proposal — and after m6 I agree one does — I'd rather it be this, and the argument for it is yours.
What the environment path actually buys
The claim everything rests on, shown rather than asserted. Two Claude Code transcripts totalling several megabytes, grepped for the value of an environment variable present in every spawned process but never typed into a command:
Bash invocations serialize as
{"name":"Bash","input":{"command":"..."}}— the command string and nothing else. The environment reaches neither the session log, the prompt history, nor the paste cache.This is also why
argvis disqualified in the proposal whileenvironis not — a distinction I left to a CWE reference when I should have spelled it out.argvis not ruled out because ofps. It is ruled out because the model has to author the string containing the secret.curl -H "Authorization: Bearer sk-..."only exists if the value passed through the context, and it is written to the transcript before the command ever runs. A variable set by the harness at spawn is never authored by the model and never serialized. On the axis this proposal targets, the two are not the same primitive at all.Who makes the security decision
One architectural consequence, and it's the part that argues hardest against making the descriptor the default.
With
secret execplus an opt-in--env, something must decide — per tool, per invocation — whether this command can take a path or needs the downgrade. In an agent that decision falls to the model, because the model writes the command. It places a security choice inside the exact component the proposal exists to keep the secret away from, and it is made under prompt-injection risk, from a page the agent was asked to read. "Use--env, this tool can't take a path" is a sentence an injection can talk a model into.The environment path has no such decision to capture. The Bash tool declares
uses: ["GITHUB_TOKEN"], the harness injects at spawn, and the model never selects a channel because there is only one. The argument isn't fewer moving parts — it's fewer decisions delegated to the model.On the threat model
I think this is the real difference between us, and naming it dissolves most of the disagreement.
The proposal's non-goals already say it "does not protect against local malware running as the same user." That isn't an oversight; it's a scoping decision, and measurements 8 and 10 are why it has to be — on Linux every carrier leaks to that reader, so no wording of the feature could honestly promise otherwise. On the machine I tested there are 10 credential containers in
$HOMEat mode0600—~/.ssh/id_ed25519,~/.pgpass, service tokens under~/.config/,~/.ansible/galaxy_token,~/.config/gcloud/access_tokens.db./proc/<pid>/environis mode0400. Same owner, same protection class, same adversary — and those keys are on disk before the session starts. A process that can read one can read all of them without waiting for the agent to run anything.There's a structural point underneath. Claude Code runs as the user, holding a file-read tool and a shell tool; measurement 9 is that fact in action. It is a same-uid reader by construction. A channel that is same-uid-proof cannot be built into a process that is itself same-uid — the boundary would have to exclude the program you're building the channel for.
What is in scope is narrower, and it's the thing that actually needs a convention: the value must never enter the transcript pipeline — the three on-disk stores (
projects/*.jsonl,history.jsonl,paste-cache/), the model's context, the provider side where prompts may be retained or trained on, and anything a third-party hook or skill can passively harvest across sessions. Measurement 9 is a small live demonstration of that pipeline swallowing a value that was never supposed to reach it.What I'm adopting
Per-spawn scoping. Your inheritance point survives the narrowing above, because passive collection by third-party hooks and skills is squarely in scope. It yields a one-line constraint that costs nothing and is identical on all three platforms:
If it lands in Claude Code's own
process.env, every hook, skill and MCP server the harness spawns inherits it. That's exactly the accumulation risk worth closing, and it closes without a new primitive.The stricter variant becomes stdin. The proposal currently says "a stricter variant passes the value over a file descriptor" — too vague to implement, and per m1 and m8 it means different things on different kernels. Stdin is the same requirement carried by something that ports, with the environment path explicit and named at the point of use. Which is the same honesty your
--envflag represents, and I think that framing is the right one.What stays out of scope
Destination binding I'd keep out of this issue, and I don't think we disagree — you said as much yourself. Binding a credential to its destination is egress control; it's real, it's the harder half, and your own limits paragraph is accurate about what it does and doesn't contain. But this request has gone six months and 18 issues without a decision largely because the ask kept growing. It deserves its own thread and I'd support it there.
Reproduction
Measurement 8, the decisive one:
Measurement 1 is the same setup, reading
/dev/fd/Ntwice from the child. I'd genuinely like a second data point on that one from a macOS release other than 26.3, since the whole read-once property rests on thatdup()semantic — if it ever changes, the guarantee changes with it and nothing in the API would say so.Filed the other half of this gap as #95680 — same argument, other direction: secrets that enter via the model's own tool calls (
tool_useargs andtool_resultstdout, e.g.gcloud secrets versions access/aws secretsmanager get-secret-value/az keyvault secret show) are persisted verbatim to five distinct stores under~/.claude, with no redaction at the writer and no scan/scrub command. Worth adding as row 19 to the map above.