如何阻止 Claude Code 代理在目录之外写入

Dev.to AI 2026-08-12T12:28:07.605642

当你坐在代理面前时,“别碰 src/ 以外的任何东西”可以由你盯着来实现。可一旦无人值守,就必须靠某个无论有没有人在看都会运行的机制来强制执行。Claude Code 提供了两种机制,但它们不能互相替代:一种是声明式的,表达不了你真正想要的规则;另一种做得到,却在结构上对某一类写入完全“失明”。下面说说两者各自的实际作用,并给出第二种的完整代码。

为什么 permissions.deny 不够用

权限规则写在 settings.json 里,形如:

Tool(specifier) : { "permissions" : { "deny" : [ "Read(./.env)" , "Read(./.env.*)" , "Write(./.github/**)" , "Write(//etc/**)" ] } }

路径采用 gitignore 风格:以 // 开头表示绝对路径,~ 表示用户主目录,其余路径相对于 settings 文件。deny 优先于 askask 优先于 allow;不同作用域下的规则是合并而不是覆盖——所以即使你个人 ~/.claude/settings.json 里允许了同一操作,项目设置里的 deny 仍然生效。这条优先级正是它有价值的地方:一条 deny 规则很难被无意中撤销。

问题出在形态上。对于无人值守的代理,你真正想要的是一个“允许清单”——只允许这些目录,其他一律不行。而 deny 能给的是“拒绝清单”,你无法用后者拼出前者。有个显而易见的办法:先拒绝一切,再单独放行例外。但这恰好会栽在让 deny 变得有用的那条优先级规则上:deny 里的 Write(**) 优先级高于你搭配的每一条 allow,结果代理什么也写不了。

Claude Code 确实有一道“白名单形态”的边界——项目根目录,加上你在 additionalDirectories 里列出的目录。它能阻止代理跑到 /etc 里,却完全没说代理在项目内部可以写哪些子目录,而那通常才是真正有意思的问题。没有人真心担心定时任务代理去改 /etc/hosts;大家担心的是,那个负责写文章的代理决定顺手修一下自己的调度配置。

一个拒绝写入的 PreToolUse 钩子

PreToolUse 钩子是 Claude Code 在调用工具之前运行的一条命令,它会通过标准输入(stdin)把即将发生的调用以 JSON 格式交给该命令。

这个 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_nametool_inputcwd 三个字段。对于 WriteEdittool_input.file_path 就是即将被写入的文件;而 Bash 只有 tool_input.command,根本没有路径字段——记住这一点,后面会用到。

回复的方式是:以退出码 0 结束,并在 stdout 输出一段 JSON:

{ "hookSpecificOutput" : { "hookEventName" : "PreToolUse" , "permissionDecision" : "deny" , "permissionDecisionReason" : "…" } }

permissionDecision 的取值有 allowdenyaskdefer 四种。defer 表示「不表态,继续走正常的权限流程」。对于守卫型 hook 来说,这是正确的默认值:返回 allow 会覆盖用户自己的权限规则,而那不是目录守卫该做的事。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,允许列表就这么悄悄失效了。把两边都解析成绝对路径,再看相对路径是否包含 .. 逃逸,这样才能应对同级目录、./ 干扰以及传入路径里的目录遍历。所有无法识别的情况都选择 defer,而不是 deny。格式错误的 payload 不该被当成权限决定。defer 会把它交还给正常流程,让它在自己的逻辑里失败,并告诉你原因。

拒绝理由是写给模型看的。「Denied」会诱使它换个工具再试。明确写出允许的根目录,并告诉它不要绕过检查,这样才能给模型一个正当的出路,而不是逼它另辟蹊径。

包装成一个从 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(失败时放行)」是一个需要你认真做出的决定。如果 hook 遇到格式错误的 payload 就直接抛异常,那你只要把允许列表拼错一个字符,整个会话里的写入操作就全被挡住了。相比之下,「代理没法干活」比「有一次检查没跑」是更糟的默认失败——前提是还有别的地方能兜住漏网的请求。

下面就是下一部分的内容。这个脚本最好的一点是,它就是一个普通的脚本,你完全不需要有代理在场就能测试它:

echo '{"tool_name":"Write","tool_input":{"file_path":"/etc/hosts"},"cwd":"' " $PWD " '"}' | node .claude/hooks/deny-outside-scope.mjs

有输出,说明被拒绝;没有输出,说明允许。在信任它之前,先跑一次验证——一个从来没人见它拒绝过任何东西的守卫,只是你单方面假设它能正常工作。

钩子永远看不到的写入

PreToolUse 钩子只在工具调用时触发。这就是边界,而漏掉的地方比第一眼看上去更多:Bash 拿到的是命令字符串,而不是路径。sed -i> filecpmvgit checkoutnpm run build——一个匹配 Write|Edit 的钩子根本不会触发;而匹配 Bash 的钩子就得解析任意 shell 才能找出写入。别试了,你赢不了的。

子代理和脚本也会执行自己的文件 I/O,完全绕开父代理的工具循环。构建产物、锁文件、格式化器的输出——这些生成结果会落进工作目录,却没有一个工具调用点名提到它们。这正是后半部分改走另一条路的原因:检查结果,而不是检查请求。

在提交任何东西之前,先把工作树和同一个允许列表做一次 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 重定向、子代理、构建步骤——只要东西进了工作树,就会出现在 git status 里,这个检查就能逮到它。两个守卫正好互补,各自堵住对方的盲区。钩子能拦下写入,并给模型一个可以继续操作的依据,但它只对匹配到的调用生效。diff 检查能看到一切,但只能事后追认。跑钩子,让错误大概率不发生;跑 diff,好能在错误真发生时发现它。

它不是什么

查看原文