md
# 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
ts
// ============================================================================
// 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'
权限系统里先分清两个概念:
text
PermissionMode:当前会话采用什么权限策略
PermissionBehavior:某一次工具调用最终是 allow / deny / ask
PermissionMode 像全局策略,例如:
text
default
plan
acceptEdits
bypassPermissions
dontAsk
auto
PermissionBehavior 是单次工具调用的结果:
text
allow:允许执行
deny:拒绝执行
ask:需要用户确认
PermissionMode.ts 本身不负责真正的安全判断。
它主要负责 mode 的解析、外部转换和 UI 展示信息。
可以这样记:
text
PermissionMode.ts = 权限模式字典
permissions.ts = 通用权限判断入口
具体 Tool.checkPermissions() = 工具自己的权限判断
通用权限入口:所有工具执行前的大门
hasPermissionsToUseToolInner:通用权限判断主流程
ts
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() 是工具执行前的通用权限判断入口。
它解决的问题是:
text
模型发出了 tool_use,
runtime 在真正执行 tool.call() 之前,
要判断这次工具调用是 allow、deny,还是 ask。
核心流程可以这样记:
text
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。
这里有两个层次的权限判断:
text
通用层:
判断这个工具整体是否被 allow / deny / ask。
工具层:
调用 tool.checkPermissions(),
判断这次具体输入是否危险。
例如 Bash:
text
Bash 工具本身可能没有被禁用,
但 Bash("rm -rf ...") 这个具体输入仍然需要被拦截。
所以权限系统不是简单的全局开关,而是:
text
工具整体规则
+ 工具输入语义判断
+ 当前 permission mode
+ 安全兜底策略
最重要的一点:
text
不确定是否安全时,默认 ask。
这就是 agent runtime 的边界:
text
模型负责提出动作。
本地 runtime 负责裁决动作能不能执行。
核心思想是:
全局规则先拦一层,工具自己再拦一层,最后不确定就问用户
hasPermissionsToUseTool:处理 ask 在不同模式下的命运
ts
export const hasPermissionsToUseTool: CanUseToolFn = async (
tool,
input,
context,
assistantMessage,
toolUseID,
): Promise<PermissionDecision> => {
const result = await hasPermissionsToUseToolInner(tool, input, context)
...
}
hasPermissionsToUseTool() 是权限系统的外层入口。
它先调用:
ts
hasPermissionsToUseToolInner(tool, input, context)
拿到基础权限结果:
text
allow / deny / ask
如果结果是 allow,基本直接返回:
ts
// 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 和运行环境继续处理。
ts
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
}
}
}
可以这样记:
text
hasPermissionsToUseToolInner:
判断这次工具调用按规则应该 allow / deny / ask。
hasPermissionsToUseTool:
判断 ask 在当前模式下应该怎么落地。
常见改写有三种:
text
dontAsk:
ask -> deny
auto:
ask -> 交给安全分类器判断
classifier allow -> allow
classifier block -> deny
headless / async agent:
不能弹权限框
ask -> hook 决策或 deny
其中 auto 不是无条件放行,而是:
text
-> 当前是 auto mode
-> 某些安全检查不能走 auto,保留 ask 或 deny
-> 某些工具要求真人交互,保留 ask
-> acceptEdits 快速路径
-> safe allowlist 快速路径
-> classifyYoloAction(...)
-> classifier block: deny
-> classifier allow: allow
headless / async agent意思是:
如果当前上下文不能弹出权限提示,例如后台 agent 或异步 subagent,那么 ask 没地方问,就不能卡住等待用户,只能走 hook 或自动拒绝
所以权限系统可以分两层理解:
text
第一层:规则和工具自身判断
hasPermissionsToUseToolInner()
第二层:运行模式处理 ask
hasPermissionsToUseTool()
这也是 agent runtime 很重要的安全边界:
text
模型只提出动作。
runtime 先判断动作是否允许。
如果需要用户确认,还要看当前环境能不能确认。
Bash 权限系统:从工具名到命令语义
bashToolCheckPermission:Bash 单条命令的权限判断
ts
export const bashToolCheckPermission = (
input,
toolPermissionContext,
compoundCommandHasCd?,
astCommand?,
): PermissionResult => {
const command = input.command.trim()
...
}
bashToolCheckPermission() 是 Bash 工具自己的权限判断核心。
通用权限系统只能知道:
text
模型想调用 Bash 工具
但 Bash 真正的风险在于:
text
这条 shell 命令具体做了什么
所以 Bash 必须自己判断 input.command
核心判断顺序:
text
exact match rule
-> deny / ask prefix rule
-> path constraints
-> exact allow rule
-> prefix allow rule
-> sed constraints
-> permission mode
-> read-only command
-> passthrough
第一步: 精确匹配规则
ts
// 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规则
ts
// 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规则可以理解为下面这一类按照命令前缀匹配的一类命令
text
Bash(git status:*)
Bash(npm test:*)
Bash(rm:*)
第三步: 检查路径约束
ts
// 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
}
这一步的意义是,即使命令本身看起来普通,路径也可能越界或敏感,如下
bash
cat /etc/passwd
echo hi > ~/.ssh/config
且deny / ask rules 要在 path constraints 之前检查,因为如果顺序错了,可能出现安全规则被路径逻辑绕开的情况
第四、五步: 处理allow
ts
// 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 约束
ts
const sedConstraintResult = checkSedConstraints(input, toolPermissionContext)
if (sedConstraintResult.behavior !== 'passthrough') {
return sedConstraintResult
}
sed 很特殊,因为它既可以读,也可以原地改文件,比如
bash
sed -i ...
第七步: 检查是否是只读
ts
// 7. Check read-only rules
if (BashTool.isReadOnly(input)) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'other',
reason: 'Read-only command is allowed',
},
}
}
第八步: 交回通用权限层
ts
// 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 程序:
bash
cmd1 && cmd2
cmd1 | cmd2
echo hi > file.txt
cd some/path && git status
所以这一节解决的问题是:
text
模型请求 Bash(command),
runtime 如何判断整个 command 是否可以安全执行?
核心可以这样记:
text
bashToolCheckPermission:
判断一条子命令。
bashToolHasPermission:
判断整个 Bash command。
第一步:先尝试用 AST 理解整个命令。
ts
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' }
这里不是执行命令,而是先判断:
text
这个 shell 字符串能不能被可靠理解?
astResult 主要有三种情况:
text
simple:能解析成清楚的 SimpleCommand[]
too-complex:结构太复杂,不能安全静态分析
parse-unavailable:AST 不可用,走旧解析路径
Bash 权限判断最怕的不是"命令长",而是 runtime 错误理解命令结构。
第二步:如果 AST 判断太复杂,保守 ask。
ts
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,
),
}
: {}),
}
}
这里有两个重点:
text
1. too-complex 不自动执行,而是 ask。
2. 但如果命中显式 deny,仍然直接 deny。
所以不是:
text
复杂命令 -> 一律询问
而是:
text
复杂命令 -> 先尊重 deny -> 没有 deny 再 ask
第三步:如果 AST 不可用,走 legacy parse 预检。
ts
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),
}
}
}
这一步仍然是同一个原则:
text
解析不了 -> ask
因为只要 runtime 不能可靠理解 Bash 结构,就不能自动放行。
第四步:sandbox 自动允许检查。
ts
if (
SandboxManager.isSandboxingEnabled() &&
SandboxManager.isAutoAllowBashIfSandboxedEnabled() &&
shouldUseSandbox(input)
) {
const sandboxAutoAllowResult = checkSandboxAutoAllow(
input,
appState.toolPermissionContext,
)
if (sandboxAutoAllowResult.behavior !== 'passthrough') {
return sandboxAutoAllowResult
}
}
sandbox 是一个更安全的执行环境。
但它不是绕过权限的后门。
这里的 checkSandboxAutoAllow() 仍然会尊重显式 deny / ask 规则。
可以这样记:
text
sandbox 可以降低风险,
但不能覆盖明确的权限规则。
第五步:检查 Bash prompt rule 分类器。
ts
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,
])
这里处理的是"描述型权限规则"。
普通规则可能是:
text
Bash(git status)
Bash(npm test:*)
而 prompt rule 更像:
text
拒绝所有发布包的命令
询问所有修改 git history 的命令
这种规则不能只靠字符串匹配,所以要用 classifyBashCommand() 判断。
后面的优先级仍然是:
text
deny > ask
ts
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。
ts
const commandOperatorResult = await checkCommandOperatorPermissions(
input,
(i: z.infer<typeof BashTool.inputSchema>) =>
bashToolHasPermission(i, context, getCommandSubcommandPrefixFn),
{ isNormalizedCdCommand, isNormalizedGitCommand },
astRoot,
)
这一层处理:
bash
cmd1 | cmd2
cmd1 && cmd2
echo hi > file.txt
注意这里把 bashToolHasPermission() 自己传进去。
说明 operator 检查可能会递归检查每一段命令。
如果 operator 检查返回 allow,源码还会回头检查原始命令:
ts
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
}
}
这个细节非常关键:
text
子命令看起来安全,不代表原始命令安全。
例如:
bash
echo hello > sensitive-file
如果拆分后只看:
bash
echo hello
它像是安全命令。
但原始命令里有 >,这是写文件动作。
所以源码必须重新检查原始 input.command 的路径和重定向。
第七步:拆成 subcommands,并处理 cd 特殊情况。
ts
const rawSubcommands =
astSubcommands ?? shadowLegacySubs ?? splitCommand(input.command)
const { subcommands, astCommandsByIdx } = filterCdCwdSubcommands(
rawSubcommands,
astCommands,
cwd,
cwdMingw,
)
这里开始进入子命令层:
text
完整 Bash command
-> 拆成多个 subcommands
如果子命令太多,保守 ask:
ts
if (
astSubcommands === null &&
subcommands.length > MAX_SUBCOMMANDS_FOR_SECURITY_CHECK
) {
return {
behavior: 'ask',
message: createPermissionRequestMessage(BashTool.name, decisionReason),
decisionReason,
}
}
然后检查 cd:
ts
const cdCommands = subcommands.filter(subCommand =>
isNormalizedCdCommand(subCommand),
)
if (cdCommands.length > 1) {
return {
behavior: 'ask',
decisionReason,
message: createPermissionRequestMessage(BashTool.name, decisionReason),
}
}
多个 cd 会让路径语义变复杂,所以需要用户确认。
还有一个特殊风险是 cd + git:
ts
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。
ts
const subcommandPermissionDecisions = subcommands.map((command, i) =>
bashToolCheckPermission(
{ command },
appState.toolPermissionContext,
compoundCommandHasCd,
astCommandsByIdx[i],
),
)
这就是和上一节的连接:
text
bashToolHasPermission()
-> 拆成 subcommands
-> bashToolCheckPermission(subcommand)
这里还把 compoundCommandHasCd 传进去。
原因是单个子命令可能不知道前面发生过 cd。
例如:
bash
cd .claude && echo x > settings.json
如果只看第二段:
bash
echo x > settings.json
它不知道当前目录已经变了。
第九步:合并子命令结果。
ts
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:
text
任意子命令 deny -> 整体 deny
然后再次检查原始命令路径:
ts
const pathResult = checkPathConstraints(
input,
getCwd(),
appState.toolPermissionContext,
compoundCommandHasCd,
astRedirects,
astCommands,
)
if (pathResult.behavior === 'deny') {
return pathResult
}
这里再次强调:
text
子命令检查会丢掉某些原始命令信息,
尤其是重定向。
所以原始命令必须再查一次。
第十步:allow / ask / passthrough 的最终合并。
ts
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,
),
}
: {}),
}
}
这里的意思是:
text
如果只有一个地方需要 ask,就直接返回那个 ask。
如果所有子命令都允许,并且没有命令注入风险:
ts
if (
subcommandPermissionDecisions.every(_ => _.behavior === 'allow') &&
!hasPossibleCommandInjection
) {
return {
behavior: 'allow',
updatedInput: input,
decisionReason: {
type: 'subcommandResults',
reasons: new Map(
subcommandPermissionDecisions.map((result, i) => [
subcommands[i]!,
result,
]),
),
},
}
}
也就是说:
text
所有子命令 allow
+ 没有注入风险
= 整体 allow
如果还有不确定的部分,就进入建议规则收集:
ts
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)
}
}
}
最后返回:
ts
return {
behavior: askSubresult !== undefined ? 'ask' : 'passthrough',
message: createPermissionRequestMessage(BashTool.name, decisionReason),
decisionReason,
suggestions: suggestedUpdates,
...(feature('BASH_CLASSIFIER')
? {
pendingClassifierCheck: buildPendingClassifierCheck(
input.command,
appState.toolPermissionContext,
),
}
: {}),
}
这一节可以这样记:
text
bashToolHasPermission =
Bash 完整命令的安全裁判。
它的主线是:
text
先解析整个命令
-> 解析不可靠就 ask
-> 检查 sandbox / prompt rule / operator
-> 拆成 subcommands
-> 每个 subcommand 调 bashToolCheckPermission
-> 任意 deny 则整体 deny
-> 原始命令再做路径检查
-> 全部 allow 且无注入风险才 allow
-> 其他情况 ask 或 passthrough
最重要的理解是:
text
Bash 权限系统不能只看命令名。
它必须理解整个 shell 字符串的结构。
尤其要记住:
text
子命令安全,不代表原始命令安全。
因为原始命令可能包含:
text
重定向写文件
管道组合
cd 后路径变化
命令替换
解析器差异
checkCommandAndSuggestRules:权限判断后的规则建议
checkCommandAndSuggestRules() 解决的问题是:
text
当 Bash 命令没有被直接 allow / deny / ask 时,
runtime 要不要给用户一个"以后允许类似命令"的建议规则?
它不是最底层的权限判断函数。
它是在 bashToolHasPermission() 拆出子命令后,用来做二次判断和 suggestions 生成的。
第一步:先检查 exact match。
ts
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
}
这里先看完整命令是否已经有精确规则。
例如:
text
Bash(npm test)
Bash(git status)
如果 exact match 已经返回 allow / deny / ask,就直接返回。
只有它是 passthrough,才继续往下判断。
可以这样记:
text
已有精确规则 -> 直接尊重
没有精确规则 -> 继续检查 prefix / 安全风险 / 建议规则
第二步:调用 bashToolCheckPermission() 做命令权限判断。
ts
// 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() 会检查:
text
prefix deny / ask / allow
path constraints
sed constraints
permission mode
read-only command
passthrough
如果结果已经是 deny 或 ask,说明这条命令已经有明确风险或明确规则,不需要再生成新的建议,直接返回。
第三步:检查命令注入风险。
ts
// 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 没成功,就调用:
ts
bashCommandIsSafeAsync(input.command)
检查命令注入、解析误判等风险。
如果发现风险,返回 ask,并且:
ts
suggestions: []
这表示:
text
危险或可疑命令可以让用户临时确认,
但不建议保存成长期 allow rule。
这是一个很重要的安全设计。
第四步:如果命令已经 allow,直接返回。
ts
// 4. Allow if command was allowed
if (permissionResult.behavior === 'allow') {
return permissionResult
}
如果 bashToolCheckPermission() 已经判断命令可以允许,就不需要生成额外建议。
第五步:生成建议规则。
ts
// 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。
这时函数会给用户建议一个可保存的权限规则:
text
如果能提取 command prefix -> 建议 prefix rule
否则 -> 建议 exact command rule
例如:
text
npm test -- --watch
可能建议保存成:
text
Bash(npm test:*)
而不是保存完整命令。
这样下次参数稍微变化时,规则仍然可复用。
CommandPrefixResult 的结构很简单:
ts
export type CommandPrefixResult = {
/** The detected command prefix, or null if no prefix could be determined */
commandPrefix: string | null
}
建议规则的生成逻辑在 suggestionForExactCommand() 里:
ts
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。
优先级是:
text
heredoc 命令 -> 提取 heredoc 前面的稳定 prefix
多行命令 -> 用第一行作为 prefix
普通单行命令 -> 尽量提取 2-word prefix
实在提不出 prefix -> 才保存 exact command
这样做是为了避免保存一些"下次永远匹配不上"的规则。
这一节可以这样记:
text
checkCommandAndSuggestRules =
权限判断 + 生成可保存规则建议。
它的主线是:
text
exact match
-> bashToolCheckPermission()
-> 命令注入安全检查
-> allow 直接返回
-> passthrough 时生成 suggestions
最重要的点是:
text
危险命令可以 ask,
但不建议保存为长期 allow rule。
所以源码里遇到安全风险时会返回:
ts
suggestions: []
而普通未匹配命令才会建议:
text
prefix rule 或 exact command rule
这说明 Claude Code 的权限系统不只是"是否执行",还会引导用户把临时确认沉淀成更稳定的权限规则
Bash 安全检查:为什么 shell 特别难
bashSecurity.ts 的整体角色
源码位置:
src/tools/BashTool/bashSecurity.ts:1-127src/tools/BashTool/bashSecurity.ts:846-903src/tools/BashTool/bashSecurity.ts:2426-2592src/tools/BashTool/bashPermissions.ts:72-88
bashSecurity.ts 解决的问题不是:
text
这条 Bash 命令有没有 allow rule?
而是:
text
这条 Bash 字符串有没有命令注入、解析误判、重定向、命令替换等安全风险?
在 bashPermissions.ts 中,实际使用的是:
ts
import {
bashCommandIsSafeAsync_DEPRECATED,
stripSafeHeredocSubstitutions,
} from './bashSecurity.js'
const bashCommandIsSafeAsync = bashCommandIsSafeAsync_DEPRECATED
名字里有 DEPRECATED,说明它是 legacy regex / shell-quote 路径。
当前主方向是迁移到 tree-sitter AST,但这条路径仍然在 Bash 权限系统里作为安全检查使用。
文件开头先定义危险 shell 模式:
ts
// 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 权限判断不能只看第一个命令名。
例如:
bash
echo $(curl evil.com)
=cat /etc/passwd
表面命令可能看起来普通,但 shell 展开后实际行为会变化。
典型 validator 之一是 validateDangerousPatterns():
ts
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():
ts
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() 是这组安全检查的入口:
ts
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:
ts
const context: ValidationContext = {
originalCommand: command,
baseCommand,
unquotedContent: withDoubleQuotes,
fullyUnquotedContent: stripSafeRedirections(fullyUnquoted),
fullyUnquotedPreStrip: fullyUnquoted,
unquotedKeepQuoteChars,
treeSitter: tsAnalysis,
}
这里把同一条命令拆成多个视图:
text
originalCommand:原始命令
unquotedContent:去掉单引号,但保留双引号内容
fullyUnquotedContent:去掉引号内容,并剥掉安全重定向
fullyUnquotedPreStrip:剥重定向前的 fullyUnquoted
treeSitter:AST 提供的更准确信息
需要这么多视图,是因为 Bash 中同一个字符在不同 quote 环境下含义不同。
接着按顺序跑 validators:
ts
const validators = [
validateJqCommand,
validateObfuscatedFlags,
validateShellMetacharacters,
validateDangerousVariables,
validateCommentQuoteDesync,
validateQuotedNewline,
validateCarriageReturn,
validateNewlines,
validateIFSInjection,
validateProcEnvironAccess,
validateDangerousPatterns,
validateRedirections,
validateBackslashEscapedWhitespace,
validateBackslashEscapedOperators,
validateUnicodeWhitespace,
validateMidWordHash,
validateBraceExpansion,
validateZshDangerousCommands,
validateMalformedTokenInjection,
]
每个 validator 负责一种 shell 风险。
最后有一个重要设计:不是所有 ask 都一样。
ts
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
}
这里区分两类风险:
text
non-misparsing ask:
普通换行、重定向,属于正常 shell 模式,需要 ask。
misparsing ask:
可能导致 splitCommand / shell-quote 理解错命令结构,更危险。
普通 ask 会先暂存,继续跑后面的 validator。
如果后面发现 misparsing 风险,就优先返回 misparsing ask。
可以这样记:
text
bashSecurity.ts =
Bash 字符串安全分析器。
它不负责判断用户是否授权,
它负责判断这条 shell 字符串是否存在语法级绕过风险。
返回值含义:
text
passthrough:
没发现安全风险,交给权限规则继续判断。
ask:
发现需要人工确认的 shell 风险。
isBashSecurityCheckForMisparsing:
这是更危险的解析误判风险,上层要更早拦住。
bashCommandIsSafeAsync:命令注入与误解析检查
源码位置:
src/tools/BashTool/bashSecurity.ts:2257-2412src/tools/BashTool/bashSecurity.ts:2426-2592
bashCommandIsSafeAsync_DEPRECATED() 是 Bash 安全检查的入口。
它解决的问题是:
text
这条 Bash command 有没有命令注入、解析误判、危险展开、重定向等语法级风险?
注意它不是权限授权器。
它返回 passthrough 只表示:
text
安全检查没发现明显 shell 风险,
后续还要交给权限规则判断。
入口先尝试 tree-sitter:
ts
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.
...
}
这里有两条路径:
text
有 tree-sitter:
用 AST 提供的 quote context 做更准确分析。
没有 tree-sitter:
退回 legacy regex / shell-quote 路径。
然后先拦截控制字符和 shell-quote 已知 bug:
ts
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,
}
}
这里直接标记:
ts
isBashSecurityCheckForMisparsing: true
意思是:
text
这类风险可能让权限系统"看错命令结构"。
接着构造 ValidationContext:
ts
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,
}
同一条命令会被拆成多个视图:
text
originalCommand:原始命令
baseCommand:第一个命令词
unquotedContent:去掉单引号,但保留双引号内容
fullyUnquotedContent:去掉引用内容,并剥掉安全重定向
fullyUnquotedPreStrip:剥重定向前的内容
treeSitter:AST 分析结果
需要多个视图,是因为 Bash 中同一个字符在不同 quote 环境下含义不同。
然后先跑 early validators:
ts
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
}
}
这里最重要的是:
text
validator 返回 allow,
主函数也会转成 passthrough。
因为安全检查里的 allow 只表示:
text
没有发现 shell 注入风险。
不表示用户已经授权执行。
普通 validators 是 Bash 安全检查的主体:
ts
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:
ts
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',
}
这里的策略是:
text
普通 ask 先暂存。
继续跑后面的 validator。
如果发现 misparsing 风险,优先返回 misparsing ask。
如果没有,再返回普通 ask。
这样可以避免普通重定向 > 提前返回,导致后面更危险的解析绕过没有被发现。
可以这样记:
text
bashCommandIsSafeAsync =
Bash 语法安全检查器。
passthrough:
没发现语法风险,交给权限规则继续判断。
ask:
发现需要确认的 shell 风险。
ask + isBashSecurityCheckForMisparsing:
发现解析误判风险,需要更保守处理。
路径与文件权限约束
checkPathConstraints:命令访问路径的权限边界
源码位置:src/tools/BashTool/pathValidation.ts:820-1109
前面讲 Bash 权限时,重点是"这条命令本身是否被规则允许"。
但 shell 命令还有另一个维度:
text
命令要访问哪个路径?
这个路径是在工作区内,还是在外部?
是读,还是写?
有没有重定向?
有没有 cd 改变相对路径语义?
这就是 pathValidation.ts 要解决的问题。
先看单条 path command 的验证入口:
ts
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)
}
这段做了几件事:
- 先剥掉安全 wrapper,例如
timeout 10 rm -rf /不能只看到timeout。 - 解析命令参数。
- 只处理支持路径检查的命令,比如
cd、ls、find、rm、sed等。 - 判断这个命令对应 read / write / create 哪种文件操作。
- 把路径参数交给对应的
pathChecker。
这里最重要的是第一步:
ts
const strippedCmd = stripSafeWrappers(cmd)
如果不剥 wrapper,危险命令可以伪装成:
bash
timeout 10 rm -rf ~/.ssh
权限系统如果只看第一个词 timeout,就会漏掉真正的 rm。
源码还提供了 AST 版本:
ts
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。
原因是源码注释写得很明确:
text
Avoids the shell-quote single-quote backslash bug.
也就是说,路径校验也要避免"解析器看错命令"。
checkPathConstraints() 还专门检查输出重定向:
ts
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
}
为什么重定向要单独看?
因为这类命令表面上可能像读:
bash
echo hello > ~/.ssh/authorized_keys
真正危险的不是 echo,而是 > 后面的写入目标。
所以 Claude Code 不能只判断 base command,还要扫描 shell 语法里的输出路径。
最后,它会遍历所有子命令:
ts
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',
}
可以这样记:
text
checkPathConstraints =
看 shell 命令里的路径副作用。
它不是最终 allow,
而是发现路径风险时提前 ask / deny;
没发现就 passthrough 给后面的权限规则继续判断。
read / write / sensitive path 的区别
源码位置:
src/utils/permissions/filesystem.ts:1030-1194src/utils/permissions/filesystem.ts:1205-1235src/utils/permissions/filesystem.ts:1479-1645src/utils/permissions/pathValidation.ts:141-230
文件路径权限里有一个关键区分:
text
read:读取文件或目录
edit:修改已有文件
create:创建新文件或目录
先看读权限入口:
ts
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 辅助字段,而是权限判断输入。
读权限判断的顺序很保守:
ts
// 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', ... }
这里的顺序很重要:
text
read deny / ask
要早于
edit implies read
否则用户显式拒绝读取某个路径,却因为有编辑权限而被绕过。
写权限入口是:
ts
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 },
}
}
}
...
}
写权限通常比读权限更严格,因为它会改变真实文件系统。
源码还给一些内部路径开了特殊通道:
ts
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。
读内部路径也有类似函数:
ts
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)) { ... }
...
}
可以这样记:
text
普通工作区路径:
读通常可自动允许。
写通常要看 mode / rule。
工作区外路径:
默认 ask。
敏感路径:
即使 mode 宽松,也可能 safetyCheck ask。
内部 runtime 路径:
只对明确用途开小口子。
cd、重定向和路径解析为什么会影响权限
Bash 里的路径不是静态字符串。
这些写法都会改变权限判断难度:
bash
cd /tmp && rm file
echo data > output.txt
echo data > "$TARGET"
timeout 5 rm -rf ./dist
所以源码里反复出现几个防御点:
text
stripSafeWrappers:
去掉 timeout / nice / nohup / time 等 wrapper,看到真实命令。
compoundCommandHasCd:
标记整条复合命令里是否出现 cd。
astRedirects:
用 tree-sitter 提取重定向目标,避免 shell-quote 误解析。
getPathsForPermissionCheck:
同时检查原始路径和解析 symlink 后的路径。
checkPathSafetyForAutoEdit:
对 .git、.claude、shell 配置、credential 等敏感文件做额外安全检查。
这里的核心思想是:
text
权限系统不能只看"命令字符串长什么样"。
它必须尽量还原 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 校验:
ts
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():
ts
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 区分了两类失败:
text
schema validation:
模型生成的参数类型不对。
tool.validateInput:
参数类型对,但具体值不适合执行。
然后进入 hooks 和权限解析:
ts
const resolved = await resolveHookPermissionDecision(
hookPermissionResult,
tool,
processedInput,
toolUseContext,
canUseTool,
assistantMessage,
toolUseID,
)
const permissionDecision = resolved.decision
processedInput = resolved.input
如果最终不是 allow:
ts
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 的关键:
text
deny / ask 没有被用户批准
-> 不是程序崩溃
-> 构造 user message
-> content 里放 tool_result
-> is_error: true
-> 下一轮模型看到错误原因
权限拒绝为什么不会让 agent 崩溃
如果权限拒绝直接抛异常,agent loop 会变成:
text
模型请求工具
-> 本地拒绝
-> 程序异常
-> 对话断掉
Claude Code 采用的是另一种设计:
text
模型请求工具
-> 本地拒绝
-> 拒绝原因作为 tool_result
-> 模型根据拒绝原因换方案
这很符合 agent 系统的闭环:
text
tool_use 不一定成功。
tool_result 可以是成功结果,也可以是结构化失败。
失败本身也是模型下一步推理的上下文。
所以权限拒绝不是"异常路径",而是 agent loop 的正常分支。
deny / ask 如何影响下一轮模型推理
权限拒绝回到模型后,模型会看到类似:
text
Error: Permission to use Bash with command ... has been denied.
或者:
text
Error: Claude requested permissions to read from /some/path,
but you haven't granted it yet.
这会影响下一轮模型:
- 如果是路径越界,它可能改读工作区内文件。
- 如果是 Bash 被拒绝,它可能改用 Read/Grep/Glob。
- 如果是写入被拒绝,它可能先解释计划,等待用户授权。
- 如果是命令太复杂,它可能拆成更简单的命令。
这就是权限系统在 agent loop 中的真实作用:
text
不是单纯阻止模型,
而是把边界反馈给模型,
让模型在边界内继续完成任务。
Phase 5 总结:模型提动作,runtime 做裁决
Phase5 完整调用链
text
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 这么复杂。
可以先抽象三层:
python
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",
)
然后在工具执行层里统一处理:
python
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))
你可以这样记:
text
权限系统最小抽象 =
PermissionMode:当前会话策略
PermissionBehavior:本次工具调用结果
PermissionDecision:行为 + 原因
PermissionManager:集中裁决
tool_result:把拒绝反馈给模型
Phase5 的核心不是"把危险命令列全",而是理解这个架构分工:
text
模型负责提出动作。
工具负责声明能力。
权限系统负责裁决。
执行系统负责真实动作。
message loop 负责把成功或失败都反馈给模型。