Fence an agent into one directory — and mean it intermediate
An agent should edit data but never code, and you want that enforced rather than politely requested in CLAUDE.md.
-
Write a
PreToolUsehook. It receives the tool call as JSON on stdin, and exit code 2 blocks it — stderr goes back to Claude as the reason, so it can correct course rather than just fail..claude/hooks/data-only.mjsimport path from "node:path"; let raw = ""; process.stdin.on("data", (c) => (raw += c)); process.stdin.on("end", () => { const file = JSON.parse(raw)?.tool_input?.file_path; if (!file) process.exit(0); const root = process.env.CLAUDE_PROJECT_DIR ?? process.cwd(); const rel = path.relative(root, path.resolve(root, file)); if (rel.startsWith("..") || !rel.startsWith("data" + path.sep)) { console.error(`writes are limited to data/ — blocked ${rel}`); process.exit(2); // 2 denies. 1 would be ignored. } }); -
Scope it to the skill it guards by declaring it in that skill's frontmatter. A hook in
settings.jsonpolices every session; a hook here is active only while this skill runs..claude/skills/refresh-data/SKILL.md--- name: refresh-data description: Re-derive the dataset from upstream. allowed-tools: Read Write Edit WebFetch hooks: PreToolUse: - matcher: "Write|Edit" hooks: - type: command command: node "${CLAUDE_PROJECT_DIR}/.claude/hooks/data-only.mjs" --- Refresh the dataset. You may only write under `data/`.
-
Test the deny path directly — feed the hook a synthetic event rather than driving a whole session.
echo '{"tool_input":{"file_path":"src/index.js"}}' \ | CLAUDE_PROJECT_DIR=$PWD node .claude/hooks/data-only.mjs; echo "exit=$?" # exit=2 → denied, as intended
The permission system asks; a hook decides. Anything you would otherwise write in CLAUDE.md as a "please don't" is a rule the model can rationalize its way past under pressure — so encode it where it cannot. Note the exit codes are unintuitive: 2 blocks, 1 is treated as a non-blocking error and the action proceeds anyway.