# Phase 5: 权限系统

## 权限模式和行为类型
### PermissionMode 和 PermissionBehavior
### PermissionRule:allow / deny / ask 规则

## 通用权限入口:所有工具执行前的大门
### hasPermissionsToUseToolInner:通用权限判断主流程
### hasPermissionsToUseTool:处理 ask 在不同模式下的命运

## Bash 权限系统:从工具名到命令语义
### bashToolCheckPermission:Bash 单条命令的权限判断
### bashToolHasPermission:完整 Bash 命令的权限入口
### checkCommandAndSuggestRules:权限判断后的规则建议

## Bash 安全检查:为什么 shell 特别难
### bashSecurity.ts 的整体角色
### bashCommandIsSafeAsync:命令注入与误解析检查
### validateDangerousPatterns:危险语法模式
### read-only command:为什么“只读命令”也要小心

## 路径与文件权限约束
### checkPathConstraints:命令访问路径的权限边界
### read / write / sensitive path 的区别
### cd、重定向和路径解析为什么会影响权限

## 权限结果如何回到 Agent Loop
### checkPermissionsAndCallTool:权限不是 allow 时如何生成 tool_result
### 权限拒绝为什么不会让 agent 崩溃
### deny / ask 如何影响下一轮模型推理

## Phase 5 总结:模型提动作,runtime 做裁决
### Phase5 完整调用链
### Python mini-agent 的权限系统抽象

Phase 5: 权限系统

权限模式和行为类型

PermissionMode 和 PermissionBehavior

// ============================================================================
// Permission Modes
// ============================================================================

export const EXTERNAL_PERMISSION_MODES = [
  'acceptEdits',
  'bypassPermissions',
  'default',
  'dontAsk',
  'plan',
] as const

export type ExternalPermissionMode = (typeof EXTERNAL_PERMISSION_MODES)[number]

// Exhaustive mode union for typechecking. The user-addressable runtime set
// is INTERNAL_PERMISSION_MODES below.
export type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble'
export type PermissionMode = InternalPermissionMode

// Runtime validation set: modes that are user-addressable (settings.json
// defaultMode, --permission-mode CLI flag, conversation recovery).
export const INTERNAL_PERMISSION_MODES = [
  ...EXTERNAL_PERMISSION_MODES,
  ...(feature('TRANSCRIPT_CLASSIFIER') ? (['auto'] as const) : ([] as const)),
] as const satisfies readonly PermissionMode[]

export const PERMISSION_MODES = INTERNAL_PERMISSION_MODES

// ============================================================================
// Permission Behaviors
// ============================================================================

export type PermissionBehavior = 'allow' | 'deny' | 'ask'

权限系统里先分清两个概念:

PermissionMode:当前会话采用什么权限策略
PermissionBehavior:某一次工具调用最终是 allow / deny / ask

PermissionMode 像全局策略,例如:

default
plan
acceptEdits
bypassPermissions
dontAsk
auto

PermissionBehavior 是单次工具调用的结果:

allow:允许执行
deny:拒绝执行
ask:需要用户确认

PermissionMode.ts 本身不负责真正的安全判断。
它主要负责 mode 的解析、外部转换和 UI 展示信息。

可以这样记:

PermissionMode.ts = 权限模式字典
permissions.ts = 通用权限判断入口
具体 Tool.checkPermissions() = 工具自己的权限判断

通用权限入口:所有工具执行前的大门

hasPermissionsToUseToolInner:通用权限判断主流程

async function hasPermissionsToUseToolInner(
  tool: Tool,
  input: { [key: string]: unknown },
  context: ToolUseContext,
): Promise<PermissionDecision> {
  if (context.abortController.signal.aborted) {
    throw new AbortError()
  }

  let appState = context.getAppState()

  // 1. Check if the tool is denied
  // 1a. Entire tool is denied
  const denyRule = getDenyRuleForTool(appState.toolPermissionContext, tool)
  if (denyRule) {
    return {
      behavior: 'deny',
      decisionReason: {
        type: 'rule',
        rule: denyRule,
      },
      message: `Permission to use ${tool.name} has been denied.`,
    }
  }

  // 1b. Check if the entire tool should always ask for permission
  const askRule = getAskRuleForTool(appState.toolPermissionContext, tool)
  if (askRule) {
    // When autoAllowBashIfSandboxed is on, sandboxed commands skip the ask rule and
    // auto-allow via Bash's checkPermissions. Commands that won't be sandboxed (excluded
    // commands, dangerouslyDisableSandbox) still need to respect the ask rule.
    const canSandboxAutoAllow =
      tool.name === BASH_TOOL_NAME &&
      SandboxManager.isSandboxingEnabled() &&
      SandboxManager.isAutoAllowBashIfSandboxedEnabled() &&
      shouldUseSandbox(input)

    if (!canSandboxAutoAllow) {
      return {
        behavior: 'ask',
        decisionReason: {
          type: 'rule',
          rule: askRule,
        },
        message: createPermissionRequestMessage(tool.name),
      }
    }
    // Fall through to let Bash's checkPermissions handle command-specific rules
  }

  // 1c. Ask the tool implementation for a permission result
  // Overridden unless tool input schema is not valid
  let toolPermissionResult: PermissionResult = {
    behavior: 'passthrough',
    message: createPermissionRequestMessage(tool.name),
  }
  try {
    const parsedInput = tool.inputSchema.parse(input)
    toolPermissionResult = await tool.checkPermissions(parsedInput, context)
  } catch (e) {
    // Rethrow abort errors so they propagate properly
    if (e instanceof AbortError || e instanceof APIUserAbortError) {
      throw e
    }
    logError(e)
  }

  // 1d. Tool implementation denied permission
  if (toolPermissionResult?.behavior === 'deny') {
    return toolPermissionResult
  }

  // 1e. Tool requires user interaction even in bypass mode
  if (
    tool.requiresUserInteraction?.() &&
    toolPermissionResult?.behavior === 'ask'
  ) {
    return toolPermissionResult
  }

  // 1f. Content-specific ask rules from tool.checkPermissions take precedence
  // over bypassPermissions mode. When a user explicitly configures a
  // content-specific ask rule (e.g. Bash(npm publish:*)), the tool's
  // checkPermissions returns {behavior:'ask', decisionReason:{type:'rule',
  // rule:{ruleBehavior:'ask'}}}. This must be respected even in bypass mode,
  // just as deny rules are respected at step 1d.
  if (
    toolPermissionResult?.behavior === 'ask' &&
    toolPermissionResult.decisionReason?.type === 'rule' &&
    toolPermissionResult.decisionReason.rule.ruleBehavior === 'ask'
  ) {
    return toolPermissionResult
  }

  // 1g. Safety checks (e.g. .git/, .claude/, .vscode/, shell configs) are
  // bypass-immune — they must prompt even in bypassPermissions mode.
  // checkPathSafetyForAutoEdit returns {type:'safetyCheck'} for these paths.
  if (
    toolPermissionResult?.behavior === 'ask' &&
    toolPermissionResult.decisionReason?.type === 'safetyCheck'
  ) {
    return toolPermissionResult
  }

  // 2a. Check if mode allows the tool to run
  // IMPORTANT: Call getAppState() to get the latest value
  appState = context.getAppState()
  // Check if permissions should be bypassed:
  // - Direct bypassPermissions mode
  // - Plan mode when the user originally started with bypass mode (isBypassPermissionsModeAvailable)
  const shouldBypassPermissions =
    appState.toolPermissionContext.mode === 'bypassPermissions' ||
    (appState.toolPermissionContext.mode === 'plan' &&
      appState.toolPermissionContext.isBypassPermissionsModeAvailable)
  if (shouldBypassPermissions) {
    return {
      behavior: 'allow',
      updatedInput: getUpdatedInputOrFallback(toolPermissionResult, input),
      decisionReason: {
        type: 'mode',
        mode: appState.toolPermissionContext.mode,
      },
    }
  }

  // 2b. Entire tool is allowed
  const alwaysAllowedRule = toolAlwaysAllowedRule(
    appState.toolPermissionContext,
    tool,
  )
  if (alwaysAllowedRule) {
    return {
      behavior: 'allow',
      updatedInput: getUpdatedInputOrFallback(toolPermissionResult, input),
      decisionReason: {
        type: 'rule',
        rule: alwaysAllowedRule,
      },
    }
  }

  // 3. Convert "passthrough" to "ask"
  const result: PermissionDecision =
    toolPermissionResult.behavior === 'passthrough'
      ? {
          ...toolPermissionResult,
          behavior: 'ask' as const,
          message: createPermissionRequestMessage(
            tool.name,
            toolPermissionResult.decisionReason,
          ),
        }
      : toolPermissionResult

  if (result.behavior === 'ask' && result.suggestions) {
    logForDebugging(
      `Permission suggestions for ${tool.name}: ${jsonStringify(result.suggestions, null, 2)}`,
    )
  }

  return result
}

hasPermissionsToUseToolInner() 是工具执行前的通用权限判断入口。

它解决的问题是:

模型发出了 tool_use,
runtime 在真正执行 tool.call() 之前,
要判断这次工具调用是 allow、deny,还是 ask。

核心流程可以这样记:

1. 如果整个工具被 deny rule 禁止,直接 deny。
2. 如果整个工具有 ask rule,通常直接 ask。
3. 调用 tool.checkPermissions(parsedInput, context),让具体工具自己判断。
4. 如果工具自己返回 deny,直接 deny。
5. 如果工具要求用户交互,即使 bypass mode 也要 ask。
6. 如果是内容级 ask rule 或 safetyCheck,也优先保留 ask。
7. 如果当前是 bypassPermissions,则 allow。
8. 如果整个工具被 allow rule 允许,则 allow。
9. 如果前面都没决定,把 passthrough 转成 ask。

这里有两个层次的权限判断:

通用层:
  判断这个工具整体是否被 allow / deny / ask。

工具层:
  调用 tool.checkPermissions(),
  判断这次具体输入是否危险。

例如 Bash:

Bash 工具本身可能没有被禁用,
但 Bash("rm -rf ...") 这个具体输入仍然需要被拦截。

所以权限系统不是简单的全局开关,而是:

工具整体规则
+ 工具输入语义判断
+ 当前 permission mode
+ 安全兜底策略

最重要的一点:

不确定是否安全时,默认 ask。

这就是 agent runtime 的边界:

模型负责提出动作。
本地 runtime 负责裁决动作能不能执行。

核心思想是:
全局规则先拦一层,工具自己再拦一层,最后不确定就问用户

hasPermissionsToUseTool:处理 ask 在不同模式下的命运

export const hasPermissionsToUseTool: CanUseToolFn = async (
  tool,
  input,
  context,
  assistantMessage,
  toolUseID,
): Promise<PermissionDecision> => {
  const result = await hasPermissionsToUseToolInner(tool, input, context)
  ...
}

hasPermissionsToUseTool() 是权限系统的外层入口。

它先调用:

hasPermissionsToUseToolInner(tool, input, context)

拿到基础权限结果:

allow / deny / ask

如果结果是 allow,基本直接返回:

 // Reset consecutive denials on any allowed tool use in auto mode.
  // This ensures that a successful tool use (even one auto-allowed by rules)
  // breaks the consecutive denial streak.
  if (result.behavior === 'allow') {
    const appState = context.getAppState()
    if (feature('TRANSCRIPT_CLASSIFIER')) {
      const currentDenialState =
        context.localDenialTracking ?? appState.denialTracking
      if (
        appState.toolPermissionContext.mode === 'auto' &&
        currentDenialState &&
        currentDenialState.consecutiveDenials > 0
      ) {
        const newDenialState = recordSuccess(currentDenialState)
        persistDenialState(context, newDenialState)
      }
    }
    return result
  }

如果结果是 ask,它会根据当前 permission mode 和运行环境继续处理。

if (result.behavior === 'ask') {
    const appState = context.getAppState()

    if (appState.toolPermissionContext.mode === 'dontAsk') {
      return {
        behavior: 'deny',
        decisionReason: {
          type: 'mode',
          mode: 'dontAsk',
        },
        message: DONT_ASK_REJECT_MESSAGE(tool.name),
      }
    }
    // Apply auto mode: use AI classifier instead of prompting user
    // Check this BEFORE shouldAvoidPermissionPrompts so classifiers work in headless mode
    if (
      feature('TRANSCRIPT_CLASSIFIER') &&
      (appState.toolPermissionContext.mode === 'auto' ||
        (appState.toolPermissionContext.mode === 'plan' &&
          (autoModeStateModule?.isAutoModeActive() ?? false)))
    ) {
      // Non-classifier-approvable safetyCheck decisions stay immune to ALL
      // auto-approve paths: the acceptEdits fast-path, the safe-tool allowlist,
      // and the classifier. Step 1g only guards bypassPermissions; this guards
      // auto. classifierApprovable safetyChecks (sensitive-file paths) fall
      // through to the classifier — the fast-paths below naturally don't fire
      // because the tool's own checkPermissions still returns 'ask'.
      if (
        result.decisionReason?.type === 'safetyCheck' &&
        !result.decisionReason.classifierApprovable
      ) {
        if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
          return {
            behavior: 'deny',
            message: result.message,
            decisionReason: {
              type: 'asyncAgent',
              reason:
                'Safety check requires interactive approval and permission prompts are not available in this context',
            },
          }
        }
        return result
      }
    }
}

可以这样记:

hasPermissionsToUseToolInner:
  判断这次工具调用按规则应该 allow / deny / ask。

hasPermissionsToUseTool:
  判断 ask 在当前模式下应该怎么落地。

常见改写有三种:

dontAsk:
  ask -> deny

auto:
  ask -> 交给安全分类器判断
  classifier allow -> allow
  classifier block -> deny

headless / async agent:
  不能弹权限框
  ask -> hook 决策或 deny

其中 auto 不是无条件放行,而是:

-> 当前是 auto mode
-> 某些安全检查不能走 auto,保留 ask 或 deny
-> 某些工具要求真人交互,保留 ask
-> acceptEdits 快速路径
-> safe allowlist 快速路径
-> classifyYoloAction(...)
-> classifier block: deny
-> classifier allow: allow

headless / async agent意思是:
如果当前上下文不能弹出权限提示,例如后台 agent 或异步 subagent,那么 ask 没地方问,就不能卡住等待用户,只能走 hook 或自动拒绝

所以权限系统可以分两层理解:

第一层:规则和工具自身判断
  hasPermissionsToUseToolInner()

第二层:运行模式处理 ask
  hasPermissionsToUseTool()

这也是 agent runtime 很重要的安全边界:

模型只提出动作。
runtime 先判断动作是否允许。
如果需要用户确认,还要看当前环境能不能确认。

Bash 权限系统:从工具名到命令语义

bashToolCheckPermission:Bash 单条命令的权限判断

export const bashToolCheckPermission = (
  input,
  toolPermissionContext,
  compoundCommandHasCd?,
  astCommand?,
): PermissionResult => {
  const command = input.command.trim()
  ...
}

bashToolCheckPermission() 是 Bash 工具自己的权限判断核心。

通用权限系统只能知道:

模型想调用 Bash 工具

但 Bash 真正的风险在于:

这条 shell 命令具体做了什么

所以 Bash 必须自己判断 input.command
核心判断顺序:

exact match rule
-> deny / ask prefix rule
-> path constraints
-> exact allow rule
-> prefix allow rule
-> sed constraints
-> permission mode
-> read-only command
-> passthrough

第一步: 精确匹配规则

// 1. Check exact match first
  const exactMatchResult = bashToolCheckExactMatchPermission(
    input,
    toolPermissionContext,
  )

  // 1a. Deny/ask if exact command has a rule
  if (
    exactMatchResult.behavior === 'deny' ||
    exactMatchResult.behavior === 'ask'
  ) {
    return exactMatchResult
  }

如果是规则是Deny或ask就直接返回

第二步: 查prefix规则

  // 2. Find all matching rules (prefix or exact)
  // SECURITY FIX: Check Bash deny/ask rules BEFORE path constraints to prevent bypass
  // via absolute paths outside the project directory (HackerOne report)
  // When AST-parsed, the subcommand is already atomic — skip the legacy
  // splitCommand re-check that misparses mid-word # as compound.
  const { matchingDenyRules, matchingAskRules, matchingAllowRules } =
    matchingRulesForInput(input, toolPermissionContext, 'prefix', {
      skipCompoundCheck: astCommand !== undefined,
    })

  // 2a. Deny if command has a deny rule
  if (matchingDenyRules[0] !== undefined) {
    return {
      behavior: 'deny',
      message: `Permission to use ${BashTool.name} with command ${command} has been denied.`,
      decisionReason: {
        type: 'rule',
        rule: matchingDenyRules[0],
      },
    }
  }

  // 2b. Ask if command has an ask rule
  if (matchingAskRules[0] !== undefined) {
    return {
      behavior: 'ask',
      message: createPermissionRequestMessage(BashTool.name),
      decisionReason: {
        type: 'rule',
        rule: matchingAskRules[0],
      },
    }
  }

prefix规则可以理解为下面这一类按照命令前缀匹配的一类命令

Bash(git status:*)
Bash(npm test:*)
Bash(rm:*)

第三步: 检查路径约束

  // 3. Check path constraints
  // This check comes after deny/ask rules so explicit rules take precedence.
  // SECURITY: When AST-derived argv is available for this subcommand, pass
  // it through so checkPathConstraints uses it directly instead of re-parsing
  // with shell-quote (which has a single-quote backslash bug that causes
  // parseCommandArguments to return [] and silently skip path validation).
  const pathResult = checkPathConstraints(
    input,
    getCwd(),
    toolPermissionContext,
    compoundCommandHasCd,
    astCommand?.redirects,
    astCommand ? [astCommand] : undefined,
  )
  if (pathResult.behavior !== 'passthrough') {
    return pathResult
  }

这一步的意义是,即使命令本身看起来普通,路径也可能越界或敏感,如下

cat /etc/passwd
echo hi > ~/.ssh/config

且deny / ask rules 要在 path constraints 之前检查,因为如果顺序错了,可能出现安全规则被路径逻辑绕开的情况

第四、五步: 处理allow

  // 4. Allow if command had an exact match allow
  if (exactMatchResult.behavior === 'allow') {
    return exactMatchResult
  }

  // 5. Allow if command has an allow rule
  if (matchingAllowRules[0] !== undefined) {
    return {
      behavior: 'allow',
      updatedInput: input,
      decisionReason: {
        type: 'rule',
        rule: matchingAllowRules[0],
      },
    }
  }

  // 5b. Check sed constraints (blocks dangerous sed operations before mode auto-allow)
  const sedConstraintResult = checkSedConstraints(input, toolPermissionContext)
  if (sedConstraintResult.behavior !== 'passthrough') {
    return sedConstraintResult
  }

allow 放在 deny / ask / path constraints 后面,意思是用户允许某类命令,不代表可以跳过更高优先级的安全限制

第六步: 检查 sed 约束

const sedConstraintResult = checkSedConstraints(input, toolPermissionContext)
if (sedConstraintResult.behavior !== 'passthrough') {
  return sedConstraintResult
}

sed 很特殊,因为它既可以读,也可以原地改文件,比如

sed -i ...

第七步: 检查是否是只读

 // 7. Check read-only rules
  if (BashTool.isReadOnly(input)) {
    return {
      behavior: 'allow',
      updatedInput: input,
      decisionReason: {
        type: 'other',
        reason: 'Read-only command is allowed',
      },
    }
  }

第八步: 交回通用权限层

// 8. Passthrough since no rules match, will trigger permission prompt
  const decisionReason = {
    type: 'other' as const,
    reason: 'This command requires approval',
  }
  return {
    behavior: 'passthrough',
    message: createPermissionRequestMessage(BashTool.name, decisionReason),
    decisionReason,
    // Suggest exact match rule to user
    // this may be overridden by prefix suggestions in `checkCommandAndSuggestRules()`
    suggestions: suggestionForExactCommand(command),
  }

最后,如果前面都没有决定,也就是交回通用权限层,最终通常会变成ask

bashToolHasPermission:完整 Bash 命令的权限入口

bashToolHasPermission() 是 Bash 工具权限判断的完整入口。

上一节的 bashToolCheckPermission() 判断的是单条子命令。
但真实 Bash 输入可能是一个完整 shell 程序:

cmd1 && cmd2
cmd1 | cmd2
echo hi > file.txt
cd some/path && git status

所以这一节解决的问题是:

模型请求 Bash(command),
runtime 如何判断整个 command 是否可以安全执行?

核心可以这样记:

bashToolCheckPermission:
  判断一条子命令。

bashToolHasPermission:
  判断整个 Bash command。

第一步:先尝试用 AST 理解整个命令。

export async function bashToolHasPermission(
  input: z.infer<typeof BashTool.inputSchema>,
  context: ToolUseContext,
  getCommandSubcommandPrefixFn = getCommandSubcommandPrefix,
): Promise<PermissionResult> {
  let appState = context.getAppState()

  const injectionCheckDisabled = isEnvTruthy(
    process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK,
  )

  let astRoot = injectionCheckDisabled
    ? null
    : feature('TREE_SITTER_BASH_SHADOW') && !shadowEnabled
      ? null
      : await parseCommandRaw(input.command)

  let astResult: ParseForSecurityResult = astRoot
    ? parseForSecurityFromAst(input.command, astRoot)
    : { kind: 'parse-unavailable' }

这里不是执行命令,而是先判断:

这个 shell 字符串能不能被可靠理解?

astResult 主要有三种情况:

simple:能解析成清楚的 SimpleCommand[]
too-complex:结构太复杂,不能安全静态分析
parse-unavailable:AST 不可用,走旧解析路径

Bash 权限判断最怕的不是“命令长”,而是 runtime 错误理解命令结构。


第二步:如果 AST 判断太复杂,保守 ask。

if (astResult.kind === 'too-complex') {
  const earlyExit = checkEarlyExitDeny(input, appState.toolPermissionContext)
  if (earlyExit !== null) return earlyExit

  const decisionReason: PermissionDecisionReason = {
    type: 'other' as const,
    reason: astResult.reason,
  }

  return {
    behavior: 'ask',
    decisionReason,
    message: createPermissionRequestMessage(BashTool.name, decisionReason),
    suggestions: [],
    ...(feature('BASH_CLASSIFIER')
      ? {
          pendingClassifierCheck: buildPendingClassifierCheck(
            input.command,
            appState.toolPermissionContext,
          ),
        }
      : {}),
  }
}

这里有两个重点:

1. too-complex 不自动执行,而是 ask。
2. 但如果命中显式 deny,仍然直接 deny。

所以不是:

复杂命令 -> 一律询问

而是:

复杂命令 -> 先尊重 deny -> 没有 deny 再 ask

第三步:如果 AST 不可用,走 legacy parse 预检。

if (astResult.kind === 'parse-unavailable') {
  const parseResult = tryParseShellCommand(input.command)
  if (!parseResult.success) {
    const decisionReason = {
      type: 'other' as const,
      reason: `Command contains malformed syntax that cannot be parsed: ${parseResult.error}`,
    }
    return {
      behavior: 'ask',
      decisionReason,
      message: createPermissionRequestMessage(BashTool.name, decisionReason),
    }
  }
}

这一步仍然是同一个原则:

解析不了 -> ask

因为只要 runtime 不能可靠理解 Bash 结构,就不能自动放行。


第四步:sandbox 自动允许检查。

if (
  SandboxManager.isSandboxingEnabled() &&
  SandboxManager.isAutoAllowBashIfSandboxedEnabled() &&
  shouldUseSandbox(input)
) {
  const sandboxAutoAllowResult = checkSandboxAutoAllow(
    input,
    appState.toolPermissionContext,
  )
  if (sandboxAutoAllowResult.behavior !== 'passthrough') {
    return sandboxAutoAllowResult
  }
}

sandbox 是一个更安全的执行环境。
但它不是绕过权限的后门。

这里的 checkSandboxAutoAllow() 仍然会尊重显式 deny / ask 规则。

可以这样记:

sandbox 可以降低风险,
但不能覆盖明确的权限规则。

第五步:检查 Bash prompt rule 分类器。

const exactMatchResult = bashToolCheckExactMatchPermission(
  input,
  appState.toolPermissionContext,
)

if (exactMatchResult.behavior === 'deny') {
  return exactMatchResult
}

if (isClassifierPermissionsEnabled()) {
  const denyDescriptions = getBashPromptDenyDescriptions(
    appState.toolPermissionContext,
  )
  const askDescriptions = getBashPromptAskDescriptions(
    appState.toolPermissionContext,
  )

  const [denyResult, askResult] = await Promise.all([
    hasDeny ? classifyBashCommand(input.command, getCwd(), denyDescriptions, 'deny', ...) : null,
    hasAsk ? classifyBashCommand(input.command, getCwd(), askDescriptions, 'ask', ...) : null,
  ])

这里处理的是“描述型权限规则”。

普通规则可能是:

Bash(git status)
Bash(npm test:*)

而 prompt rule 更像:

拒绝所有发布包的命令
询问所有修改 git history 的命令

这种规则不能只靠字符串匹配,所以要用 classifyBashCommand() 判断。

后面的优先级仍然是:

deny > ask
if (denyResult?.matches && denyResult.confidence === 'high') {
  return {
    behavior: 'deny',
    message: `Denied by Bash prompt rule: "${denyResult.matchedDescription}"`,
    decisionReason: {
      type: 'other',
      reason: `Denied by Bash prompt rule: "${denyResult.matchedDescription}"`,
    },
  }
}

if (askResult?.matches && askResult.confidence === 'high') {
  return {
    behavior: 'ask',
    message: createPermissionRequestMessage(BashTool.name),
    decisionReason: {
      type: 'other',
      reason: `Required by Bash prompt rule: "${askResult.matchedDescription}"`,
    },
    suggestions,
  }
}

第六步:处理管道、重定向等 shell operator。

const commandOperatorResult = await checkCommandOperatorPermissions(
  input,
  (i: z.infer<typeof BashTool.inputSchema>) =>
    bashToolHasPermission(i, context, getCommandSubcommandPrefixFn),
  { isNormalizedCdCommand, isNormalizedGitCommand },
  astRoot,
)

这一层处理:

cmd1 | cmd2
cmd1 && cmd2
echo hi > file.txt

注意这里把 bashToolHasPermission() 自己传进去。
说明 operator 检查可能会递归检查每一段命令。

如果 operator 检查返回 allow,源码还会回头检查原始命令:

if (commandOperatorResult.behavior === 'allow') {
  const safetyResult =
    astSubcommands === null
      ? await bashCommandIsSafeAsync(input.command)
      : null

  const pathResult = checkPathConstraints(
    input,
    getCwd(),
    appState.toolPermissionContext,
    commandHasAnyCd(input.command),
    astRedirects,
    astCommands,
  )
  if (pathResult.behavior !== 'passthrough') {
    return pathResult
  }
}

这个细节非常关键:

子命令看起来安全,不代表原始命令安全。

例如:

echo hello > sensitive-file

如果拆分后只看:

echo hello

它像是安全命令。
但原始命令里有 >,这是写文件动作。

所以源码必须重新检查原始 input.command 的路径和重定向。


第七步:拆成 subcommands,并处理 cd 特殊情况。

const rawSubcommands =
  astSubcommands ?? shadowLegacySubs ?? splitCommand(input.command)

const { subcommands, astCommandsByIdx } = filterCdCwdSubcommands(
  rawSubcommands,
  astCommands,
  cwd,
  cwdMingw,
)

这里开始进入子命令层:

完整 Bash command
-> 拆成多个 subcommands

如果子命令太多,保守 ask:

if (
  astSubcommands === null &&
  subcommands.length > MAX_SUBCOMMANDS_FOR_SECURITY_CHECK
) {
  return {
    behavior: 'ask',
    message: createPermissionRequestMessage(BashTool.name, decisionReason),
    decisionReason,
  }
}

然后检查 cd

const cdCommands = subcommands.filter(subCommand =>
  isNormalizedCdCommand(subCommand),
)

if (cdCommands.length > 1) {
  return {
    behavior: 'ask',
    decisionReason,
    message: createPermissionRequestMessage(BashTool.name, decisionReason),
  }
}

多个 cd 会让路径语义变复杂,所以需要用户确认。

还有一个特殊风险是 cd + git

const compoundCommandHasCd = cdCommands.length > 0

if (compoundCommandHasCd) {
  const hasGitCommand = subcommands.some(cmd =>
    isNormalizedGitCommand(cmd.trim()),
  )
  if (hasGitCommand) {
    return {
      behavior: 'ask',
      decisionReason,
      message: createPermissionRequestMessage(BashTool.name, decisionReason),
    }
  }
}

因为 git 的行为依赖当前目录。
cd 到某个目录后再执行 git,风险和单独执行 git status 不一样。


第八步:每个子命令调用 bashToolCheckPermission。

const subcommandPermissionDecisions = subcommands.map((command, i) =>
  bashToolCheckPermission(
    { command },
    appState.toolPermissionContext,
    compoundCommandHasCd,
    astCommandsByIdx[i],
  ),
)

这就是和上一节的连接:

bashToolHasPermission()
  -> 拆成 subcommands
  -> bashToolCheckPermission(subcommand)

这里还把 compoundCommandHasCd 传进去。
原因是单个子命令可能不知道前面发生过 cd

例如:

cd .claude && echo x > settings.json

如果只看第二段:

echo x > settings.json

它不知道当前目录已经变了。


第九步:合并子命令结果。

const deniedSubresult = subcommandPermissionDecisions.find(
  _ => _.behavior === 'deny',
)
if (deniedSubresult !== undefined) {
  return {
    behavior: 'deny',
    message: `Permission to use ${BashTool.name} with command ${input.command} has been denied.`,
    decisionReason: {
      type: 'subcommandResults',
      reasons: new Map(
        subcommandPermissionDecisions.map((result, i) => [
          subcommands[i]!,
          result,
        ]),
      ),
    },
  }
}

合并时先看 deny:

任意子命令 deny -> 整体 deny

然后再次检查原始命令路径:

const pathResult = checkPathConstraints(
  input,
  getCwd(),
  appState.toolPermissionContext,
  compoundCommandHasCd,
  astRedirects,
  astCommands,
)
if (pathResult.behavior === 'deny') {
  return pathResult
}

这里再次强调:

子命令检查会丢掉某些原始命令信息,
尤其是重定向。

所以原始命令必须再查一次。


第十步:allow / ask / passthrough 的最终合并。

const askSubresult = subcommandPermissionDecisions.find(
  _ => _.behavior === 'ask',
)
const nonAllowCount = count(
  subcommandPermissionDecisions,
  _ => _.behavior !== 'allow',
)

if (pathResult.behavior === 'ask' && askSubresult === undefined) {
  return pathResult
}

if (askSubresult !== undefined && nonAllowCount === 1) {
  return {
    ...askSubresult,
    ...(feature('BASH_CLASSIFIER')
      ? {
          pendingClassifierCheck: buildPendingClassifierCheck(
            input.command,
            appState.toolPermissionContext,
          ),
        }
      : {}),
  }
}

这里的意思是:

如果只有一个地方需要 ask,就直接返回那个 ask。

如果所有子命令都允许,并且没有命令注入风险:

if (
  subcommandPermissionDecisions.every(_ => _.behavior === 'allow') &&
  !hasPossibleCommandInjection
) {
  return {
    behavior: 'allow',
    updatedInput: input,
    decisionReason: {
      type: 'subcommandResults',
      reasons: new Map(
        subcommandPermissionDecisions.map((result, i) => [
          subcommands[i]!,
          result,
        ]),
      ),
    },
  }
}

也就是说:

所有子命令 allow
+ 没有注入风险
= 整体 allow

如果还有不确定的部分,就进入建议规则收集:

const collectedRules: Map<string, PermissionRuleValue> = new Map()

for (const [subcommand, permissionResult] of subcommandResults) {
  if (
    permissionResult.behavior === 'ask' ||
    permissionResult.behavior === 'passthrough'
  ) {
    const updates =
      'suggestions' in permissionResult
        ? permissionResult.suggestions
        : undefined

    const rules = extractRules(updates)
    for (const rule of rules) {
      const ruleKey = permissionRuleValueToString(rule)
      collectedRules.set(ruleKey, rule)
    }
  }
}

最后返回:

return {
  behavior: askSubresult !== undefined ? 'ask' : 'passthrough',
  message: createPermissionRequestMessage(BashTool.name, decisionReason),
  decisionReason,
  suggestions: suggestedUpdates,
  ...(feature('BASH_CLASSIFIER')
    ? {
        pendingClassifierCheck: buildPendingClassifierCheck(
          input.command,
          appState.toolPermissionContext,
        ),
      }
    : {}),
}

这一节可以这样记:

bashToolHasPermission =
  Bash 完整命令的安全裁判。

它的主线是:

先解析整个命令
-> 解析不可靠就 ask
-> 检查 sandbox / prompt rule / operator
-> 拆成 subcommands
-> 每个 subcommand 调 bashToolCheckPermission
-> 任意 deny 则整体 deny
-> 原始命令再做路径检查
-> 全部 allow 且无注入风险才 allow
-> 其他情况 ask 或 passthrough

最重要的理解是:

Bash 权限系统不能只看命令名。
它必须理解整个 shell 字符串的结构。

尤其要记住:

子命令安全,不代表原始命令安全。

因为原始命令可能包含:

重定向写文件
管道组合
cd 后路径变化
命令替换
解析器差异

checkCommandAndSuggestRules:权限判断后的规则建议

checkCommandAndSuggestRules() 解决的问题是:

当 Bash 命令没有被直接 allow / deny / ask 时,
runtime 要不要给用户一个“以后允许类似命令”的建议规则?

它不是最底层的权限判断函数。
它是在 bashToolHasPermission() 拆出子命令后,用来做二次判断和 suggestions 生成的。


第一步:先检查 exact match。

export async function checkCommandAndSuggestRules(
  input: z.infer<typeof BashTool.inputSchema>,
  toolPermissionContext: ToolPermissionContext,
  commandPrefixResult: CommandPrefixResult | null | undefined,
  compoundCommandHasCd?: boolean,
  astParseSucceeded?: boolean,
): Promise<PermissionResult> {
  // 1. Check exact match first
  const exactMatchResult = bashToolCheckExactMatchPermission(
    input,
    toolPermissionContext,
  )
  if (exactMatchResult.behavior !== 'passthrough') {
    return exactMatchResult
  }

这里先看完整命令是否已经有精确规则。

例如:

Bash(npm test)
Bash(git status)

如果 exact match 已经返回 allow / deny / ask,就直接返回。
只有它是 passthrough,才继续往下判断。

可以这样记:

已有精确规则 -> 直接尊重
没有精确规则 -> 继续检查 prefix / 安全风险 / 建议规则

第二步:调用 bashToolCheckPermission() 做命令权限判断。

  // 2. Check the command prefix
  const permissionResult = bashToolCheckPermission(
    input,
    toolPermissionContext,
    compoundCommandHasCd,
  )
  // 2a. Deny/ask if command was explictly denied/asked
  if (
    permissionResult.behavior === 'deny' ||
    permissionResult.behavior === 'ask'
  ) {
    return permissionResult
  }

这里回到了上一节读过的单条命令权限判断。

bashToolCheckPermission() 会检查:

prefix deny / ask / allow
path constraints
sed constraints
permission mode
read-only command
passthrough

如果结果已经是 denyask,说明这条命令已经有明确风险或明确规则,不需要再生成新的建议,直接返回。


第三步:检查命令注入风险。

  // 3. Ask for permission if command injection is detected. Skip when the
  // AST parse already succeeded — tree-sitter has verified there are no
  // hidden substitutions or structural tricks, so the legacy regex-based
  // validators (backslash-escaped operators, etc.) would only add FPs.
  if (
    !astParseSucceeded &&
    !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK)
  ) {
    const safetyResult = await bashCommandIsSafeAsync(input.command)

    if (safetyResult.behavior !== 'passthrough') {
      const decisionReason: PermissionDecisionReason = {
        type: 'other' as const,
        reason:
          safetyResult.behavior === 'ask' && safetyResult.message
            ? safetyResult.message
            : 'This command contains patterns that could pose security risks and requires approval',
      }

      return {
        behavior: 'ask',
        message: createPermissionRequestMessage(BashTool.name, decisionReason),
        decisionReason,
        suggestions: [], // Don't suggest saving a potentially dangerous command
      }
    }
  }

这段很关键。

如果 AST 解析已经成功,说明 tree-sitter 已经确认没有隐藏替换或结构技巧,就跳过 legacy regex 检查,避免误报。

如果 AST 没成功,就调用:

bashCommandIsSafeAsync(input.command)

检查命令注入、解析误判等风险。

如果发现风险,返回 ask,并且:

suggestions: []

这表示:

危险或可疑命令可以让用户临时确认,
但不建议保存成长期 allow rule。

这是一个很重要的安全设计。


第四步:如果命令已经 allow,直接返回。

  // 4. Allow if command was allowed
  if (permissionResult.behavior === 'allow') {
    return permissionResult
  }

如果 bashToolCheckPermission() 已经判断命令可以允许,就不需要生成额外建议。


第五步:生成建议规则。

  // 5. Suggest prefix if available, otherwise exact command
  const suggestedUpdates = commandPrefixResult?.commandPrefix
    ? suggestionForPrefix(commandPrefixResult.commandPrefix)
    : suggestionForExactCommand(input.command)

  return {
    ...permissionResult,
    suggestions: suggestedUpdates,
  }
}

走到这里,通常说明命令还没有明确 allow / deny / ask,结果多半是 passthrough

这时函数会给用户建议一个可保存的权限规则:

如果能提取 command prefix -> 建议 prefix rule
否则 -> 建议 exact command rule

例如:

npm test -- --watch

可能建议保存成:

Bash(npm test:*)

而不是保存完整命令。
这样下次参数稍微变化时,规则仍然可复用。

CommandPrefixResult 的结构很简单:

export type CommandPrefixResult = {
  /** The detected command prefix, or null if no prefix could be determined */
  commandPrefix: string | null
}

建议规则的生成逻辑在 suggestionForExactCommand() 里:

function suggestionForExactCommand(command: string): PermissionUpdate[] {
  // Heredoc commands contain multi-line content that changes each invocation,
  // making exact-match rules useless (they'll never match again). Extract a
  // stable prefix before the heredoc operator and suggest a prefix rule instead.
  const heredocPrefix = extractPrefixBeforeHeredoc(command)
  if (heredocPrefix) {
    return sharedSuggestionForPrefix(BashTool.name, heredocPrefix)
  }

  // Multiline commands without heredoc also make poor exact-match rules.
  // Saving the full multiline text can produce patterns containing `:*` in
  // the middle, which fails permission validation and corrupts the settings
  // file. Use the first line as a prefix rule instead.
  if (command.includes('\n')) {
    const firstLine = command.split('\n')[0]!.trim()
    if (firstLine) {
      return sharedSuggestionForPrefix(BashTool.name, firstLine)
    }
  }

  // Single-line commands: extract a 2-word prefix for reusable rules.
  // Without this, exact-match rules are saved that never match future
  // invocations with different arguments.
  const prefix = getSimpleCommandPrefix(command)
  if (prefix) {
    return sharedSuggestionForPrefix(BashTool.name, prefix)
  }

  return sharedSuggestionForExactCommand(BashTool.name, command)
}

这里可以看出它不是总建议 exact command。

优先级是:

heredoc 命令 -> 提取 heredoc 前面的稳定 prefix
多行命令 -> 用第一行作为 prefix
普通单行命令 -> 尽量提取 2-word prefix
实在提不出 prefix -> 才保存 exact command

这样做是为了避免保存一些“下次永远匹配不上”的规则。


这一节可以这样记:

checkCommandAndSuggestRules =
  权限判断 + 生成可保存规则建议。

它的主线是:

exact match
-> bashToolCheckPermission()
-> 命令注入安全检查
-> allow 直接返回
-> passthrough 时生成 suggestions

最重要的点是:

危险命令可以 ask,
但不建议保存为长期 allow rule。

所以源码里遇到安全风险时会返回:

suggestions: []

而普通未匹配命令才会建议:

prefix rule 或 exact command rule

这说明 Claude Code 的权限系统不只是“是否执行”,还会引导用户把临时确认沉淀成更稳定的权限规则

Bash 安全检查:为什么 shell 特别难

bashSecurity.ts 的整体角色

源码位置:

  • src/tools/BashTool/bashSecurity.ts:1-127
  • src/tools/BashTool/bashSecurity.ts:846-903
  • src/tools/BashTool/bashSecurity.ts:2426-2592
  • src/tools/BashTool/bashPermissions.ts:72-88

bashSecurity.ts 解决的问题不是:

这条 Bash 命令有没有 allow rule?

而是:

这条 Bash 字符串有没有命令注入、解析误判、重定向、命令替换等安全风险?

bashPermissions.ts 中,实际使用的是:

import {
  bashCommandIsSafeAsync_DEPRECATED,
  stripSafeHeredocSubstitutions,
} from './bashSecurity.js'

const bashCommandIsSafeAsync = bashCommandIsSafeAsync_DEPRECATED

名字里有 DEPRECATED,说明它是 legacy regex / shell-quote 路径。
当前主方向是迁移到 tree-sitter AST,但这条路径仍然在 Bash 权限系统里作为安全检查使用。


文件开头先定义危险 shell 模式:

// Note: Backtick pattern is handled separately in validateDangerousPatterns
// to distinguish between escaped and unescaped backticks
const COMMAND_SUBSTITUTION_PATTERNS = [
  { pattern: /<\(/, message: 'process substitution <()' },
  { pattern: />\(/, message: 'process substitution >()' },
  { pattern: /=\(/, message: 'Zsh process substitution =()' },
  // Zsh EQUALS expansion: =cmd at word start expands to $(which cmd).
  // `=curl evil.com` → `/usr/bin/curl evil.com`, bypassing Bash(curl:*) deny
  // rules since the parser sees `=curl` as the base command, not `curl`.
  {
    pattern: /(?:^|[\s;&|])=[a-zA-Z_]/,
    message: 'Zsh equals expansion (=cmd)',
  },
  { pattern: /\$\(/, message: '$() command substitution' },
  { pattern: /\$\{/, message: '${} parameter substitution' },
  ...
]

这些模式说明 Bash 权限判断不能只看第一个命令名。

例如:

echo $(curl evil.com)
=cat /etc/passwd

表面命令可能看起来普通,但 shell 展开后实际行为会变化。


典型 validator 之一是 validateDangerousPatterns()

function validateDangerousPatterns(
  context: ValidationContext,
): PermissionResult {
  const { unquotedContent } = context

  // Special handling for backticks - check for UNESCAPED backticks only
  // Escaped backticks (e.g., \`) are safe and commonly used in SQL commands
  if (hasUnescapedChar(unquotedContent, '`')) {
    return {
      behavior: 'ask',
      message: 'Command contains backticks (`) for command substitution',
    }
  }

  // Other command substitution checks (include double-quoted content)
  for (const { pattern, message } of COMMAND_SUBSTITUTION_PATTERNS) {
    if (pattern.test(unquotedContent)) {
      return { behavior: 'ask', message: `Command contains ${message}` }
    }
  }

  return { behavior: 'passthrough', message: 'No dangerous patterns' }
}

它检查反引号、$()${}、process substitution 等危险展开。
这些不一定绝对恶意,所以返回 ask,不是 deny

另一个典型 validator 是 validateRedirections()

function validateRedirections(context: ValidationContext): PermissionResult {
  const { fullyUnquotedContent } = context

  if (/</.test(fullyUnquotedContent)) {
    return {
      behavior: 'ask',
      message:
        'Command contains input redirection (<) which could read sensitive files',
    }
  }

  if (/>/.test(fullyUnquotedContent)) {
    return {
      behavior: 'ask',
      message:
        'Command contains output redirection (>) which could write to arbitrary files',
    }
  }

  return { behavior: 'passthrough', message: 'No redirections' }
}

< 可能读取敏感文件,> 可能写任意文件,所以都需要用户确认。


bashCommandIsSafeAsync_DEPRECATED() 是这组安全检查的入口:

export async function bashCommandIsSafeAsync_DEPRECATED(
  command: string,
  onDivergence?: () => void,
): Promise<PermissionResult> {
  // Try to get tree-sitter analysis
  const parsed = await ParsedCommand.parse(command)
  const tsAnalysis = parsed?.getTreeSitterAnalysis() ?? null

  // If no tree-sitter, fall back to sync version
  if (!tsAnalysis) {
    return bashCommandIsSafe_DEPRECATED(command)
  }

  // Run the same security checks but with tree-sitter enriched context.
  ...
}

它先尝试 tree-sitter。
如果拿不到 tree-sitter 分析,就退回 legacy 同步版本。

拿到 tree-sitter 后,会构造 ValidationContext

const context: ValidationContext = {
  originalCommand: command,
  baseCommand,
  unquotedContent: withDoubleQuotes,
  fullyUnquotedContent: stripSafeRedirections(fullyUnquoted),
  fullyUnquotedPreStrip: fullyUnquoted,
  unquotedKeepQuoteChars,
  treeSitter: tsAnalysis,
}

这里把同一条命令拆成多个视图:

originalCommand:原始命令
unquotedContent:去掉单引号,但保留双引号内容
fullyUnquotedContent:去掉引号内容,并剥掉安全重定向
fullyUnquotedPreStrip:剥重定向前的 fullyUnquoted
treeSitter:AST 提供的更准确信息

需要这么多视图,是因为 Bash 中同一个字符在不同 quote 环境下含义不同。


接着按顺序跑 validators:

const validators = [
  validateJqCommand,
  validateObfuscatedFlags,
  validateShellMetacharacters,
  validateDangerousVariables,
  validateCommentQuoteDesync,
  validateQuotedNewline,
  validateCarriageReturn,
  validateNewlines,
  validateIFSInjection,
  validateProcEnvironAccess,
  validateDangerousPatterns,
  validateRedirections,
  validateBackslashEscapedWhitespace,
  validateBackslashEscapedOperators,
  validateUnicodeWhitespace,
  validateMidWordHash,
  validateBraceExpansion,
  validateZshDangerousCommands,
  validateMalformedTokenInjection,
]

每个 validator 负责一种 shell 风险。

最后有一个重要设计:不是所有 ask 都一样。

const nonMisparsingValidators = new Set([
  validateNewlines,
  validateRedirections,
])

let deferredNonMisparsingResult: PermissionResult | null = null
for (const validator of validators) {
  const result = validator(context)
  if (result.behavior === 'ask') {
    if (nonMisparsingValidators.has(validator)) {
      if (deferredNonMisparsingResult === null) {
        deferredNonMisparsingResult = result
      }
      continue
    }
    return { ...result, isBashSecurityCheckForMisparsing: true as const }
  }
}
if (deferredNonMisparsingResult !== null) {
  return deferredNonMisparsingResult
}

这里区分两类风险:

non-misparsing ask:
  普通换行、重定向,属于正常 shell 模式,需要 ask。

misparsing ask:
  可能导致 splitCommand / shell-quote 理解错命令结构,更危险。

普通 ask 会先暂存,继续跑后面的 validator。
如果后面发现 misparsing 风险,就优先返回 misparsing ask。

可以这样记:

bashSecurity.ts =
  Bash 字符串安全分析器。

它不负责判断用户是否授权,
它负责判断这条 shell 字符串是否存在语法级绕过风险。

返回值含义:

passthrough:
  没发现安全风险,交给权限规则继续判断。

ask:
  发现需要人工确认的 shell 风险。

isBashSecurityCheckForMisparsing:
  这是更危险的解析误判风险,上层要更早拦住。

bashCommandIsSafeAsync:命令注入与误解析检查

源码位置:

  • src/tools/BashTool/bashSecurity.ts:2257-2412
  • src/tools/BashTool/bashSecurity.ts:2426-2592

bashCommandIsSafeAsync_DEPRECATED() 是 Bash 安全检查的入口。

它解决的问题是:

这条 Bash command 有没有命令注入、解析误判、危险展开、重定向等语法级风险?

注意它不是权限授权器。
它返回 passthrough 只表示:

安全检查没发现明显 shell 风险,
后续还要交给权限规则判断。

入口先尝试 tree-sitter:

export async function bashCommandIsSafeAsync_DEPRECATED(
  command: string,
  onDivergence?: () => void,
): Promise<PermissionResult> {
  // Try to get tree-sitter analysis
  const parsed = await ParsedCommand.parse(command)
  const tsAnalysis = parsed?.getTreeSitterAnalysis() ?? null

  // If no tree-sitter, fall back to sync version
  if (!tsAnalysis) {
    return bashCommandIsSafe_DEPRECATED(command)
  }

  // Run the same security checks but with tree-sitter enriched context.
  ...
}

这里有两条路径:

有 tree-sitter:
  用 AST 提供的 quote context 做更准确分析。

没有 tree-sitter:
  退回 legacy regex / shell-quote 路径。

然后先拦截控制字符和 shell-quote 已知 bug:

if (CONTROL_CHAR_RE.test(command)) {
  return {
    behavior: 'ask',
    message:
      'Command contains non-printable control characters that could be used to bypass security checks',
    isBashSecurityCheckForMisparsing: true,
  }
}

if (hasShellQuoteSingleQuoteBug(command)) {
  return {
    behavior: 'ask',
    message:
      'Command contains single-quoted backslash pattern that could bypass security checks',
    isBashSecurityCheckForMisparsing: true,
  }
}

这里直接标记:

isBashSecurityCheckForMisparsing: true

意思是:

这类风险可能让权限系统“看错命令结构”。

接着构造 ValidationContext

const { processedCommand } = extractHeredocs(command, { quotedOnly: true })

const baseCommand = command.split(' ')[0] || ''

const tsQuote = tsAnalysis.quoteContext
const regexQuote = extractQuotedContent(
  processedCommand,
  baseCommand === 'jq',
)

const context: ValidationContext = {
  originalCommand: command,
  baseCommand,
  unquotedContent: withDoubleQuotes,
  fullyUnquotedContent: stripSafeRedirections(fullyUnquoted),
  fullyUnquotedPreStrip: fullyUnquoted,
  unquotedKeepQuoteChars,
  treeSitter: tsAnalysis,
}

同一条命令会被拆成多个视图:

originalCommand:原始命令
baseCommand:第一个命令词
unquotedContent:去掉单引号,但保留双引号内容
fullyUnquotedContent:去掉引用内容,并剥掉安全重定向
fullyUnquotedPreStrip:剥重定向前的内容
treeSitter:AST 分析结果

需要多个视图,是因为 Bash 中同一个字符在不同 quote 环境下含义不同。


然后先跑 early validators:

const earlyValidators = [
  validateEmpty,
  validateIncompleteCommands,
  validateSafeCommandSubstitution,
  validateGitCommit,
]

for (const validator of earlyValidators) {
  const result = validator(context)
  if (result.behavior === 'allow') {
    return {
      behavior: 'passthrough',
      message: ...,
    }
  }
  if (result.behavior !== 'passthrough') {
    return result.behavior === 'ask'
      ? { ...result, isBashSecurityCheckForMisparsing: true as const }
      : result
  }
}

这里最重要的是:

validator 返回 allow,
主函数也会转成 passthrough。

因为安全检查里的 allow 只表示:

没有发现 shell 注入风险。

不表示用户已经授权执行。


普通 validators 是 Bash 安全检查的主体:

const validators = [
  validateJqCommand,
  validateObfuscatedFlags,
  validateShellMetacharacters,
  validateDangerousVariables,
  validateCommentQuoteDesync,
  validateQuotedNewline,
  validateCarriageReturn,
  validateNewlines,
  validateIFSInjection,
  validateProcEnvironAccess,
  validateDangerousPatterns,
  validateRedirections,
  validateBackslashEscapedWhitespace,
  validateBackslashEscapedOperators,
  validateUnicodeWhitespace,
  validateMidWordHash,
  validateBraceExpansion,
  validateZshDangerousCommands,
  validateMalformedTokenInjection,
]

每个 validator 负责一种 shell 风险。

最后区分普通 ask 和 misparsing ask:

const nonMisparsingValidators = new Set([
  validateNewlines,
  validateRedirections,
])

let deferredNonMisparsingResult: PermissionResult | null = null
for (const validator of validators) {
  const result = validator(context)
  if (result.behavior === 'ask') {
    if (nonMisparsingValidators.has(validator)) {
      if (deferredNonMisparsingResult === null) {
        deferredNonMisparsingResult = result
      }
      continue
    }
    return { ...result, isBashSecurityCheckForMisparsing: true as const }
  }
}
if (deferredNonMisparsingResult !== null) {
  return deferredNonMisparsingResult
}

return {
  behavior: 'passthrough',
  message: 'Command passed all security checks',
}

这里的策略是:

普通 ask 先暂存。
继续跑后面的 validator。
如果发现 misparsing 风险,优先返回 misparsing ask。
如果没有,再返回普通 ask。

这样可以避免普通重定向 > 提前返回,导致后面更危险的解析绕过没有被发现。

可以这样记:

bashCommandIsSafeAsync =
  Bash 语法安全检查器。

passthrough:
  没发现语法风险,交给权限规则继续判断。

ask:
  发现需要确认的 shell 风险。

ask + isBashSecurityCheckForMisparsing:
  发现解析误判风险,需要更保守处理。

路径与文件权限约束

checkPathConstraints:命令访问路径的权限边界

源码位置:src/tools/BashTool/pathValidation.ts:820-1109

前面讲 Bash 权限时,重点是“这条命令本身是否被规则允许”。
但 shell 命令还有另一个维度:

命令要访问哪个路径?
这个路径是在工作区内,还是在外部?
是读,还是写?
有没有重定向?
有没有 cd 改变相对路径语义?

这就是 pathValidation.ts 要解决的问题。

先看单条 path command 的验证入口:

function validateSinglePathCommand(
  cmd: string,
  cwd: string,
  toolPermissionContext: ToolPermissionContext,
  compoundCommandHasCd?: boolean,
): PermissionResult {
  const strippedCmd = stripSafeWrappers(cmd)

  const extractedArgs = parseCommandArguments(strippedCmd)
  if (extractedArgs.length === 0) {
    return {
      behavior: 'passthrough',
      message: 'Empty command - no paths to validate',
    }
  }

  const [baseCmd, ...args] = extractedArgs
  if (!baseCmd || !SUPPORTED_PATH_COMMANDS.includes(baseCmd as PathCommand)) {
    return {
      behavior: 'passthrough',
      message: `Command '${baseCmd}' is not a path-restricted command`,
    }
  }

  const operationTypeOverride =
    baseCmd === 'sed' && sedCommandIsAllowedByAllowlist(strippedCmd)
      ? ('read' as FileOperationType)
      : undefined

  const pathChecker = createPathChecker(
    baseCmd as PathCommand,
    operationTypeOverride,
  )
  return pathChecker(args, cwd, toolPermissionContext, compoundCommandHasCd)
}

这段做了几件事:

  1. 先剥掉安全 wrapper,例如 timeout 10 rm -rf / 不能只看到 timeout
  2. 解析命令参数。
  3. 只处理支持路径检查的命令,比如 cdlsfindrmsed 等。
  4. 判断这个命令对应 read / write / create 哪种文件操作。
  5. 把路径参数交给对应的 pathChecker

这里最重要的是第一步:

const strippedCmd = stripSafeWrappers(cmd)

如果不剥 wrapper,危险命令可以伪装成:

timeout 10 rm -rf ~/.ssh

权限系统如果只看第一个词 timeout,就会漏掉真正的 rm

源码还提供了 AST 版本:

function validateSinglePathCommandArgv(
  cmd: SimpleCommand,
  cwd: string,
  toolPermissionContext: ToolPermissionContext,
  compoundCommandHasCd?: boolean,
): PermissionResult {
  const argv = stripWrappersFromArgv(cmd.argv)
  ...
  const pathChecker = createPathChecker(...)
  return pathChecker(args, cwd, toolPermissionContext, compoundCommandHasCd)
}

它不再用 shell-quote 重新解析字符串,而是使用 tree-sitter 已经解析好的 argv。

原因是源码注释写得很明确:

Avoids the shell-quote single-quote backslash bug.

也就是说,路径校验也要避免“解析器看错命令”。


checkPathConstraints() 还专门检查输出重定向:

const { redirections, hasDangerousRedirection } = astRedirects
  ? astRedirectsToOutputRedirections(astRedirects)
  : extractOutputRedirections(input.command)

if (hasDangerousRedirection) {
  return {
    behavior: 'ask',
    message: 'Shell expansion syntax in paths requires manual approval',
    decisionReason: {
      type: 'other',
      reason: 'Shell expansion syntax in paths requires manual approval',
    },
  }
}

const redirectionResult = validateOutputRedirections(
  redirections,
  cwd,
  toolPermissionContext,
  compoundCommandHasCd,
)
if (redirectionResult.behavior !== 'passthrough') {
  return redirectionResult
}

为什么重定向要单独看?

因为这类命令表面上可能像读:

echo hello > ~/.ssh/authorized_keys

真正危险的不是 echo,而是 > 后面的写入目标。

所以 Claude Code 不能只判断 base command,还要扫描 shell 语法里的输出路径。

最后,它会遍历所有子命令:

if (astCommands) {
  for (const cmd of astCommands) {
    const result = validateSinglePathCommandArgv(
      cmd,
      cwd,
      toolPermissionContext,
      compoundCommandHasCd,
    )
    if (result.behavior === 'ask' || result.behavior === 'deny') {
      return result
    }
  }
} else {
  const commands = splitCommand_DEPRECATED(input.command)
  for (const cmd of commands) {
    const result = validateSinglePathCommand(
      cmd,
      cwd,
      toolPermissionContext,
      compoundCommandHasCd,
    )
    if (result.behavior === 'ask' || result.behavior === 'deny') {
      return result
    }
  }
}

return {
  behavior: 'passthrough',
  message: 'All path commands validated successfully',
}

可以这样记:

checkPathConstraints =
  看 shell 命令里的路径副作用。

它不是最终 allow,
而是发现路径风险时提前 ask / deny;
没发现就 passthrough 给后面的权限规则继续判断。

read / write / sensitive path 的区别

源码位置:

  • src/utils/permissions/filesystem.ts:1030-1194
  • src/utils/permissions/filesystem.ts:1205-1235
  • src/utils/permissions/filesystem.ts:1479-1645
  • src/utils/permissions/pathValidation.ts:141-230

文件路径权限里有一个关键区分:

read:读取文件或目录
edit:修改已有文件
create:创建新文件或目录

先看读权限入口:

export function checkReadPermissionForTool(
  tool: Tool,
  input: { [key: string]: unknown },
  toolPermissionContext: ToolPermissionContext,
): PermissionDecision {
  if (typeof tool.getPath !== 'function') {
    return {
      behavior: 'ask',
      message: `Claude requested permissions to use ${tool.name}, but you haven't granted it yet.`,
    }
  }
  const path = tool.getPath(input)
  const pathsToCheck = getPathsForPermissionCheck(path)
  ...
}

这里要求工具必须能告诉权限系统“我要访问哪个 path”。

这就是 Phase3 里 Tool.getPath() 的意义:不是 UI 辅助字段,而是权限判断输入。

读权限判断的顺序很保守:

// READ-SPECIFIC deny rules first
const denyRule = matchingRuleForInput(..., 'read', 'deny')
if (denyRule) return { behavior: 'deny', ... }

// READ-SPECIFIC ask rules
const askRule = matchingRuleForInput(..., 'read', 'ask')
if (askRule) return { behavior: 'ask', ... }

// Edit access implies read access
const editResult = checkWritePermissionForTool(...)
if (editResult.behavior === 'allow') return editResult

// Allow reads in working directories
if (pathInAllowedWorkingPath(...)) return { behavior: 'allow', ... }

// Internal harness readable paths
const internalReadResult = checkReadableInternalPath(...)
if (internalReadResult.behavior !== 'passthrough') return internalReadResult

// Explicit allow rules
const allowRule = matchingRuleForInput(..., 'read', 'allow')
if (allowRule) return { behavior: 'allow', ... }

// Default ask
return { behavior: 'ask', ... }

这里的顺序很重要:

read deny / ask
要早于
edit implies read

否则用户显式拒绝读取某个路径,却因为有编辑权限而被绕过。


写权限入口是:

export function checkWritePermissionForTool<Input extends AnyObject>(
  tool: Tool<Input>,
  input: z.infer<Input>,
  toolPermissionContext: ToolPermissionContext,
  precomputedPathsToCheck?: readonly string[],
): PermissionDecision {
  if (typeof tool.getPath !== 'function') {
    return {
      behavior: 'ask',
      message: `Claude requested permissions to use ${tool.name}, but you haven't granted it yet.`,
    }
  }
  const path = tool.getPath(input)

  const pathsToCheck =
    precomputedPathsToCheck ?? getPathsForPermissionCheck(path)
  for (const pathToCheck of pathsToCheck) {
    const denyRule = matchingRuleForInput(
      pathToCheck,
      toolPermissionContext,
      'edit',
      'deny',
    )
    if (denyRule) {
      return {
        behavior: 'deny',
        message: `Permission to edit ${path} has been denied.`,
        decisionReason: { type: 'rule', rule: denyRule },
      }
    }
  }
  ...
}

写权限通常比读权限更严格,因为它会改变真实文件系统。

源码还给一些内部路径开了特殊通道:

export function checkEditableInternalPath(
  absolutePath: string,
  input: { [key: string]: unknown },
): PermissionResult {
  const normalizedPath = normalize(absolutePath)

  if (isSessionPlanFile(normalizedPath)) {
    return {
      behavior: 'allow',
      updatedInput: input,
      decisionReason: {
        type: 'other',
        reason: 'Plan files for current session are allowed for writing',
      },
    }
  }

  if (isScratchpadPath(normalizedPath)) { ... }
  if (isAgentMemoryPath(normalizedPath)) { ... }
  if (isAutoMemPath(normalizedPath)) { ... }
  if (project .claude/launch.json) { ... }

  return { behavior: 'passthrough', message: '' }
}

这些内部路径不是“随便写”,而是 Claude Code 自己运行需要的受控目录,例如:

  • 当前 session 的 plan 文件。
  • scratchpad。
  • agent memory。
  • 自动 memory 文件。
  • 桌面 preview 的 .claude/launch.json

读内部路径也有类似函数:

export function checkReadableInternalPath(
  absolutePath: string,
  input: { [key: string]: unknown },
): PermissionResult {
  const normalizedPath = normalize(absolutePath)

  if (isSessionMemoryPath(normalizedPath)) {
    return {
      behavior: 'allow',
      updatedInput: input,
      decisionReason: {
        type: 'other',
        reason: 'Session memory files are allowed for reading',
      },
    }
  }

  if (isProjectDirPath(normalizedPath)) { ... }
  if (isSessionPlanFile(normalizedPath)) { ... }
  ...
}

可以这样记:

普通工作区路径:
  读通常可自动允许。
  写通常要看 mode / rule。

工作区外路径:
  默认 ask。

敏感路径:
  即使 mode 宽松,也可能 safetyCheck ask。

内部 runtime 路径:
  只对明确用途开小口子。

cd、重定向和路径解析为什么会影响权限

Bash 里的路径不是静态字符串。

这些写法都会改变权限判断难度:

cd /tmp && rm file
echo data > output.txt
echo data > "$TARGET"
timeout 5 rm -rf ./dist

所以源码里反复出现几个防御点:

stripSafeWrappers:
  去掉 timeout / nice / nohup / time 等 wrapper,看到真实命令。

compoundCommandHasCd:
  标记整条复合命令里是否出现 cd。

astRedirects:
  用 tree-sitter 提取重定向目标,避免 shell-quote 误解析。

getPathsForPermissionCheck:
  同时检查原始路径和解析 symlink 后的路径。

checkPathSafetyForAutoEdit:
  对 .git、.claude、shell 配置、credential 等敏感文件做额外安全检查。

这里的核心思想是:

权限系统不能只看“命令字符串长什么样”。
它必须尽量还原 shell 执行时真正会访问的路径。

权限结果如何回到 Agent Loop

checkPermissionsAndCallTool:权限不是 allow 时如何生成 tool_result

源码位置:src/services/tools/toolExecution.ts:599-733

源码位置:src/services/tools/toolExecution.ts:916-1104

Phase1 里我们讲过:工具结果必须作为 user/tool_result 回到下一轮 messages。

权限拒绝也是一样。

checkPermissionsAndCallTool() 里,工具调用先做 schema 校验:

const parsedInput = tool.inputSchema.safeParse(input)
if (!parsedInput.success) {
  return [
    {
      message: createUserMessage({
        content: [
          {
            type: 'tool_result',
            content: `<tool_use_error>InputValidationError: ${errorContent}</tool_use_error>`,
            is_error: true,
            tool_use_id: toolUseID,
          },
        ],
        toolUseResult: `InputValidationError: ${parsedInput.error.message}`,
        sourceToolAssistantUUID: assistantMessage.uuid,
      }),
    },
  ]
}

注意,这里不是 throw 给整个 agent loop。

它构造了一个错误 tool_result,让模型下一轮自己修正调用参数。

接着调用工具自己的 validateInput()

const isValidCall = await tool.validateInput?.(
  parsedInput.data,
  toolUseContext,
)
if (isValidCall?.result === false) {
  return [
    {
      message: createUserMessage({
        content: [
          {
            type: 'tool_result',
            content: `<tool_use_error>${isValidCall.message}</tool_use_error>`,
            is_error: true,
            tool_use_id: toolUseID,
          },
        ],
        toolUseResult: `Error: ${isValidCall.message}`,
        sourceToolAssistantUUID: assistantMessage.uuid,
      }),
    },
  ]
}

这说明 Claude Code 区分了两类失败:

schema validation:
  模型生成的参数类型不对。

tool.validateInput:
  参数类型对,但具体值不适合执行。

然后进入 hooks 和权限解析:

const resolved = await resolveHookPermissionDecision(
  hookPermissionResult,
  tool,
  processedInput,
  toolUseContext,
  canUseTool,
  assistantMessage,
  toolUseID,
)
const permissionDecision = resolved.decision
processedInput = resolved.input

如果最终不是 allow:

if (permissionDecision.behavior !== 'allow') {
  let errorMessage = permissionDecision.message

  const messageContent: ContentBlockParam[] = [
    {
      type: 'tool_result',
      content: errorMessage,
      is_error: true,
      tool_use_id: toolUseID,
    },
  ]

  resultingMessages.push({
    message: createUserMessage({
      content: messageContent,
      toolUseResult: `Error: ${errorMessage}`,
      sourceToolAssistantUUID: assistantMessage.uuid,
    }),
  })

  return resultingMessages
}

这就是权限系统接回 agent loop 的关键:

deny / ask 没有被用户批准
-> 不是程序崩溃
-> 构造 user message
-> content 里放 tool_result
-> is_error: true
-> 下一轮模型看到错误原因

权限拒绝为什么不会让 agent 崩溃

如果权限拒绝直接抛异常,agent loop 会变成:

模型请求工具
-> 本地拒绝
-> 程序异常
-> 对话断掉

Claude Code 采用的是另一种设计:

模型请求工具
-> 本地拒绝
-> 拒绝原因作为 tool_result
-> 模型根据拒绝原因换方案

这很符合 agent 系统的闭环:

tool_use 不一定成功。
tool_result 可以是成功结果,也可以是结构化失败。
失败本身也是模型下一步推理的上下文。

所以权限拒绝不是“异常路径”,而是 agent loop 的正常分支。

deny / ask 如何影响下一轮模型推理

权限拒绝回到模型后,模型会看到类似:

Error: Permission to use Bash with command ... has been denied.

或者:

Error: Claude requested permissions to read from /some/path,
but you haven't granted it yet.

这会影响下一轮模型:

  • 如果是路径越界,它可能改读工作区内文件。
  • 如果是 Bash 被拒绝,它可能改用 Read/Grep/Glob。
  • 如果是写入被拒绝,它可能先解释计划,等待用户授权。
  • 如果是命令太复杂,它可能拆成更简单的命令。

这就是权限系统在 agent loop 中的真实作用:

不是单纯阻止模型,
而是把边界反馈给模型,
让模型在边界内继续完成任务。

Phase 5 总结:模型提动作,runtime 做裁决

Phase5 完整调用链

assistant/tool_use
  name = Bash / Read / Edit / ...
  input = ...

-> toolExecution.checkPermissionsAndCallTool()
  -> inputSchema.safeParse()
  -> tool.validateInput()
  -> PreToolUse hooks
  -> resolveHookPermissionDecision()

-> hasPermissionsToUseTool()
  -> hasPermissionsToUseToolInner()
     -> tool-level deny rule
     -> tool-level ask rule
     -> tool.checkPermissions()
     -> bypassPermissions / plan bypass
     -> always allow rule
     -> mode-specific handling

-> for Bash:
  -> bashToolHasPermission()
     -> tree-sitter / legacy parse
     -> exact deny / ask / allow
     -> sandbox auto allow
     -> command operators
     -> bashCommandIsSafeAsync()
     -> split subcommands
     -> checkPathConstraints()
     -> read-only allow
     -> classifier / permission prompt

-> if allow:
  -> tool.call()

-> if deny / unresolved ask:
  -> createUserMessage({
       content: [{
         type: "tool_result",
         tool_use_id,
         is_error: true,
         content: errorMessage
       }]
     })
  -> append to messages
  -> next model call

Python mini-agent 的权限系统抽象

Phase5 的实践不需要一开始就实现 Claude Code 这么复杂。

可以先抽象三层:

from enum import Enum
from dataclasses import dataclass

class PermissionBehavior(Enum):
    ALLOW = "allow"
    DENY = "deny"
    ASK = "ask"

class PermissionMode(Enum):
    DEFAULT = "default"
    ACCEPT_EDITS = "accept_edits"
    BYPASS = "bypass"

@dataclass
class PermissionDecision:
    behavior: PermissionBehavior
    reason: str = ""

class PermissionManager:
    def __init__(self, mode: PermissionMode):
        self.mode = mode
        self.deny_patterns = ["rm -rf /", "sudo ", "git push --force"]
        self.read_only_commands = ["ls", "cat", "grep", "rg", "pwd"]

    def check_shell(self, command: str) -> PermissionDecision:
        for pattern in self.deny_patterns:
            if pattern in command:
                return PermissionDecision(
                    PermissionBehavior.DENY,
                    f"Command matches deny pattern: {pattern}",
                )

        base = command.strip().split()[0] if command.strip() else ""
        if base in self.read_only_commands:
            return PermissionDecision(
                PermissionBehavior.ALLOW,
                "Read-only command",
            )

        if self.mode == PermissionMode.BYPASS:
            return PermissionDecision(
                PermissionBehavior.ALLOW,
                "Bypass mode",
            )

        return PermissionDecision(
            PermissionBehavior.ASK,
            "Command requires approval",
        )

然后在工具执行层里统一处理:

decision = permission_manager.check_shell(command)

if decision.behavior != PermissionBehavior.ALLOW:
    messages.append({
        "role": "user",
        "content": [{
            "type": "tool_result",
            "tool_use_id": tool_use.id,
            "is_error": True,
            "content": f"Permission denied: {decision.reason}",
        }],
    })
    return

result = shell_tool.call(command)
messages.append(result.to_tool_result(tool_use.id))

你可以这样记:

权限系统最小抽象 =
  PermissionMode:当前会话策略
  PermissionBehavior:本次工具调用结果
  PermissionDecision:行为 + 原因
  PermissionManager:集中裁决
  tool_result:把拒绝反馈给模型

Phase5 的核心不是“把危险命令列全”,而是理解这个架构分工:

模型负责提出动作。
工具负责声明能力。
权限系统负责裁决。
执行系统负责真实动作。
message loop 负责把成功或失败都反馈给模型。

更多推荐