How to stop a Claude Code agent writing outside a directory A developer detailed how to prevent Claude Code agents from writing outside designated directories using PreToolUse hooks, explaining that permission rules alone are insufficient for fine-grained control. The post provides code for a hook that denies file writes outside a specified scope, addressing a gap in Claude Code's built-in permissions. When you're sitting in front of an agent, "don't touch anything outside src/ " is enforced by you noticing. Unattended, it has to be enforced by something that runs whether or not anyone is watching. Claude Code gives you two mechanisms for that, and they are not interchangeable. One is declarative and can't express what you probably want. The other can, but is structurally blind to a whole category of writes. Here's what each one actually does, and the code for the second. permissions.deny isn't enough Permission rules live in settings.json and take the form Tool specifier : { "permissions": { "deny": "Read ./.env ", "Read ./.env. ", "Write ./.github/ ", "Write //etc/ " } } Paths are gitignore-style. A leading // means absolute, ~ means home, and anything else is relative to the settings file. deny beats ask , which beats allow , and rules merge across scopes rather than override — so a deny in project settings still applies even when your personal ~/.claude/settings.json allows the same thing. That precedence is the useful part: a deny rule is hard to undo by accident. The problem is shape. What you want for an unattended agent is an allow-list — only these directories, nothing else. What deny gives you is a block-list, and you cannot build the first out of the second. The obvious trick of denying everything and allowing back the exceptions fails on exactly the precedence rule that makes deny valuable: Write in deny outranks every allow you pair it with, so the agent can write nothing at all. Claude Code does have one allow-list-shaped boundary — the project root, plus whatever you list in additionalDirectories . That stops an agent wandering into /etc . It says nothing about which directories inside your project it may write, which is usually the interesting question. Nobody's real worry is that a scheduled agent edits /etc/hosts . It's that the agent tasked with writing articles decides to fix its own scheduling config. So for anything finer than "stay in the project", you need code. A PreToolUse hook is a command Claude Code runs before a tool call, handing it the pending call as JSON on stdin. The hook answers, and the answer is binding. Register it against the tools that write files: { "hooks": { "PreToolUse": { "matcher": "Write|Edit|MultiEdit|NotebookEdit", "hooks": { "type": "command", "command": "node ${CLAUDE PROJECT DIR}/.claude/hooks/deny-outside-scope.mjs" } } } } The payload arriving on stdin carries tool name , tool input , and cwd . For Write and Edit , tool input.file path is the file about to be touched. For Bash , there's tool input.command and no path at all — remember that, it matters later. You reply by exiting 0 and printing JSON on stdout: { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "…" } } permissionDecision is allow , deny , ask , or defer . defer means "no opinion, carry on with the normal permission flow", and it's the right default for a guard: a hook that returns allow is overriding the user's own permission rules, which is not a scope guard's job. The permissionDecisionReason goes to the model, so it's worth writing as an instruction rather than an error code. There's a second way to block — exit code 2, with the reason on stderr. It works, but you lose the structured field, and on exit 0 stderr goes only to the debug log where neither you nor the model will see it. Prefer the JSON. The whole guard: js import { isAbsolute, relative, resolve } from "node:path"; const FILE WRITING TOOLS = new Set "Write", "Edit", "MultiEdit", "NotebookEdit" ; function contains root, target { const rel = relative resolve root , resolve target ; return rel === "" || rel.startsWith ".." && isAbsolute rel ; } export function decideWrite payload, { allow, root } { if FILE WRITING TOOLS.has payload?.tool name return { decision: "defer" }; const filePath = payload?.tool input?.file path; if typeof filePath == "string" || filePath === "" return { decision: "defer" }; const base = root ?? payload?.cwd ?? process.cwd ; const target = isAbsolute filePath ? filePath : resolve base, filePath ; const roots = allow.map entry = isAbsolute entry ? entry : resolve base, entry ; if roots.some allowed = contains allowed, target return { decision: "defer" }; return { decision: "deny", reason: ${payload.tool name} to ${filePath} is outside this agent's write scope. + Allowed: ${allow.join ", " }. If this file genuinely needs changing, + say so and stop — do not work around the guard. , }; } Three things in there are load-bearing. contains uses path.relative , not a string prefix. "/app/src-secret".startsWith "/app/src" is true , and that is how an allow-list quietly stops being one. Resolving both sides and asking whether the relative path escapes with .. is the version that survives sibling directories, ./ noise, and traversal in the incoming path. Everything unrecognised defers rather than denies. A malformed payload isn't a permission decision. Deferring hands it back to the normal flow, which will fail on its own terms and tell you why. The reason is addressed to the model. "Denied" invites a retry through a different tool. Naming the allowed roots and saying explicitly not to work around the guard gives it somewhere to go that isn't a workaround. Wrap it in a script that reads stdin and never throws: export async function runHook { allow, stdin = process.stdin, stdout = process.stdout } { let payload; try { const chunks = ; for await const chunk of stdin chunks.push Buffer.isBuffer chunk ? chunk : Buffer.from chunk ; payload = JSON.parse Buffer.concat chunks .toString "utf8" ; } catch { return 0; // fail open — see below } const verdict = decideWrite payload, { allow } ; if verdict.decision === "deny" stdout.write JSON.stringify { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: verdict.reason, }, } ; return 0; } Failing open is a real decision and you should make it deliberately. A hook that throws on a bad payload blocks every write in the session the moment you typo the allow-list, and "the agent can't work" is a worse default failure than "one check didn't run" — provided something else catches what gets through. That's the next section. The best thing about this being a plain script is that you can test it without an agent anywhere near it: echo '{"tool name":"Write","tool input":{"file path":"/etc/hosts"},"cwd":"'"$PWD"'"}' | node .claude/hooks/deny-outside-scope.mjs Output means denied. Silence means allowed. Do that once before you trust it — a guard nobody has seen refuse anything is a guard you're assuming works. A PreToolUse hook fires on tool calls. That's the boundary, and it leaks in more places than it first appears: Bash gets a command string, not a path. sed -i , file , cp , mv , git checkout , npm run build — a hook matched on Write|Edit never fires, and one matched on Bash would have to parse arbitrary shell to find the writes. Don't try; you'll lose.Which is why the second half of this is a check on the result rather than the request. Before anything gets committed, diff the working tree against the same allow-list: js import { execFileSync } from "node:child process"; const changed = execFileSync "git", "status", "--porcelain=v1" , { encoding: "utf8" } .split "\n" .filter line = line.length 3 .map line = line.slice 3 .trim ; // slice BEFORE trim: M path has a leading space const violations = changed.filter p = ALLOWED.some prefix = p.startsWith prefix ; It doesn't care what produced the change. Shell redirect, subagent, build step — if it landed in the tree, it's in git status , and this catches it. The two guards are complements, and each covers the other's failure. The hook stops the write and gives the model a reason it can act on, but only for calls it was matched against. The diff sees everything but only after the fact. Run the hook so the mistake mostly doesn't happen; run the diff so you find out when it did anyway. It isn't a sandbox. Both guards live inside the agent's own harness — the hook is invoked by Claude Code, the diff by your own script. That's a good defence against mistakes and against instructions the agent picked up from a file it read. It is not a boundary against an attacker with shell access on the same machine, and treating it as one is how people end up surprised. If you need a real boundary, that's a container or an OS-level sandbox, and it's a different piece of work. What you get for these ~60 lines is narrower and still worth having: an unattended agent that can't quietly rewrite its own configuration, and a check at commit time that tells you when something got through anyway. Originally published at fewparts.co.uk. I write about running agents unattended, and sell the packaged version of this code — Agent Guardrails Kit, £22.00. Saying so up front because you'd work it out in one click anyway.