详解Claude Code的permissions.deny和allow-fallback两种隔离机制,前者声明式但无法做白名单,后者可精确控制目录但对符号链接等场景存在盲区,并提供可落地代码示例。
当你坐在一个 Agent 面前时,"不要碰 src/ 以外的任何东西"是靠你盯着来 enforce 的。但在无人值守的场景下,必须靠某种无论有没有人看着都会运行的东西来 enforce。
Claude Code 为此提供了两种机制,它们不可互换。一种是声明式的,但无法表达你真正想要的东西;另一种可以表达,但在结构上对一类写入完全 blind。以下是它们各自实际的行为,以及第二种机制的代码。
权限规则位于 settings.json 中,形式为 Tool(specifier):
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Write(./.github/**)",
"Write(//etc/**)"
]
}
}
路径采用 gitignore 风格。前缀 // 表示绝对路径,~ 表示 home,其他都是相对于 settings 文件的路径。deny 优先于 ask,ask 优先于 allow,规则会在不同 scope 之间合并而非覆盖——所以项目设置中的 deny 即使在你的个人 ~/.claude/settings.json 允许同一操作时仍然生效。这个优先级规则就是 deny 的价值所在:一条 deny 规则很难被意外撤销。
问题在于它的形状。你对无人值守 Agent 真正想要的是一个 allow-list——只有这些目录,其他都不行。而 deny 给你的只是一个 block-list,你无法用后者构建出前者。有一个看似巧妙的技巧:deny 所有东西,然后 allow 例外——但它恰好在让 deny 变得有价值的那条优先级规则上失败了:deny 中的 Write(**) 优先于你与之配对的任何 allow,所以 Agent 什么都写不了。
Claude Code 确实有一种 allow-list 形状的边界——项目根目录,加上你列在 additionalDirectories 中的任何目录。这可以阻止 Agent 溜进 /etc。但它没有说明项目内部哪些目录可以写,这通常才是真正需要关心的问题。没人真正担心一个定时任务的 Agent 会去编辑 /etc/hosts。担心的是被安排写文章的 Agent 决定去改它自己的调度配置。
所以,对于任何比"待在项目里"更细粒度的控制,你需要代码。
PreToolUse hook 是 Claude Code 在工具调用之前运行的命令,它将待执行的调用作为 JSON 通过 stdin 传入。hook 返回答案,而这个答案是有约束力的。
将它注册到写文件的工具上:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "node ${CLAUDE_PROJECT_DIR}/.claude/hooks/deny-outside-scope.mjs"
}
]
}
]
}
}
通过 stdin 收到的 payload 包含 tool_name、tool_input 和 cwd。对于 Write 和 Edit,tool_input.file_path 是即将被操作的文件路径。对于 Bash,只有 tool_input.command 而没有任何路径——记住这一点,后面会用到。
你通过退出 0 并在 stdout 打印 JSON 来回复:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "…"
}
}
permissionDecision 可以是 allow、deny、ask 或 defer。defer 表示"没意见,按正常权限流程继续",对于一个 guard 来说这是正确的默认值。返回 allow 的 hook 会覆盖用户自己的权限规则,这不是 scope guard 应该做的事。permissionDecisionReason 会发给模型,所以值得把它写成一条指令而不是错误码。
还有第二种阻止方式——退出码 2,理由写在 stderr 上。这也能工作,但你失去了结构化字段,而且退出码为 0 时 stderr 只进调试日志,你和模型都看不到。优先使用 JSON。
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.`,
};
}
其中有三个要点是承重墙。
contains 使用 path.relative,而不是字符串前缀。"/app/src-secret".startsWith("/app/src") 返回 true,这就是 allow-list 悄悄失效的方式。将两边都 resolve 然后问相对路径是否通过 .. 逃逸,这个版本才能在同级目录、./ 干扰和路径遍历面前存活。
所有无法识别的东西都 defer 而不是 deny。一个格式错误的 payload 不是权限决策。defer 把它交回正常流程,它会按自己的方式失败并告诉你原因。
reason 是给模型的。"Denied" 会促使它通过不同的工具重试。列出允许的根目录并明确说不要绕过 guard,给了它一个不走偏门正道的去处。
把它包装在一个读取 stdin 且永不抛出的脚本里:
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;
}
fail open 是一个真实的决策,你应该深思熟虑后再做。一个在 payload 格式错误时抛出的 hook 会在你 typo allow-list 的那一刻阻塞整个 session 的所有写入,"Agent 不能工作"是一个比"一个检查没运行"更糟糕的默认失败——前提是有别的东西能 catch 漏网之鱼。那是下一节的内容。
这个机制作为一个普通脚本最大的好处是你可以在没有任何 Agent 在场的情况下测试它:
echo '{"tool_name":"Write","tool_input":{"file_path":"/etc/hosts"},"cwd":"'"$PWD"'"}' | node .claude/hooks/deny-outside-scope.mjs
有输出意味着被拒绝。静默意味着允许。在你信任它之前做一次这个测试——一个从未有人见过它拒绝任何东西的 guard 是你假设它能工作的 guard。
PreToolUse hook 在工具调用时触发。这就是边界,而它渗漏的地方比初看时更多:
Bash 收到的是命令字符串,而不是路径。sed -i、> file、cp、mv、git checkout、npm run build——一个匹配 Write|Edit 的 hook 永远不会触发,而匹配 Bash 的 hook 必须解析任意 shell 才能找到写入。别试,你会输。
Agent 启动的 subagent 和脚本做它们自己的文件 I/O,不在父级的 tool loop 里。
生成的输出——构建产物、lockfile、格式化器的通过——以没有任何工具调用命名它的方式落入目录树中。
这就是为什么第二道防线是检查结果而不是请求。在任何东西被提交之前,将工作树与同一个 allow-list 做 diff:
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)));
它不关心是什么产生了变更。Shell 重定向、subagent、构建步骤——如果它落入了目录树,它就在 git status 里,而这个检查能 catch 它。
两道 guard 是互补的,各自覆盖对方的漏洞。hook 阻止写入并给模型一个可以据此行动的 reason,但只对匹配到的调用生效。diff 能看到一切,但只是事后诸葛亮。让 hook 运行使得错误大多数时候不会发生;让 diff 运行使得你能发现错误何时仍然发生了。
这不是沙箱。两道 guard 都活在 Agent 自己的 harness 内部——hook 由 Claude Code 调用,diff 由你自己的脚本调用。这是对错误和对 Agent 从它读取过的文件中获得的指令的良好防御。它不是对同一台机器上有 shell 访问权限的攻击者的边界,把它们混为一谈就是人们最终惊讶的原因。如果你需要真正的边界,那是容器或 OS 级别的沙箱,那是另一回事。
用这约 60 行代码换来的东西范围更窄但仍然值得拥有:一个不会悄悄重写自己配置的无人值守 Agent,以及一个在提交时告诉你什么东西还是漏了的检查。
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.
For further actions, you may consider blocking this person and/or reporting abuse