Unwait

Claude Code security, with the right threat model

· 8 min read claude code security permissions sandboxing prompt injection

The risk is not that the model turns on you. It is that an agent reads attacker-controlled text all day and runs commands you approved in a hurry. The permission layers that actually hold, how to configure the sandbox, why only hooks enforce anything, and the attack surface in the tooling you build around the agent.

Most worry about agent security is aimed at the wrong thing. "Will the model decide to delete my repo" is not the interesting risk. Two things are:

  1. The agent reads attacker-controlled text all day. Repository contents you cloned, dependency source, issue text, a web page it fetched, the output of a tool. Any of it can contain instructions aimed at the agent rather than at you.
  2. You approve commands in a hurry. Every permission system eventually meets a human who has answered forty prompts today and stops reading the forty-first.

Everything useful about hardening Claude Code follows from those two facts. Here is what the tool gives you, what it does not, and where the real holes are, including one in tooling we built ourselves.

Layer 1: the permission model

In Manual mode (config value default), Claude Code starts read-only. It runs a built-in set of read-only commands like ls, cat, and git status without asking, and prompts before anything that modifies your system. Three properties of that mode are worth knowing because they are the ones doing quiet work:

Auto mode replaces you with a classifier model that reviews actions and blocks what it judges unsafe. On Pro, Max, and Team plans it is the starting mode. Your explicit ask and deny rules still apply on top, and an organization can turn it off. Plan mode is read-only research, with the caveat we covered in the plan mode post: commands still run during planning, and in a session with bypass permissions available its blocks are not enforced at all.

Two more defaults worth naming. Network commands like curl and wget are not auto-approved, so fetching from the web is a decision you make. And trust verification fires on a first-time codebase and on every new MCP server, though it is disabled under -p, and trust for a session started directly in your home directory is never written to disk.

What to actually configure: grow an allowlist from real prompts instead of turning permissions off, which is the practice we argued for earlier, and use permissions.deny for the handful of things that should never happen in this repo regardless of context. Deny rules are the part of the permission system that does not depend on anyone reading carefully at 5pm.

Layer 2: the sandbox

The permission system asks. The sandbox enforces, using the operating system, for every Bash command and its children. Run /sandbox to configure it.

It is built in on macOS via Seatbelt. On Linux and WSL2 it needs bubblewrap and socat installed, and the /sandbox panel tells you what is missing. Native Windows is not supported; run inside WSL2.

Two independent layers:

Filesystem isolation. Sandboxed commands write only to the working directory by default. sandbox.filesystem.denyRead, allowRead, and denyWrite shape the rest, and the precedence rule is the useful part: the more specific path wins, and a deny holds inside a wider allow. That means this does what you want:

{
  "sandbox": {
    "filesystem": {
      "allowRead": ["~/"],
      "denyRead": ["~/**/.env"]
    }
  }
}

Every .env under your home directory stays unreadable while the rest is readable, and a broad allow cannot silently re-expose the secret. credentials entries go further: deny blocks a file and unsets an environment variable before each sandboxed command, while mask shows the command a sentinel value and has the sandbox proxy swap in the real credential only on hosts you list in injectHosts. That is the difference between hiding a token from the model and hiding it from the process that needs to use it.

Network isolation. network.allowedDomains is an allowlist the proxy enforces for sandboxed commands, so a build can reach your registry and nothing else.

Auto-allow mode runs sandboxable commands without prompting, which is the honest trade: fewer prompts, a real boundary instead of a human reading them. Commands that cannot be sandboxed fall back to normal permissions.

One warning from the docs that deserves repeating, because it is the configuration people reach for first: turning filesystem isolation off (sandbox.filesystem.disabled) while auto-allowing commands means a sandboxed command can write your shell startup files, something on $PATH, or ~/.claude/settings.json, and use that to widen its own access on the next turn. Network isolation without filesystem isolation is a fence with a gate in it.

Layer 3: hooks, the only thing that actually enforces

This is the point people miss most often. CLAUDE.md is not a security control. It arrives as a user message, and Claude tries to follow it with no guarantee, as the memory post covers. If a rule must hold regardless of what the model decides, it is a hook.

A PreToolUse hook inspects a tool call before it runs and can block it. That is the enforcement layer for "never force-push", "never touch production config", "no rm -rf outside the repo". We published six hook configs worth stealing, and the shape is always the same: the model proposes, the shell script disposes. For teams, ConfigChange hooks let you audit or block settings changes made during a session, which closes the loop where a permission config is loosened mid-flight.

The rough division:

Concern Where it belongs
Style, conventions, "prefer X" CLAUDE.md
"Ask before doing X" Permission rules
"X can never happen" permissions.deny plus a PreToolUse hook
"This command can only touch these paths and hosts" Sandbox

Prompt injection, concretely

Built-in mitigations exist and are worth knowing: web fetch runs in an isolated context window so fetched content cannot easily inject into your main conversation, network commands are not auto-approved, subagent output is scanned before it reaches the parent so it cannot imitate system formatting, and complex bash commands come with natural-language explanations so approval is an informed act.

None of that makes the problem go away, and the practical rules are unglamorous: do not pipe untrusted content straight into the agent, review commands before approving rather than pattern-matching on the first word, and use a VM or dev container when the work involves external services or code you did not write. If you are reviewing a pull request from a stranger with an agent that can run commands, the repository is untrusted input, not just code.

The surface you build yourself

Here is the part that does not show up in security guides, learned by auditing our own design.

Unwait runs a small HTTP daemon on 127.0.0.1 that Claude Code hooks curl when a turn starts and ends. The first version had no authentication, because loopback is local and local felt safe. It is not: any web page your user visits can POST to their own 127.0.0.1, and a text/plain POST is a CORS simple request that skips preflight entirely. The browser blocks the attacker from reading the response, which does not matter, because the side effect already ran. The full writeup is here, including the fix (a 0600 token file the browser cannot read, rotated per launch) and two bugs we shipped anyway.

The one worth repeating in an agent-security context: our hook script appended the working directory to a URL, and a repository named proj&ppid=1234 could smuggle extra query parameters and override the real ones. Strings from the filesystem are attacker-influenced input, because people clone repositories they did not name. Any hook or integration you write is handed session ids, working directories, file paths, and tool output, all of it shaped by content the agent just read. Treat that payload the way you treat an HTTP request body.

Supply chain: MCP servers and plugins

Both run arbitrary code with your user privileges. Anthropic reviews connectors against listing criteria for its directory but does not security-audit MCP servers, and explicitly does not control what a third-party plugin contains. Adding a marketplace is adding a dependency, and installing a plugin is running someone's code on your machine, so the same standard applies: prefer things you or your organization wrote, pin what you can, and review what a plugin declares before installing (the /plugin detail pane lists every component it adds, as covered in the plugins post).

A checklist you can size to your risk

Solo, your own repos, familiar code: auto mode with a deny list for the destructive handful, a PreToolUse hook for anything truly irreversible, and credentials kept out of the environment you launch from.

Working on cloned or unfamiliar code: turn the sandbox on with filesystem and network isolation, deny reads of ~/**/.env and credential files, and keep an allowlist of the domains the build actually needs. Prefer a dev container or VM when the code will be executed.

Team: enforce the baseline through managed settings rather than asking people to configure it, share the permission config in version control, audit with ConfigChange hooks, and run /security-review on branches before merge. The security-guidance plugin reviews changes for common vulnerabilities in the same session that wrote them, which catches things earlier than a review gate.

Everyone: report suspicious behavior with /feedback, and vulnerabilities in Claude Code itself through Anthropic's HackerOne program rather than publicly.

The uncomfortable summary is that most of this is ordinary engineering hygiene applied to a new place. The agent is not the threat; it is a very fast, very literal contractor with your credentials, working from instructions it partly read off the internet. Build the fences accordingly, and remember that the tooling you attach to it is now part of your attack surface, which is the lesson our own daemon taught us the hard way.

Unwait does this for you

A macOS menu bar app that watches your Claude Code and Codex sessions, shows a short card while they work, and puts a strip on screen the moment one finishes. Free for two weeks, no card and no sign up.

Try for free
← All posts