前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
原理篇
面经篇
AI 面试
自检篇
每日一题
  • 综合
    • 综合题型
    • 其他问题
    • 设计模式
    • 思维导图
    • 学习路线
  • 前端基础
    • HTTP
    • 浏览器
    • 计算机基础
  • 进阶学习
    • NPM工作流
    • Docker
    • Canvas
    • Node学习指南
    • 前端综合文章
  • 其他
    • Handbook
    • 职场话题
    • CSS可视化
小程序题库
公众号动态
博客动态
AI 热点
开发者导航
基础篇
进阶篇
高频篇
精选篇
手写篇
原理篇
面经篇
AI 面试
自检篇
每日一题
  • 综合
    • 综合题型
    • 其他问题
    • 设计模式
    • 思维导图
    • 学习路线
  • 前端基础
    • HTTP
    • 浏览器
    • 计算机基础
  • 进阶学习
    • NPM工作流
    • Docker
    • Canvas
    • Node学习指南
    • 前端综合文章
  • 其他
    • Handbook
    • 职场话题
    • CSS可视化
小程序题库
公众号动态
博客动态
AI 热点
开发者导航
返回 AI 情报前线
All News · 全部资讯4239
  • Anthropic Fable/Mythos 5曾因出口管制被暂停,后已恢复
  • AI Agent本质是分布式系统:生产级架构与数学原理
  • AI Agent开发陷阱:单步85%可靠率下端到端仅27%
  • OpenAI取消免费版文字对话限制,推GPT-5.6 Luna
  • AI 编程 Agent 真正需要的是结构化上下文,而非像素上下文
  • 零依赖浏览器扩展:一键向 Claude/Cursor 发送网页结构化上下文
  • Agent Skill 安全威胁模型与防御框架:description 注入攻击实测
  • MWNN Kanban:VS Code 内支持人机协作的看板扩展
  • GitHub Models正式停用,开发者需迁移至自有API密钥
  • OpenChamber:开源多Agent协作开发环境,支持5模型并行
  • TormentNexus放弃微服务,选择Go+TypeScript模块化单体
  • Figma截图给Claude效果差的根本原因
  • MCP服务器为自主Agent加固的四个经验教训
  • AI 语音工作流中人机切换的追加式审计日志设计
  • Claude 速率限制原理解析及实时监控方案
  • SQLite 文本历史存储新思路:用 zstd 压缩整个版本数组
  • Claude Code 新版:跨会话消息传递功能已上线
  • Java从零实现MiniGPT系列:吃透Transformer原理
  • 用 Java 从零实现 MiniGPT:分词器设计详解
  • 澳大利亚发生全球首例AI自主网络攻击:AI助手入侵健身房网站
  • Claude Code权限控制:permissions.deny的局限与目录级隔离方案
  • EU AI Act 第50条已生效:AI 生成内容强制披露要点解读
  • 垂直AI Agent为何优于水平Agent及构建方法
  • 生产级WhatsApp AI Agent架构实战18个月
  • 用OpenAPI Spec实现通用SaaS连接器架构
  • Cloudflare/Circle/OSL:Agent支付基础设施三连发
  • OpenAI因安全阈值主动暂停Astra模型开发
  • x402协议获50亿美元交易量,AI支付基础设施正式成立
  • OpenChamber 开源:同一任务并行跑 5 个 AI 模型取最优
  • MarketNow 转型安全基础设施:MCP Server 安全审计
  • AI 编程的真正瓶颈:代码生成免费,验证是全部工作
  • 受监管行业 LLM 部署清单:范围、所有权与系统边界
  • 利用 Neon 分支隔离 AI 调用成本:prod/preview/CI 分别计量
  • Opik vs Langfuse 深度对比:LLM 可观测性工具边界评测
  • 跨提供商模型兜底路由:OpenAI 兼容网关简化 fallback 逻辑
  • Stryker MCP让AI编程助手直接跑突变测试,100%覆盖率可验证
  • 避免AI Agent重复解析代码库的方案
  • Next.js中RSC与AI流式输出的实战模式
  • 六个 SQLite 高危漏洞原来是 AI 幻觉:CVE 被撤销事件分析
  • 研究数据:重度 AI 用户代码理解率仅 50%,调试能力下滑最显著
  • Claude Code自动模式将默认开启,编程介入更少
  • 用统计假设检验验证Agent指令变更效果
  • 从零构建实时听写:离线语音转文字的工程实践
  • 用 Docker 沙盒隔离 AI 编程 Agent:安全运行指南
  • GitOps 实践:让 AI Agent 团队配置同步像 Git Push 一样简单
  • 教程:用Cloudflare Workers构建轻量AI API安全代理
  • 2026 年主流 LLM 可观测平台横评:Langfuse、LangSmith、Braintrust、Arize
  • 24 GB 显存不等于 24 GB 可用:本地 LLM 显存规划实战
  • Google发布WeatherNext 2天气预报AI模型
  • ComfyUI:模块化AI创作引擎
  • Stryker MCP Reporter:AI 编程 Agent 的变异测试自动化
  • 已加载 51 / 4239
8.0
热点
AI SCORE
编程提效2026-08-10 05:48

Claude Code权限控制:permissions.deny的局限与目录级隔离方案

dev.to · AI#Claude Code#Agent安全#权限控制
Editor brief · 编辑速览

详解Claude Code的permissions.deny和allow-fallback两种隔离机制,前者声明式但无法做白名单,后者可精确控制目录但对符号链接等场景存在盲区,并提供可落地代码示例。

文章思维导图
Knowledge map
拖拽缩放
Full translation

完整中文译文

当你坐在一个 Agent 面前时,"不要碰 src/ 以外的任何东西"是靠你盯着来 enforce 的。但在无人值守的场景下,必须靠某种无论有没有人看着都会运行的东西来 enforce。

Claude Code 为此提供了两种机制,它们不可互换。一种是声明式的,但无法表达你真正想要的东西;另一种可以表达,但在结构上对一类写入完全 blind。以下是它们各自实际的行为,以及第二种机制的代码。

为什么 permissions.deny 不够用

权限规则位于 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

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。

一个 hook 永远看不到的写入

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

Original source

本文由 AI 翻译整理自 dev.to · AI,原文版权归原作者所有。

阅读英文原文
上一篇
澳大利亚发生全球首例AI自主网络攻击:AI助手入侵健身房网站
下一篇
EU AI Act 第50条已生效:AI 生成内容强制披露要点解读