Claude Code 101|07|Permissions

Claude Code 的权限系统不是一个确认弹窗,而是 Read/Edit/Bash/MCP/Web 等不同风险面的组合治理。

Claude Code 101|07|Permissions

代码 Agent 的安全边界不在模型输出文本,而在 runtime 是否允许它改变世界。一个回答错误的聊天模型最多误导用户;一个拥有文件写入、shell、网络和外部工具的 Coding Agent,则可能删除文件、泄露信息、污染配置、执行恶意命令,或者把危险授权保存成长期规则。

所以,Permissions 不是“弹一个确认框”。确认框只是用户交互形态,真正的权限系统是一套风险建模:哪些行动是读,哪些是写,哪些会改变未来执行环境,哪些会访问开放世界,哪些命令看似只读但包含重定向或命令替换,哪些路径因为 symlink、UNC、大小写或特殊设备绕过而变危险。

Claude Code 很适合作为 Permissions 案例:文件权限、Bash 权限、通用规则、规则解析、工具执行和 hooks 一起组成 runtime 的安全模型。核心不是“弹一个确认框”,而是把不同工具的行动风险拆成可解释、可配置、可审计的决策流程。

工程问题:Agent 行动风险必须被建模

权限系统要回答的问题不是“用户是否信任模型”,而是“这次行动的风险面是什么”。同一个 Agent 可以做很多事:读一个业务代码文件、写一个配置文件、运行 git status、运行 rm -rf、访问文档网站、调用 MCP server、通过 LSP 查引用。这些行动不能用同一个 Allow/Deny 规则粗暴处理。

至少有五类风险需要分开建模。

第一,信息暴露。Read、Grep、Glob、LSP、WebFetch 都可能扩大模型可见信息。只读不等于无风险,特别是读取凭据、私密配置、gitignored 文件或用户 home 下敏感路径时。

第二,状态修改。Edit、Write、NotebookEdit、Bash 写操作会改变文件或环境。写操作需要比读操作更严格的路径检查、staleness 检查和用户确认。

第三,未来执行环境污染。有些文件本身不是业务代码,但会改变之后的 shell、git、编辑器、MCP 或 Agent 行为,例如 .bashrc.zshrc.gitconfig.mcp.json.claude.json.git.vscode.idea.claude。修改这些文件的风险大于普通业务代码。

第四,命令执行。Bash 是最大风险面,因为字符串命令可以组合管道、重定向、子 shell、环境变量、命令替换、控制流和外部程序。权限系统如果只做 startsWith('git status'),一定会被绕过。

第五,开放世界访问。Web 和 MCP 可能连接项目之外的世界。它们的风险既包括数据外泄,也包括供应链和远程能力调用。

成熟权限系统的目标不是永远拒绝这些行动,而是把它们拆成可解释、可配置、可审计的决策。

概念边界:Permissions 是 Tool Runtime 的治理子协议

上一篇讲 Tool Runtime 时,我们看到每个 Tool 都有 checkPermissions(),工具执行链会在 validate 之后进入权限判断。Permissions 因此不是全局拦截器,而是工具协议的一部分。

它又不完全属于单个工具。通用 deny / ask / allow 规则、permission mode、session-scoped 授权、hook permission、用户交互、规则保存建议,都需要跨工具统一。于是 Claude Code 的权限设计呈现为“通用规则 + 工具特定判断 + 用户交互 + 持久规则”的组合。

下一篇会进入 File、Bash、Web、LSP 四类工具。那些工具之所以复杂,很大程度上正是因为它们的权限边界不同。Permissions 先建立风险模型,具体工具再把模型落到文件路径、命令 AST、domain、LSP location 等细节上。

机制一:Read 和 Edit 是两条不同权限通道

文件系统权限最容易被低估。很多系统只有“是否允许访问文件”一个开关,但 Claude Code 把 Read 和 Edit 分开。src/utils/permissions/filesystem.ts 中有 checkReadPermissionForTool()checkWritePermissionForTool() 两条入口。它们共享一些路径规范化和规则匹配逻辑,但风险语义不同。

Read 权限关注信息暴露。它要处理原始路径和 resolved path,避免 symlink 绕过;要阻断 UNC 路径和可疑 Windows path pattern;要处理工作目录内默认可读与敏感路径额外保护之间的关系;还要尊重显式 deny / ask / allow 规则。读取一个业务代码文件和读取一个凭据文件,不应该被同等对待。

Edit 权限关注状态修改。它除了路径规则,还要检查危险文件和目录、.claude 相关特殊规则、session-scoped 授权、acceptEdits 模式、显式规则和默认 ask。允许编辑有时可以隐含读取,因为写入前需要理解文件内容;但 read-specific deny / ask 仍然可能优先,不能因为写权限就绕过读边界。

这个区分非常关键。读和写在工程系统里不是对称操作:读会扩大模型知识面,写会改变用户世界。二者都危险,但危险类型不同。一个好的权限系统应该把它们建模成不同 action,而不是同一个 file:*

可迁移抽象如下:

type FileAction = 'read' | 'write' | 'create' | 'delete'

type FilePermissionInput = {
  action: FileAction
  requestedPath: string
  resolvedPath: string
  toolName: string
  sessionId: string
}

async function decideFilePermission(input: FilePermissionInput, ctx: PermissionContext) {
  const normalized = normalizeForSecurity(input)
  if (isSuspiciousPath(normalized)) return deny('suspicious path')

  const explicit = matchRules(input.action, normalized, ctx.rules)
  if (explicit) return explicit

  if (input.action === 'read') return decideRead(normalized, ctx)
  return decideWrite(normalized, ctx)
}

注意这里同时保留 requested path 和 resolved path。权限系统必须处理“用户/模型请求的路径”和“文件系统真实指向的路径”之间的差异。

机制二:危险路径代表“未来控制面”

src/utils/permissions/filesystem.ts 中定义了 DANGEROUS_FILESDANGEROUS_DIRECTORIES。文件包括 .gitconfig.gitmodules.bashrc.bash_profile.zshrc.zprofile.profile.ripgreprc.mcp.json.claude.json;目录包括 .git.vscode.idea.claude

这组名单的意义不在于“这些名字特殊”,而在于它们代表未来控制面。

  • shell rc 文件会影响之后命令执行。
  • git config 和 submodule 配置会影响版本控制行为和供应链。
  • MCP 配置会影响外部工具连接。
  • Agent 配置会影响后续权限和运行方式。
  • 编辑器配置可能影响任务、调试、插件和自动化。
  • .git 目录直接关系仓库历史与引用。

也就是说,权限系统不只要保护当前文件内容,还要保护未来 runtime 的控制面。攻击者如果无法直接执行危险命令,也可能通过修改配置让下一次安全命令变危险。Agent 如果不理解这一点,就会把“修改配置文件”误判为普通文本编辑。

伪代码可以这样写:

function classifyPathRisk(path: NormalizedPath): PathRisk {
  if (matchesDangerousControlFile(path)) {
    return { level: 'high', reason: 'modifies future execution context' }
  }
  if (isCredentialLike(path)) {
    return { level: 'high', reason: 'may expose secrets' }
  }
  if (isProjectSource(path)) {
    return { level: 'normal', reason: 'project source' }
  }
  return { level: 'unknown', reason: 'outside known workspace' }
}

危险路径列表不应该被看作硬编码偏好,而应该被看作风险分类器的一部分。不同组织可以扩展这组规则,比如 CI 配置、deployment 配置、package manager hooks、Terraform state 等。

机制三:路径安全是现实细节,不是字符串前缀

文件权限之所以复杂,是因为路径不是普通字符串。src/utils/permissions/filesystem.ts 里能看到大小写规范化、路径展开、path traversal、UNC、Windows path、symlink resolved path、macOS/Windows case-insensitive filesystem 等相关处理。注释中特别提到,把路径统一小写用于比较,是为了避免在大小写不敏感文件系统上用混合大小写绕过安全检查。

这类细节在安全系统里非常重要。常见绕过包括:

  • 使用 symlink 指向工作区外敏感路径。
  • 使用大小写变化绕过 .claude / .git 检测。
  • 使用 .. 或路径分隔符差异绕过目录限制。
  • 使用 UNC 或 Windows 特殊路径触发非预期访问。
  • 通过设备文件、临时目录或工具结果目录制造循环读取。

权限系统不能只做 path.startsWith(projectRoot)。安全判断应该基于规范化路径、resolved path、平台语义和规则来源。

可迁移流程如下:

async function normalizeForPermission(rawPath: string): Promise<SecurePath> {
  const expanded = expandUserAndEnv(rawPath)
  const sanitized = rejectTraversal(expanded)
  const resolved = await resolveSymlinks(sanitized)
  const comparable = caseFoldForSecurity(resolved)

  if (isUncOrDevicePath(comparable)) {
    throw new PermissionError('unsupported path form')
  }

  return { raw: rawPath, expanded, resolved, comparable }
}

注意 caseFoldForSecurity 不等于改变实际执行路径。权限比较可以用安全规范化形式,但实际工具执行仍应使用经过验证的真实路径,避免破坏用户路径语义。

机制四:Bash 权限必须理解命令结构

Bash 是权限系统中最复杂的部分。src/tools/BashTool/bashPermissions.ts 显示了多阶段决策:先处理 deny / ask 规则,再检查路径约束,再处理 exact allow 和 prefix allow,再检查 sed 约束、permission mode、read-only,最后没有匹配时要求用户批准。src/tools/BashTool/bashSecurity.tssrc/tools/BashTool/readOnlyValidation.ts 则承担命令安全与只读判断。

关键点是:Bash 权限不是字符串匹配。它需要理解命令结构。

一个命令可能表面以 git status 开头,但后面接了 ; curl ...;可能通过 &&||、管道和重定向改变语义;可能在命令替换里执行额外代码;可能先 cd 到另一个目录再运行看似安全的命令;可能用 sed -i 或重定向修改文件;可能通过环境变量或 shell builtin 改变执行上下文。

Claude Code 使用 tree-sitter 解析命令,并在解析失败或结构复杂时倾向 fail closed:请求用户确认。bashPermissions.ts 中也能看到 AST parse 成功时,可以避免某些 legacy regex 检查带来的误报;但当无法可靠理解命令结构时,就不应该自动放行。

这体现了安全系统的基本原则:能证明安全才自动允许,不能证明就进入 ask,而不是“没发现危险就允许”。

伪代码如下:

async function decideBashPermission(command: string, ctx: PermissionContext) {
  const ast = parseShellCommand(command)

  const explicit = matchExplicitRules(command, ctx.rules)
  if (explicit?.behavior === 'deny') return explicit
  if (explicit?.behavior === 'ask') return explicit

  if (!ast.ok) {
    return ask('command structure cannot be safely analyzed')
  }

  const structuralRisk = inspectShellAst(ast.tree)
  if (structuralRisk.requiresApproval) return ask(structuralRisk.reason)

  const pathRisk = checkCommandPathConstraints(ast.commands, ctx)
  if (pathRisk.requiresApproval) return ask(pathRisk.reason)

  if (isReadOnlyCommand(ast.tree)) return allow('read-only command')

  const savedRule = matchAllowRules(ast.commandPrefix, ctx.rules)
  if (savedRule) return allow('matched saved rule')

  return ask('command requires approval')
}

这段伪代码和真实实现的函数名不同,但表达的是同一架构:规则匹配、结构解析、路径约束、只读分类和默认 ask 必须组合。

机制五:权限规则建议不能制造长期风险

权限系统不仅要判断本次调用,还要处理“以后不要再问”的规则保存。这里有一个很容易被忽略的问题:如果系统给用户建议了过宽规则,用户一键保存后,风险会从一次行动扩大成长期授权。

Claude Code 对 Bash 规则建议非常谨慎。它避免建议 bash:*sh:*sudo:*xargs:*env:* 等过宽前缀;对 compound command,也限制建议数量。bashPermissions.ts 中的 suggestion 逻辑体现了这一点:可以建议 exact command 或较窄 prefix,但不能为了减少弹窗把安全边界放得太宽。

这背后的原则是:权限规则本身也是一种代码。它会改变未来 runtime 的行为,因此必须被当作高风险配置处理。一个坏规则比一次坏命令更危险,因为它会沉默地允许后续行动。

可迁移设计:

type PermissionSuggestion = {
  scope: 'once' | 'session' | 'project' | 'global'
  pattern: string
  risk: 'narrow' | 'medium' | 'broad'
}

function suggestSavedRules(action: Action): PermissionSuggestion[] {
  const candidates = deriveNarrowPatterns(action)
  return candidates.filter(s => s.risk !== 'broad')
}

UI 也应该把 “allow once” 与 “allow always” 清楚地区分。很多权限事故并不是用户批准了危险操作,而是用户以为批准一次,实际保存了长期规则。

机制六:Hooks 可以参与权限,但不能绕过交互语义

Tool Runtime 中还有 hooks。src/services/tools/toolHooks.ts 处理 hook permission result,并把 allow、deny、ask 与工具要求交互的情况组合起来。hook 可以提供额外决策,但 runtime 必须确认它是否满足工具交互需求,不能让 hook 的 allow 盲目绕过所有约束。

这说明权限系统不仅是用户和工具之间的关系,还包括自动化策略。企业或团队可能希望通过 hooks 统一拒绝某些工具、自动允许某些只读操作、或在执行前做审计。这样的扩展能力很有价值,但也会带来治理复杂度。

原则是:hook 决策应该进入同一 PermissionResult 协议,而不是旁路执行。它要能表达 allow、deny、ask、updatedInput、decisionReason,并被 transcript / analytics / UI 观察到。否则权限系统会被多个隐形通道撕裂。

机制七:MCP 与 Web 是开放世界边界

MCP 工具通常以 server/tool 粒度进入权限系统。外部 server 暴露的 annotations 可以告诉 runtime 某个工具是 readOnly、destructive 还是 openWorld,但这不是天然信任。权限应该分两层:是否允许连接某个 MCP server,以及是否允许调用某个 server 的某个 tool。

WebFetch 则按 domain 做权限控制。预批准文档站点可以直接允许抓取,但跨 host redirect 不应该自动继续,因为 redirect 可能把用户批准的 URL 带到另一个域名。WebSearch 又是另一类边界,它往往通过模型 API 的 server-side search 能力工作,权限语义与本地 fetch 不同。

开放世界权限的关键不是“能不能联网”一个开关,而是:访问哪个 domain、以什么方式访问、是否可能上传本地信息、redirect 是否改变授权对象、结果是否进入模型上下文、是否被记录。

Claude Code 作为 Permissions 案例

从 Agent Runtime 的角度看,Claude Code 的权限系统不是一个 UI 弹窗,而是一组工具风险面的组合治理。文件读写、Bash、Web、MCP、LSP、hooks 和长期规则都对应不同风险模型,不能用同一个 allow / deny 开关粗暴处理。

这也是 Permissions 的关键:安全感不是让模型“自觉谨慎”,而是 runtime 能够把行动风险建模、解释、授权、记录和复用。

工程启发

第一,把权限建模为 action risk,而不是 yes/no。Read、Edit、Bash、Web、MCP、LSP 的风险不同,应该有不同决策路径。

第二,路径安全要处理真实文件系统细节。symlink、大小写、UNC、设备文件、path traversal、工作区边界都必须在代码里防御。

第三,命令权限要理解结构。对 shell 字符串做前缀匹配是不够的,至少要有 AST、重定向、控制流、命令替换、路径参数和只读分类。

第四,保存权限规则比批准单次操作更危险。规则建议应该尽量窄,避免产生长期过宽授权。

第五,hook 和外部策略必须进入同一权限协议。不要允许旁路自动化绕过审计与 UI。

第六,只读不等于无风险。读取敏感文件、LSP 查询、Web 抓取、MCP read-only tool 都可能扩展模型可见信息。

小结

Claude Code 的 Permissions 把 Agent 行动拆成不同风险面:文件读取、文件写入、危险路径、shell 命令、开放世界访问、外部工具调用、hook 决策和长期规则。真正的安全感不是让模型“自觉谨慎”,而是 runtime 能够在代码层把风险建模、验证、解释和审计。

下一篇会把这些权限原则落到具体工具边界上:File、Bash、Web、LSP。它们共享 Tool Runtime,也共享权限协议,但每一类工具连接真实工程环境的方式都不同。