A coding agent with shell access inherits every secret your shell can reach. Not through a vulnerability. By design, because that is what makes it able to run your tests and deploy your branch.
The leak, when it happens, is made of two ordinary actions:
- It reads a credential file. Reading files is the job.
- It makes an outbound request. Making requests is also the job.
Neither half is suspicious. The breach is the sequence, and nothing in a per-action approval model can see a sequence.
What "reachable" means, concretely
Before defending anything, it is worth knowing what is actually exposed. On a typical developer machine an agent with your shell can reach, without one unusual command:
| Where | What is in it |
|---|---|
~/.aws/credentials | long-lived cloud keys, often with broad IAM |
~/.ssh/id_* | the key that authenticates you to every host |
~/.config/gh/hosts.yml | a GitHub token, often with repo and workflow |
~/.npmrc, ~/.pypirc | publish tokens for your packages |
.env in any project you opened | database URLs with the password inline |
~/.kube/config | cluster admin, frequently |
None of these need privilege escalation. They are files in your home directory and the agent runs as you.
The four ways it gets out
Straight read and post. cat a credential, curl it somewhere. The crude version, and the one every tool catches.
Through an approved destination. The same secret, sent to a host already on your allowlist. A POST to your own GitHub repository carrying .env in the body passes a domain allowlist cleanly, because the destination was never the problem.
Encoded or split. base64 prints the same bytes as cat. A value split across two requests reassembles at the other end. Anything that matches on the literal text of a command loses here, because the attacker writes the text.
Quietly, inside something legitimate. A secret pasted into a commit message, a bug report the agent files for you, or a stack trace it uploads for help. No exfiltration tool was involved, and the value is gone all the same.
What each layer of defence actually covers
The important thing is that these are not alternatives. Each one is blind to what the others see.
| Layer | Stops | Blind to |
|---|---|---|
| Permission prompts | actions you did not approve | anything you did approve |
| Command allowlists | forbidden verbs | the file an allowed verb opens |
| Egress / domain allowlists | unknown destinations | the payload, and any allowed host |
| Content scanning (DLP) | recognised secret shapes in what leaves | a secret with no recognisable shape |
| Credential jail | reads of known credential paths | a secret that lives somewhere unusual |
Two conclusions follow. A domain allowlist alone is not secret protection, because it has no opinion about a request body. And content scanning alone is not either, because it can only match shapes somebody thought to describe.
The defence that holds up
Stop the read, not just the send. Once a credential is in the agent's context it has already been sent to a model provider, and every later control is damage limitation.
That means a rule about files, evaluated on the structure of the command rather than its text. It has to survive a different verb, a wrapper, and a different tool reaching the same path.
This is what node9 does at the point the agent acts, and these are real verdicts from the live gate, with a control so the result means something:
cat README.md → ✅ ALLOW control: an ordinary file
cat ~/.ssh/id_rsa → 🛑 BLOCK project-jail, resolved from the AST
base64 ~/.ssh/id_rsa → 🛑 BLOCK different verb, same file, same answer
Read{file_path: ~/.ssh/id_rsa} → 🛑 BLOCK the agent's own file tool, same rule
The last line is the one people miss. A credential can be opened through the shell or through the agent's built-in Read, Grep, and Glob tools. Those are two separate code paths inside any gate, and a rule wired to one of them leaves the other open.
Alongside that, two things run without configuration:
- Fifty-eight named provider credential shapes are matched in every tool argument, so a key pasted into a command is refused whether or not its file was jailed.
- The cloud metadata address is permanently unreachable. Verified on both doors: a shell
curlto169.254.169.254and aWebFetchto the same address are both refused, and the refusal says the address cannot be allowlisted at all. That address is how a stolen role becomes stolen cloud credentials.
Where this stops
- Egress gates destinations, not payloads. Deciding which hosts an agent may reach is a different control from deciding what may be in the request. You want both.
- A secret with no shape is not detectable as a secret. An internal token that looks like a random word is a string until something tells the scanner otherwise.
- Commands that build a path at runtime are read less reliably than a literal one. Structural parsing is much harder to fool than text matching, and it is not a proof.
- Attachments bypass tool gating entirely. In Claude Code, a file attached with
@never becomes a tool call, so no hook sees it, in any product of this kind.
Check your own machine
npx node9-ai posture # what is reachable here, about 60 seconds, no install
npx node9-ai scan # what your agents already did
Then rotate whatever those two turn up before you harden anything. A credential an agent has already read is a credential that has already left the building.