{"slug": "how-to-stop-a-claude-code-agent-writing-outside-a-directory", "title": "How to stop a Claude Code agent writing outside a directory", "summary": "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.", "body_md": "When you're sitting in front of an agent, \"don't touch anything outside `src/`\n\n\" is enforced by you noticing. Unattended, it has to be enforced by something that runs whether or not anyone is watching.\n\nClaude 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.\n\n`permissions.deny`\n\nisn't enough\nPermission rules live in `settings.json`\n\nand take the form `Tool(specifier)`\n\n:\n\n```\n{\n  \"permissions\": {\n    \"deny\": [\n      \"Read(./.env)\",\n      \"Read(./.env.*)\",\n      \"Write(./.github/**)\",\n      \"Write(//etc/**)\"\n    ]\n  }\n}\n```\n\nPaths are gitignore-style. A leading `//`\n\nmeans absolute, `~`\n\nmeans home, and anything else is relative to the settings file. `deny`\n\nbeats `ask`\n\n, which beats `allow`\n\n, and rules merge across scopes rather than override — so a `deny`\n\nin project settings still applies even when your personal `~/.claude/settings.json`\n\nallows the same thing. That precedence is the useful part: a deny rule is hard to undo by accident.\n\nThe problem is shape. What you want for an unattended agent is an allow-list — *only* these directories, nothing else. What `deny`\n\ngives 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(**)`\n\nin `deny`\n\noutranks every `allow`\n\nyou pair it with, so the agent can write nothing at all.\n\nClaude Code does have one allow-list-shaped boundary — the project root, plus whatever you list in `additionalDirectories`\n\n. That stops an agent wandering into `/etc`\n\n. 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`\n\n. It's that the agent tasked with writing articles decides to fix its own scheduling config.\n\nSo for anything finer than \"stay in the project\", you need code.\n\nA `PreToolUse`\n\nhook 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.\n\nRegister it against the tools that write files:\n\n```\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Write|Edit|MultiEdit|NotebookEdit\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"node ${CLAUDE_PROJECT_DIR}/.claude/hooks/deny-outside-scope.mjs\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nThe payload arriving on stdin carries `tool_name`\n\n, `tool_input`\n\n, and `cwd`\n\n. For `Write`\n\nand `Edit`\n\n, `tool_input.file_path`\n\nis the file about to be touched. For `Bash`\n\n, there's `tool_input.command`\n\nand no path at all — remember that, it matters later.\n\nYou reply by exiting 0 and printing JSON on stdout:\n\n```\n{\n  \"hookSpecificOutput\": {\n    \"hookEventName\": \"PreToolUse\",\n    \"permissionDecision\": \"deny\",\n    \"permissionDecisionReason\": \"…\"\n  }\n}\n```\n\n`permissionDecision`\n\nis `allow`\n\n, `deny`\n\n, `ask`\n\n, or `defer`\n\n. `defer`\n\nmeans \"no opinion, carry on with the normal permission flow\", and it's the right default for a guard: a hook that returns `allow`\n\nis overriding the user's own permission rules, which is not a scope guard's job. The `permissionDecisionReason`\n\ngoes to the model, so it's worth writing as an instruction rather than an error code.\n\nThere'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.\n\nThe whole guard:\n\n``` js\nimport { isAbsolute, relative, resolve } from \"node:path\";\n\nconst FILE_WRITING_TOOLS = new Set([\"Write\", \"Edit\", \"MultiEdit\", \"NotebookEdit\"]);\n\nfunction contains(root, target) {\n  const rel = relative(resolve(root), resolve(target));\n  return rel === \"\" || (!rel.startsWith(\"..\") && !isAbsolute(rel));\n}\n\nexport function decideWrite(payload, { allow, root }) {\n  if (!FILE_WRITING_TOOLS.has(payload?.tool_name)) return { decision: \"defer\" };\n\n  const filePath = payload?.tool_input?.file_path;\n  if (typeof filePath !== \"string\" || filePath === \"\") return { decision: \"defer\" };\n\n  const base = root ?? payload?.cwd ?? process.cwd();\n  const target = isAbsolute(filePath) ? filePath : resolve(base, filePath);\n  const roots = allow.map((entry) => (isAbsolute(entry) ? entry : resolve(base, entry)));\n\n  if (roots.some((allowed) => contains(allowed, target))) return { decision: \"defer\" };\n\n  return {\n    decision: \"deny\",\n    reason:\n      `${payload.tool_name} to ${filePath} is outside this agent's write scope. ` +\n      `Allowed: ${allow.join(\", \")}. If this file genuinely needs changing, ` +\n      `say so and stop — do not work around the guard.`,\n  };\n}\n```\n\nThree things in there are load-bearing.\n\n`contains`\n\nuses `path.relative`\n\n, not a string prefix.`\"/app/src-secret\".startsWith(\"/app/src\")`\n\nis `true`\n\n, and that is how an allow-list quietly stops being one. Resolving both sides and asking whether the relative path escapes with `..`\n\nis the version that survives sibling directories, `./`\n\nnoise, and traversal in the incoming path.\n\n**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.\n\n**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.\n\nWrap it in a script that reads stdin and never throws:\n\n```\nexport async function runHook({ allow, stdin = process.stdin, stdout = process.stdout }) {\n  let payload;\n  try {\n    const chunks = [];\n    for await (const chunk of stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));\n    payload = JSON.parse(Buffer.concat(chunks).toString(\"utf8\"));\n  } catch {\n    return 0;   // fail open — see below\n  }\n  const verdict = decideWrite(payload, { allow });\n  if (verdict.decision === \"deny\") stdout.write(JSON.stringify({\n    hookSpecificOutput: {\n      hookEventName: \"PreToolUse\",\n      permissionDecision: \"deny\",\n      permissionDecisionReason: verdict.reason,\n    },\n  }));\n  return 0;\n}\n```\n\nFailing 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.\n\nThe best thing about this being a plain script is that you can test it without an agent anywhere near it:\n\n```\necho '{\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"/etc/hosts\"},\"cwd\":\"'\"$PWD\"'\"}' | node .claude/hooks/deny-outside-scope.mjs\n```\n\nOutput 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.\n\nA `PreToolUse`\n\nhook fires on tool calls. That's the boundary, and it leaks in more places than it first appears:\n\n`Bash`\n\ngets a command string, not a path. `sed -i`\n\n, `> file`\n\n, `cp`\n\n, `mv`\n\n, `git checkout`\n\n, `npm run build`\n\n— a hook matched on `Write|Edit`\n\nnever fires, and one matched on `Bash`\n\nwould 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:\n\n``` js\nimport { execFileSync } from \"node:child_process\";\n\nconst changed = execFileSync(\"git\", [\"status\", \"--porcelain=v1\"], { encoding: \"utf8\" })\n  .split(\"\\n\")\n  .filter((line) => line.length > 3)\n  .map((line) => line.slice(3).trim());   // slice BEFORE trim: ` M path` has a leading space\n\nconst violations = changed.filter((p) => !ALLOWED.some((prefix) => p.startsWith(prefix)));\n```\n\nIt doesn't care what produced the change. Shell redirect, subagent, build step — if it landed in the tree, it's in `git status`\n\n, and this catches it.\n\nThe 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.\n\nIt 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.\n\nWhat 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.\n\n*Originally published at fewparts.co.uk.*\n\n*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.*", "url": "https://wpnews.pro/news/how-to-stop-a-claude-code-agent-writing-outside-a-directory", "canonical_source": "https://dev.to/fewparts/how-to-stop-a-claude-code-agent-writing-outside-a-directory-253p", "published_at": "2026-08-09 21:48:31+00:00", "updated_at": "2026-08-09 22:16:14.220831+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-safety"], "entities": ["Claude Code", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/how-to-stop-a-claude-code-agent-writing-outside-a-directory", "markdown": "https://wpnews.pro/news/how-to-stop-a-claude-code-agent-writing-outside-a-directory.md", "text": "https://wpnews.pro/news/how-to-stop-a-claude-code-agent-writing-outside-a-directory.txt", "jsonld": "https://wpnews.pro/news/how-to-stop-a-claude-code-agent-writing-outside-a-directory.jsonld"}}