OpenClaw 工具调用全链路深度解析:一条 exec 命令的七道闸门

0 阅读17分钟

本文基于 OpenClaw 源码深度分析,完整还原一条 exec 工具调用从 AI 模型产出到操作系统进程启动的全过程,重点拆解其中七道安全闸门的实现细节。


一、引子:工具调用为什么复杂

当你在 OpenClaw 对话框里发出 "帮我跑一下这个脚本" 时,AI 模型不会直接操控你的终端。它会发出一个结构化的 工具调用(tool call),由 OpenClaw 的执行层接管。这看起来很简单——不就是 child_process.spawn 吗?

实际上,从模型产出 exec 调用到操作系统真正 fork 出进程,中间至少经历七道完整的处理阶段:

  1. 工具注册与参数规范化 — 决定这个工具是谁、能做什么
  2. Host 路由与提权裁决 — 命令要在哪里跑、是否需要 elevated 权限
  3. 环境变量净化 — 阻止宿主机敏感信息泄漏进子进程
  4. Shell 语法分析 — 静态解析命令链,建立可审计的执行段列表
  5. 白名单与 SafeBin 评估 — 每个命令段对照允许列表做多源匹配
  6. 混淆检测 — 拦截 base64/eval/curl-pipe-shell 等绕过模式
  7. 审批流程 — 需要人工确认时挂起执行,等待授权信号

只有全部通过,才会进入真正的 spawn 阶段。本文将逐层拆解这七道闸门的源码实现。


二、工具的骨架:createExecToolexecSchema

一切从 createExecTool 开始。它是一个工厂函数,接收配置 ExecToolDefaults,返回一个符合 AgentTool 接口的工具对象。

2.1 参数规范定义

// src/agents/bash-tools.exec-runtime.ts L98-144
export const execSchema = Type.Object({
  command: Type.String({ description: "Shell command to execute" }),
  workdir: Type.Optional(Type.String({ description: "Working directory (defaults to cwd)" })),
  env: Type.Optional(Type.Record(Type.String(), Type.String())),
  yieldMs: Type.Optional(
    Type.Number({
      description: "Milliseconds to wait before backgrounding (default 10000)",
    }),
  ),
  background: Type.Optional(Type.Boolean({ description: "Run in background immediately" })),
  timeout: Type.Optional(
    Type.Number({
      description: "Timeout in seconds (optional, kills process on expiry)",
    }),
  ),
  pty: Type.Optional(
    Type.Boolean({
      description:
        "Run in a pseudo-terminal (PTY) when available (TTY-required CLIs, coding agents)",
    }),
  ),
  elevated: Type.Optional(
    Type.Boolean({
      description: "Run on the host with elevated permissions (if allowed)",
    }),
  ),
  host: Type.Optional(
    Type.String({
      description: "Exec host (sandbox|gateway|node).",
    }),
  ),
  security: Type.Optional(
    Type.String({
      description: "Exec security mode (deny|allowlist|full).",
    }),
  ),
  ask: Type.Optional(
    Type.String({
      description: "Exec ask mode (off|on-miss|always).",
    }),
  ),
  node: Type.Optional(
    Type.String({
      description: "Node id/name for host=node.",
    }),
  ),
});

这个 Schema 使用 TypeBox 定义,AI 模型在生成工具调用时必须符合该结构。注意 hostsecurityask 都是可选字符串而非枚举类型——这是有意为之的设计:运行时规范化比编译时严格枚举更灵活,同时后续会通过 normalizeExecHost/normalizeExecSecurity/normalizeExecAsk 做严格的值域验证。

2.2 ExecToolDefaults 的深度配置

// src/agents/bash-tools.exec-types.ts L5-30
export type ExecToolDefaults = {
  host?: ExecHost;
  security?: ExecSecurity;
  ask?: ExecAsk;
  node?: string;
  pathPrepend?: string[];
  safeBins?: string[];
  safeBinTrustedDirs?: string[];
  safeBinProfiles?: Record<string, SafeBinProfileFixture>;
  agentId?: string;
  backgroundMs?: number;
  timeoutSec?: number;
  approvalRunningNoticeMs?: number;
  sandbox?: BashSandboxConfig;
  elevated?: ExecElevatedDefaults;
  allowBackground?: boolean;
  scopeKey?: string;
  sessionKey?: string;
  messageProvider?: string;
  currentChannelId?: string;
  currentThreadTs?: string;
  accountId?: string;
  notifyOnExit?: boolean;
  notifyOnExitEmptySuccess?: boolean;
  cwd?: string;
};

ExecToolDefaults 是创建工具时的"出厂设置",而模型调用时传入的参数是"运行时覆盖"。两者的关系是:运行时参数不能越出出厂设置的边界,除非开启了 elevated 提权。这是整个权限模型的基础约定。

2.3 工厂函数的预计算阶段

createExecTool 在工厂阶段就完成了一批昂贵计算,避免每次调用重复计算:

// src/agents/bash-tools.exec.ts L151-201
export function createExecTool(
  defaults?: ExecToolDefaults,
): AgentTool<any, ExecToolDetails> {
  // 1. 计算后台超时窗口(clamp 到 [10, 120000] ms)
  const defaultBackgroundMs = clampWithDefault(
    defaults?.backgroundMs ?? readEnvInt("PI_BASH_YIELD_MS"),
    10_000,
    10,
    120_000,
  );

  // 2. 计算默认超时(1800秒 = 30分钟)
  const defaultTimeoutSec =
    typeof defaults?.timeoutSec === "number" && defaults.timeoutSec > 0
      ? defaults.timeoutSec
      : 1800;

  // 3. 解析 SafeBin 策略(含未剖析警告)
  const {
    safeBins,
    safeBinProfiles,
    trustedSafeBinDirs,
    unprofiledSafeBins,
    unprofiledInterpreterSafeBins,
  } = resolveExecSafeBinRuntimePolicy({
    local: {
      safeBins: defaults?.safeBins,
      safeBinTrustedDirs: defaults?.safeBinTrustedDirs,
      safeBinProfiles: defaults?.safeBinProfiles,
    },
    onWarning: (message) => {
      logInfo(message);
    },
  });

  // 4. 记录无 profile 的 SafeBin 警告
  if (unprofiledSafeBins.length > 0) {
    logInfo(
      `exec: ignoring unprofiled safeBins entries (${unprofiledSafeBins.toSorted().join(", ")}); use allowlist or define tools.exec.safeBinProfiles.<bin>`,
    );
  }
  if (unprofiledInterpreterSafeBins.length > 0) {
    logInfo(
      `exec: interpreter/runtime binaries in safeBins (${unprofiledInterpreterSafeBins.join(", ")}) are unsafe without explicit hardened profiles; prefer allowlist entries`,
    );
  }

  // 5. 从 sessionKey 解析 agentId
  const parsedAgentSession = parseAgentSessionKey(defaults?.sessionKey);
  const agentId =
    defaults?.agentId ??
    (parsedAgentSession ? resolveAgentIdFromSessionKey(defaults?.sessionKey) : undefined);
  // ...
}

这个设计把 "策略解析" 从 "请求处理" 中剥离出来。resolveExecSafeBinRuntimePolicy 会读取本地配置、合并全局 SAFE_BIN_PROFILES、验证每个 safeBin 条目是否有对应的 profile——这些都是可以复用的计算结果。


三、第一道闸门:Host 路由与提权裁决

每个 exec 请求首先要确定在哪里执行——这是 Host 路由,决定了后续所有安全策略的适用范围。

3.1 三种执行主机

OpenClaw 定义了三种执行主机:

// src/infra/exec-approvals.ts L10
export type ExecHost = "sandbox" | "gateway" | "node";
  • sandbox:在 Docker 容器内执行,最安全,默认选项
  • gateway:在宿主机(网关进程所在机器)上执行
  • node:在某个远程节点上执行(多节点分布式场景)

3.2 Host 裁决逻辑

// src/agents/bash-tools.exec.ts L307-319
const configuredHost = defaults?.host ?? "sandbox";
const sandboxHostConfigured = defaults?.host === "sandbox";
const requestedHost = normalizeExecHost(params.host) ?? null;
let host: ExecHost = requestedHost ?? configuredHost;

// 非提权模式下,运行时请求的 host 不得与出厂配置不同
if (!elevatedRequested && requestedHost && requestedHost !== configuredHost) {
  throw new Error(
    `exec host not allowed (requested ${renderExecHostLabel(requestedHost)}; ` +
      `configure tools.exec.host=${renderExecHostLabel(configuredHost)} to allow).`,
  );
}

// 提权模式下强制走 gateway(直接访问宿主机)
if (elevatedRequested) {
  host = "gateway";
}

关键约束:AI 模型不能自行切换执行主机。如果出厂配置是 sandbox,模型传入 host=gateway 会直接报错。唯一的例外是 elevated=true 模式——此时强制走 gateway,但需要通过独立的提权闸门。

3.3 提权闸门

// src/agents/bash-tools.exec.ts L248-303
const elevatedDefaults = defaults?.elevated;
const elevatedAllowed = Boolean(elevatedDefaults?.enabled && elevatedDefaults.allowed);
const elevatedDefaultMode =
  elevatedDefaults?.defaultLevel === "full"
    ? "full"
    : elevatedDefaults?.defaultLevel === "ask"
      ? "ask"
      : elevatedDefaults?.defaultLevel === "on"
        ? "ask"    // "on" 映射为 "ask",保守处理
        : "off";
const effectiveDefaultMode = elevatedAllowed ? elevatedDefaultMode : "off";

// 模型请求 elevated=true 时,映射为具体模式
const elevatedMode =
  typeof params.elevated === "boolean"
    ? params.elevated
      ? elevatedDefaultMode === "full"
        ? "full"
        : "ask"
      : "off"
    : effectiveDefaultMode;

const elevatedRequested = elevatedMode !== "off";

// 双重检查:enabled + allowed 两个门都要打开
if (elevatedRequested) {
  if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
    throw new Error(
      [
        `elevated is not available right now (runtime=${runtime}).`,
        `Failing gates: ${gates.join(", ")}`,
        "Fix-it keys:",
        "- tools.elevated.enabled",
        "- tools.elevated.allowFrom.<provider>",
        // ...
      ].filter(Boolean).join("\n"),
    );
  }
}

提权需要同时满足:

  1. tools.elevated.enabled = true(全局开关)
  2. tools.elevated.allowFrom.<provider> = true(按来源渠道授权)

full 模式下还会额外做 security = "full"ask = "off" 的强制覆盖,跳过所有后续安全检查——这是最高权限。


四、第二道闸门:安全模式(Security)与审批模式(Ask)的组合矩阵

OpenClaw 的命令安全策略由两个独立维度的交叉积构成:

// src/infra/exec-approvals.ts L11-12
export type ExecSecurity = "deny" | "allowlist" | "full";
export type ExecAsk = "off" | "on-miss" | "always";

Security 三级

  • deny:拒绝所有执行(最严格,适合只读代理)
  • allowlist:仅允许白名单中的命令
  • full:允许所有命令(与 elevated full 配合使用)

Ask 三级

  • off:无需审批,直接执行
  • on-miss:白名单未命中时请求审批
  • always:每次执行都需要审批

4.1 安全级别的 minSecurity 原则

// src/infra/exec-approvals.ts L547-554
export function minSecurity(a: ExecSecurity, b: ExecSecurity): ExecSecurity {
  const order: Record<ExecSecurity, number> = { deny: 0, allowlist: 1, full: 2 };
  return order[a] <= order[b] ? a : b;
}

export function maxAsk(a: ExecAsk, b: ExecAsk): ExecAsk {
  const order: Record<ExecAsk, number> = { off: 0, "on-miss": 1, always: 2 };
  return order[a] >= order[b] ? a : b;
}

这两个函数体现了 "Fail Closed" 哲学:

  • security两者中的最小值(越严格越优先)
  • ask两者中的最大值(越多审批越优先)

运行时模型可以请求更宽松的安全级别,但 minSecurity 保证了实际安全级别永远不会超过配置上限。

// src/agents/bash-tools.exec.ts L321-334
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
const requestedSecurity = normalizeExecSecurity(params.security);
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);

const configuredAsk = defaults?.ask ?? loadExecApprovals().defaults?.ask ?? "on-miss";
const requestedAsk = normalizeExecAsk(params.ask);
let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);

// elevated full 模式绕过所有审批
const bypassApprovals = elevatedRequested && elevatedMode === "full";
if (bypassApprovals) {
  ask = "off";
}

沙箱模式的特殊处理:当 host === "sandbox" 时,默认 security 为 "deny"——沙箱环境本身就是隔离的,不需要 allowlist 检查,直接拦截敏感命令注入即可。


五、第三道闸门:环境变量净化

5.1 沙箱与宿主机的分岔

环境变量处理在沙箱路径和宿主机路径上走不同的逻辑:

// src/agents/bash-tools.exec.ts L364-400
const inheritedBaseEnv = coerceEnv(process.env);
// 沙箱直接继承宿主机完整 env;gateway/node 需要净化
const baseEnv = host === "sandbox" ? inheritedBaseEnv : sanitizeHostBaseEnv(inheritedBaseEnv);

// 宿主机路径:在合并之前先验证模型提供的 env
if (host !== "sandbox" && params.env) {
  validateHostEnv(params.env);
}

const mergedEnv = params.env ? { ...baseEnv, ...params.env } : baseEnv;

// 沙箱路径:重建干净的 env(只保留 PATH + sandbox.env + params.env)
const env = sandbox
  ? buildSandboxEnv({
      defaultPath: DEFAULT_PATH,
      paramsEnv: params.env,
      sandboxEnv: sandbox.env,
      containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
    })
  : mergedEnv;

这个设计很微妙:沙箱继承完整宿主机 env,然后再被 buildSandboxEnv 重建覆盖。为什么?因为 Docker exec 命令本身是在宿主机上发起的,宿主机 env 会影响 Docker 进程本身,但容器内的实际执行环境由 -e 参数注入,由 buildSandboxEnv 精确控制。

5.2 sanitizeHostBaseEnv:宿主机环境净化

// src/agents/bash-tools.exec-runtime.ts L40-54
export function sanitizeHostBaseEnv(env: Record<string, string>): Record<string, string> {
  const sanitized: Record<string, string> = {};
  for (const [key, value] of Object.entries(env)) {
    const upperKey = key.toUpperCase();
    if (upperKey === "PATH") {
      sanitized[key] = value;    // PATH 允许继承
      continue;
    }
    if (isDangerousHostEnvVarName(upperKey)) {
      continue;                  // 危险变量直接丢弃
    }
    sanitized[key] = value;
  }
  return sanitized;
}

5.3 validateHostEnv:模型提供 env 的强校验

// src/agents/bash-tools.exec-runtime.ts L57-76
export function validateHostEnv(env: Record<string, string>): void {
  for (const key of Object.keys(env)) {
    const upperKey = key.toUpperCase();

    // 1. 阻断已知危险变量(Fail Closed)
    if (isDangerousHostEnvVarName(upperKey)) {
      throw new Error(
        `Security Violation: Environment variable '${key}' is forbidden during host execution.`,
      );
    }

    // 2. 严格阻断 PATH 修改(防止二进制劫持)
    if (upperKey === "PATH") {
      throw new Error(
        "Security Violation: Custom 'PATH' variable is forbidden during host execution.",
      );
    }
  }
}

isDangerousHostEnvVarName 内部引用了一个 JSON 策略文件 host-env-security-policy.json,其中维护了需要阻断的环境变量名称列表和前缀列表(如 LD_DYLD_ 等动态链接器相关变量,它们可以被用于劫持共享库加载路径)。

这里对 PATH 的处理特别值得关注:净化函数允许继承 PATH,但验证函数禁止模型覆盖 PATH。这是因为宿主机继承的 PATH 是可信的,而模型注入的 PATH 可能包含恶意路径前缀,导致系统命令被劫持。

5.4 沙箱环境的精确重建

// src/agents/bash-tools.shared.ts L17-34
export function buildSandboxEnv(params: {
  defaultPath: string;
  paramsEnv?: Record<string, string>;
  sandboxEnv?: Record<string, string>;
  containerWorkdir: string;
}) {
  const env: Record<string, string> = {
    PATH: params.defaultPath,         // 固定为已知安全的默认 PATH
    HOME: params.containerWorkdir,    // HOME 指向容器工作目录
  };
  // sandbox.env 覆盖(管理员配置,可信)
  for (const [key, value] of Object.entries(params.sandboxEnv ?? {})) {
    env[key] = value;
  }
  // params.env 覆盖(模型提供,最后合并)
  for (const [key, value] of Object.entries(params.paramsEnv ?? {})) {
    env[key] = value;
  }
  return env;
}

沙箱环境从零重建:只有白纸黑字写进去的变量才存在于容器里。默认 PATH 是固定字符串,HOME 指向容器工作目录——这防止了容器内进程访问宿主机家目录的可能。

5.5 Docker 命令构建中的 PATH 处理

// src/agents/bash-tools.shared.ts L49-87
export function buildDockerExecArgs(params: {
  containerName: string;
  command: string;
  workdir?: string;
  env: Record<string, string>;
  tty: boolean;
}) {
  const args = ["exec", "-i"];
  // ...
  for (const [key, value] of Object.entries(params.env)) {
    // 跳过 PATH——Windows 宿主机 PATH 里有反斜杠路径,
    // 通过 -e 传入会毒化 Docker 的可执行文件查找
    if (key === "PATH") {
      continue;
    }
    args.push("-e", `${key}=${value}`);
  }
  const hasCustomPath = typeof params.env.PATH === "string" && params.env.PATH.length > 0;
  if (hasCustomPath) {
    // 通过特殊的 OPENCLAW_PREPEND_PATH 间接传递,避免插值到 shell 命令里
    args.push("-e", `OPENCLAW_PREPEND_PATH=${params.env.PATH}`);
  }
  // login shell (-l) 会 source /etc/profile 重置 PATH,
  // 所以在 profile 之后再 export 我们的 PATH
  const pathExport = hasCustomPath
    ? 'export PATH="${OPENCLAW_PREPEND_PATH}:$PATH"; unset OPENCLAW_PREPEND_PATH; '
    : "";
  args.push(params.containerName, "/bin/sh", "-lc", `${pathExport}${params.command}`);
  return args;
}

这个 PATH 处理方案解决了一个跨平台陷阱:Windows 宿主机的 PATH 包含反斜杠路径(C:\Windows\System32),直接通过 -e PATH=... 传给 Docker 会导致 Linux 容器里的 sh 找不到。通过 OPENCLAW_PREPEND_PATH 中转,在 -lc 的 shell 脚本里再合并,既保留了路径,又避免了格式冲突。


六、第四道闸门:Shell 语法分析

在评估白名单之前,需要先把命令字符串解析成结构化的执行段列表。这是 analyzeShellCommandsplitShellPipeline 的职责。

6.1 命令链分割

// src/infra/exec-approvals-analysis.ts L24-31
export type ExecCommandAnalysis = {
  ok: boolean;
  reason?: string;
  segments: ExecCommandSegment[];
  chains?: ExecCommandSegment[][];  // 按链操作符分组(&&, ||, ;)
};

对于 cmd1 && cmd2 || cmd3; cmd4 这样的命令链,解析器需要:

  1. 识别 &&||; 这三种链操作符
  2. 把每个独立命令提取为一个 ExecCommandSegment
  3. 对每个 segment 做 argv 解析和可执行文件路径解析
// src/infra/exec-approvals-allowlist.ts L530-610
export function evaluateShellAllowlist(
  params: { command: string; env?: NodeJS.ProcessEnv } & ExecAllowlistContext,
): ExecAllowlistAnalysis {
  // 保守策略:行续接符(\<newline>)语义复杂,直接返回失败
  if (hasShellLineContinuation(params.command)) {
    return analysisFailure();
  }

  // Windows 平台不走链分割(PowerShell 解析规则不同)
  const chainParts = isWindowsPlatform(params.platform) ? null : splitCommandChain(params.command);

  if (!chainParts) {
    // 无链操作符:直接分析单个命令
    const analysis = analyzeShellCommand({ command: params.command, ... });
    const evaluation = evaluateExecAllowlist({ analysis, ...allowlistContext });
    return { analysisOk: true, allowlistSatisfied: evaluation.allowlistSatisfied, ... };
  }

  // 有链操作符:每个 part 单独分析,全部通过才算 satisfied
  for (const part of chainParts) {
    const analysis = analyzeShellCommand({ command: part, ... });
    const evaluation = evaluateExecAllowlist({ analysis, ...allowlistContext });
    if (!evaluation.allowlistSatisfied) {
      return { analysisOk: true, allowlistSatisfied: false, ... };
    }
  }
  return { analysisOk: true, allowlistSatisfied: true, ... };
}

链式命令的全通过原则cmd1 && cmd2 中,只要 cmd2 不在白名单里,整个命令就会被拒绝或触发审批。这防止了通过链接一个合法命令来"携带"一个恶意命令绕过检查。

6.2 Heredoc 的特殊处理

shell 的 heredoc(<<EOF)是解析器最复杂的部分之一:

// src/infra/exec-approvals-analysis.ts L80-200(片段)
// 解析 heredoc 的定界符,支持带引号的定界符(禁止展开)和不带引号(允许展开)
const parseHeredocDelimiter = (source, start) => {
  // ...
  if (first === "'" || first === '"') {
    // 带引号的定界符:内容不展开(安全)
    return { delimiter, end: i + 1, quoted: true };
  }
  // 不带引号的定界符:内容可能含 $() 展开
  return { delimiter, end: i, quoted: false };
};

// 在 heredoc 体内检测命令替换
const hasUnquotedHeredocExpansionToken = (line: string): boolean => {
  for (let i = 0; i < line.length; i++) {
    const ch = line[i];
    if (ch === "`" && !isEscapedInHeredocLine(line, i)) {
      return true;   // 反引号命令替换
    }
    if (ch === "$" && !isEscapedInHeredocLine(line, i)) {
      const next = line[i + 1];
      if (next === "(" || next === "{") {
        return true;  // $() 或 ${} 展开
      }
    }
  }
  return false;
};

当 heredoc 定界符不带引号时(如 cat <<EOF),heredoc 体内可以包含 $(...) 命令替换。解析器会检测这种情况,并在 processGatewayAllowlist 中强制触发审批:

// src/agents/bash-tools.exec-host-gateway.ts L123-141
const hasHeredocSegment = allowlistEval.segments.some((segment) =>
  segment.argv.some((token) => token.startsWith("<<")),
);
const requiresHeredocApproval =
  hostSecurity === "allowlist" && analysisOk && allowlistSatisfied && hasHeredocSegment;

if (requiresHeredocApproval) {
  params.warnings.push(
    "Warning: heredoc execution requires explicit approval in allowlist mode.",
  );
}

即使 heredoc 命令本身(如 cat)在白名单里,只要检测到 heredoc,也会触发审批。这堵住了通过 heredoc 注入任意代码的漏洞。


七、第五道闸门:白名单评估与 SafeBin 机制

7.1 三源匹配

白名单评估的核心函数 evaluateSegments 对每个命令段做三路并行匹配:

// src/infra/exec-approvals-allowlist.ts L198-271
function evaluateSegments(segments, params): {satisfied, matches, segmentSatisfiedBy} {
  const matches: ExecAllowlistEntry[] = [];
  const skillBinTrust = buildSkillBinTrustIndex(params.skillBins);
  const allowSkills = params.autoAllowSkills === true && skillBinTrust.size > 0;
  const segmentSatisfiedBy: ExecSegmentSatisfiedBy[] = [];

  const satisfied = segments.every((segment) => {
    // 路由1:白名单条目直接匹配
    const match = executableMatch ?? shellScriptMatch;

    // 路由2:SafeBin 检查(受信二进制 + 参数 profile)
    const safe = isSafeBinUsage({ argv, resolution, safeBins, safeBinProfiles, ... });

    // 路由3:Skill 自动授权(来自 ClawHub 安装的技能二进制)
    const skillAllow = isSkillAutoAllowedSegment({ segment, allowSkills, skillBinTrust });

    const by: ExecSegmentSatisfiedBy = match
      ? "allowlist"
      : safe
        ? "safeBins"
        : skillAllow
          ? "skills"
          : null;

    segmentSatisfiedBy.push(by);
    return Boolean(by);
  });

  return { satisfied, matches, segmentSatisfiedBy };
}

三源的优先级:allowlist > safeBins > skills。segmentSatisfiedBy 数组记录了每个命令段是被哪种机制放行的,这对审计日志极为重要。

7.2 SafeBin 的双重验证

SafeBin 是一种特殊的快速放行机制——对于已知安全的工具(如 gitnpmcat),只要路径来自可信目录、参数符合 profile,就不需要显式写入 allowlist。

// src/infra/exec-approvals-allowlist.ts L51-96
export function isSafeBinUsage(params: {
  argv: string[];
  resolution: CommandResolution | null;
  safeBins: Set<string>;
  platform?: string | null;
  trustedSafeBinDirs?: ReadonlySet<string>;
  safeBinProfiles?: Readonly<Record<string, SafeBinProfile>>;
}): boolean {
  // Windows 平台保守处理:PowerShell 解析规则不同,不走 SafeBin
  if (isWindowsPlatform(params.platform ?? process.platform)) {
    return false;
  }
  if (params.safeBins.size === 0) {
    return false;
  }

  const execName = resolution?.executableName?.toLowerCase();
  if (!execName || !params.safeBins.has(execName)) {
    return false;   // 不在 safeBins 集合里
  }

  // 路径必须来自受信目录
  if (
    !isTrustedPath({
      resolvedPath: resolution.resolvedPath,
      trustedDirs: params.trustedSafeBinDirs,
    })
  ) {
    return false;
  }

  // 参数必须通过 profile 验证
  const profile = safeBinProfiles[execName];
  if (!profile) {
    return false;   // 没有 profile 就不允许
  }
  return validateSafeBinArgv(argv.slice(1), profile);
}

路径可信判断 + 参数 profile 验证构成了双重屏障:即使攻击者在某个目录放了一个伪装成 git 的恶意二进制,路径信任检查也会拒绝它;即使路径合法,如果参数带有危险 flag(如 --exec-c),profile 验证也会拒绝。

7.3 allow-always 时的模式提取

当用户批准"永远允许"时,resolveAllowAlwaysPatterns 会从命令中提取应该持久化的白名单模式:

// src/infra/exec-approvals-allowlist.ts L507-525
export function resolveAllowAlwaysPatterns(params: {
  segments: ExecCommandSegment[];
  cwd?: string;
  env?: NodeJS.ProcessEnv;
  platform?: string | null;
}): string[] {
  const patterns = new Set<string>();
  for (const segment of params.segments) {
    collectAllowAlwaysPatterns({
      segment,
      cwd: params.cwd,
      env: params.env,
      platform: params.platform,
      depth: 0,
      out: patterns,
    });
  }
  return Array.from(patterns);
}

collectAllowAlwaysPatterns 会展开 shell wrapper(如 zsh -lc "git status"),递归提取内层实际执行的可执行文件路径。这样持久化的是 git 的绝对路径,而不是 zsh 的路径——确保白名单尽可能精细。


八、第六道闸门:混淆检测

OpenClaw 内置了一个专门针对 AI 生成命令的混淆检测器,它的背景是 Issue #8592——AI 模型有时会生成绕过白名单检查的混淆命令。

8.1 检测模式

// src/infra/exec-obfuscation-detect.ts L92-169
const OBFUSCATION_PATTERNS: ObfuscationPattern[] = [
  {
    id: "base64-pipe-exec",
    description: "Base64 decode piped to shell execution",
    regex: /base64\s+(?:-d|--decode)\b.*\|\s*(?:sh|bash|zsh|dash|ksh|fish)\b/i,
  },
  {
    id: "hex-pipe-exec",
    description: "Hex decode (xxd) piped to shell execution",
    regex: /xxd\s+-r\b.*\|\s*(?:sh|bash|zsh|dash|ksh|fish)\b/i,
  },
  {
    id: "printf-pipe-exec",
    description: "printf with escape sequences piped to shell execution",
    regex: /printf\s+.*\\x[0-9a-f]{2}.*\|\s*(?:sh|bash|zsh|dash|ksh|fish)\b/i,
  },
  {
    id: "eval-decode",
    description: "eval with encoded/decoded input",
    regex: /eval\s+.*(?:base64|xxd|printf|decode)/i,
  },
  {
    id: "pipe-to-shell",
    description: "Content piped directly to shell interpreter",
    regex: /\|\s*(?:sh|bash|zsh|dash|ksh|fish)\b(?:\s+[^|;\n\r]+)?\s*$/im,
  },
  {
    id: "octal-escape",
    description: "Bash octal escape sequences (potential command obfuscation)",
    regex: /\$'(?:[^']*\\[0-7]{3}){2,}/,
  },
  {
    id: "hex-escape",
    description: "Bash hex escape sequences (potential command obfuscation)",
    regex: /\$'(?:[^']*\\x[0-9a-fA-F]{2}){2,}/,
  },
  {
    id: "curl-pipe-shell",
    description: "Remote content (curl/wget) piped to shell execution",
    regex: /(?:curl|wget)\s+.*\|\s*(?:sh|bash|zsh|dash|ksh|fish)\b/i,
  },
  {
    id: "var-expansion-obfuscation",
    description: "Variable assignment chain with expansion (potential obfuscation)",
    regex: /(?:[a-zA-Z_]\w{0,2}=[^;\s]+\s*;\s*){2,}[^$]*\$(?:[a-zA-Z_]|\{[a-zA-Z_])/,
  },
  // ...共 14 个模式
];

8.2 不可见 Unicode 清洗

// src/infra/exec-obfuscation-detect.ts L22-90
const INVISIBLE_UNICODE_CODE_POINTS = new Set<number>([
  0x00ad,   // SOFT HYPHEN
  0x034f,   // COMBINING GRAPHEME JOINER
  0x061c,   // ARABIC LETTER MARK
  0xfeff,   // ZERO WIDTH NO-BREAK SPACE (BOM)
  0x200b,   // ZERO WIDTH SPACE
  0x200c,   // ZERO WIDTH NON-JOINER
  0x200d,   // ZERO WIDTH JOINER
  // ... 共 300+ 个 Unicode 隐形字符
  ...Array.from({ length: 95 }, (_unused, index) => 0xe0020 + index),   // Tags block
  ...Array.from({ length: 240 }, (_unused, index) => 0xe0100 + index),  // Variation Selectors Supplement
]);

function stripInvisibleUnicode(command: string): string {
  return Array.from(command)
    .filter((char) => !INVISIBLE_UNICODE_CODE_POINTS.has(char.codePointAt(0) ?? -1))
    .join("");
}

export function detectCommandObfuscation(command: string): ObfuscationDetection {
  // ...
  // 先做 NFKC 规范化,再去除隐形字符
  const normalizedCommand = stripInvisibleUnicode(command.normalize("NFKC"));
  // 在规范化后的字符串上匹配
  for (const pattern of OBFUSCATION_PATTERNS) {
    if (!pattern.regex.test(normalizedCommand)) {
      continue;
    }
    // ...
  }
}

NFKC 规范化 + 隐形字符过滤是专门针对"视觉欺骗"攻击的:攻击者可能在 base64 中间插入零宽字符,让正则表达式看不到 base64 完整词,但 shell 执行时自动忽略这些字符。规范化后再检测,可以挡住这类攻击。

8.3 合法例外:SAFE_CURL_PIPE_URLS

curl-pipe-shell 是常见的安装脚本模式(如 curl https://bun.sh/install | bash)。为了不误伤合法用途,OpenClaw 维护了一个安全 URL 白名单:

// src/infra/exec-obfuscation-detect.ts L171-180
const SAFE_CURL_PIPE_URLS = [
  { host: "brew.sh" },
  { host: "get.pnpm.io" },
  { host: "bun.sh", pathPrefix: "/install" },
  { host: "sh.rustup.rs" },
  { host: "get.docker.com" },
  { host: "install.python-poetry.org" },
  { host: "raw.githubusercontent.com", pathPrefix: "/Homebrew" },
  { host: "raw.githubusercontent.com", pathPrefix: "/nvm-sh/nvm" },
];

这些是社区广泛信任的安装脚本来源。当 curl-pipe-shell 模式匹配,但 URL 精确匹配这个白名单时,不触发混淆告警。


九、第七道闸门:审批流程

如果所有静态检查都通过,但 ask 策略要求人工审批,命令会进入异步审批流程。

9.1 审批请求创建

// src/agents/bash-tools.exec-host-gateway.ts L143-178
if (requiresAsk) {
  const requestArgs = buildDefaultExecApprovalRequestArgs({
    warnings: params.warnings,
    approvalRunningNoticeMs: params.approvalRunningNoticeMs,
    createApprovalSlug,
    turnSourceChannel: params.turnSourceChannel,
    turnSourceAccountId: params.turnSourceAccountId,
  });

  const {
    approvalId,    // UUID,完整 ID
    approvalSlug,  // 8 字符短码,用于用户输入
    warningText,
    expiresAtMs,   // 默认 120 秒后过期
    preResolvedDecision,   // 是否已有预决定(如来自 socket)
    initiatingSurface,     // 哪个平台发起的(telegram/discord/...)
    sentApproverDms,       // 是否已发 DM 给审批人
    unavailableReason,     // 为什么审批渠道不可用
  } = await createAndRegisterDefaultExecApprovalRequest({
    ...requestArgs,
    register: registerGatewayApproval,
  });

  // 立即返回 "approval-pending" 工具结果给模型
  return {
    pendingResult: buildExecApprovalPendingToolResult({ ... }),
  };
}

审批流程是非阻塞的:创建审批请求后立即返回 approval-pending 状态给模型,告知模型"命令需要审批,等待人工响应"。后续等待和执行在独立的异步协程中进行。

9.2 审批决策回调

// src/agents/bash-tools.exec-host-gateway.ts L191-294
void (async () => {
  // 等待审批决定(最多 120 秒)
  const decision = await resolveApprovalDecisionOrUndefined({
    approvalId,
    preResolvedDecision,
    onFailure: () =>
      void sendExecApprovalFollowupResult(
        followupTarget,
        `Exec denied (gateway id=${approvalId}, approval-request-failed): ${params.command}`,
      ),
  });
  if (decision === undefined) {
    return;  // 等待超时或请求失败
  }

  const { baseDecision, approvedByAsk, deniedReason } = createExecApprovalDecisionState({
    decision,
    askFallback,
    obfuscationDetected: obfuscation.detected,
  });

  // 超时回退:如果 askFallback=allowlist 且命令在白名单里,自动批准
  if (baseDecision.timedOut && askFallback === "allowlist") {
    if (!analysisOk || !allowlistSatisfied) {
      deniedReason = "approval-timeout (allowlist-miss)";
    } else {
      approvedByAsk = true;  // 超时后白名单命令自动放行
    }
  } else if (decision === "allow-once") {
    approvedByAsk = true;
  } else if (decision === "allow-always") {
    approvedByAsk = true;
    // 持久化白名单
    if (hostSecurity === "allowlist") {
      const patterns = resolveAllowAlwaysPatterns({ segments, cwd, env, platform });
      for (const pattern of patterns) {
        addAllowlistEntry(approvals.file, params.agentId, pattern);
      }
    }
  }

  if (deniedReason) {
    await sendExecApprovalFollowupResult(followupTarget, `Exec denied (gateway ...)`);
    return;
  }

  // 通过:执行命令
  const run = await runExecProcess({ ... });
  markBackgrounded(run.session);
  const outcome = await run.promise;
  // 发送执行结果给用户
  await sendExecApprovalFollowupResult(followupTarget, summary);
})();

审批结果对应的三种后续行为:

  • allow-once:执行一次,不写入白名单
  • allow-always:执行 + 持久化白名单(通过 addAllowlistEntry
  • deny:拒绝,发送拒绝通知

超时回退askFallback)是一个重要的降级机制:如果审批人 120 秒内没有响应,askFallback=allowlist 配置可以让白名单命令自动通过,避免因无人审批而阻塞 AI 的正常工作。


十、进程生命周期:从 Spawn 到 ProcessSession

通过所有安全闸门后,终于进入 runExecProcess——实际的进程创建阶段。

10.1 ProcessSession:进程生命周期的载体

// src/agents/bash-process-registry.ts L28-55
export interface ProcessSession {
  id: string;               // 随机生成的会话 ID
  command: string;          // 原始命令(用于显示/日志)
  scopeKey?: string;        // 所属作用域
  sessionKey?: string;      // 所属 agent 会话
  notifyOnExit?: boolean;   // 后台退出时是否通知
  child?: ChildProcessWithoutNullStreams;
  stdin?: SessionStdin;
  pid?: number;
  startedAt: number;
  cwd?: string;
  maxOutputChars: number;           // 最大输出缓冲(默认 200KB)
  pendingMaxOutputChars?: number;   // pending 状态最大输出(默认 30KB)
  totalOutputChars: number;         // 总输出字符计数
  pendingStdout: string[];          // pending 输出缓冲
  pendingStderr: string[];
  pendingStdoutChars: number;
  pendingStderrChars: number;
  aggregated: string;               // 完整聚合输出(受 maxOutputChars 截断)
  tail: string;                     // 最近 2000 字符(用于通知摘要)
  exitCode?: number | null;
  exitSignal?: NodeJS.Signals | number | null;
  exited: boolean;
  truncated: boolean;               // 输出是否被截断
  backgrounded: boolean;            // 是否已进入后台模式
}

10.2 Spawn 规格计算

// src/agents/bash-tools.exec-runtime.ts L388-436
const spawnSpec = (() => {
  if (opts.sandbox) {
    // 沙箱路径:封装为 docker exec 命令
    return {
      mode: "child" as const,
      argv: [
        "docker",
        ...buildDockerExecArgs({
          containerName: opts.sandbox.containerName,
          command: execCommand,
          workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
          env: shellRuntimeEnv,
          tty: opts.usePty,
        }),
      ],
      env: process.env,   // docker 进程本身继承宿主机 env
      stdinMode: opts.usePty ? "pipe-open" as const : "pipe-closed" as const,
    };
  }
  const { shell, args: shellArgs } = getShellConfig();
  const childArgv = [shell, ...shellArgs, execCommand];
  if (opts.usePty) {
    return {
      mode: "pty" as const,
      ptyCommand: execCommand,
      childFallbackArgv: childArgv,  // PTY 失败时的降级方案
      env: shellRuntimeEnv,
      stdinMode: "pipe-open" as const,
    };
  }
  return {
    mode: "child" as const,
    argv: childArgv,
    env: shellRuntimeEnv,
    stdinMode: "pipe-closed" as const,  // 非 PTY 非沙箱:关闭 stdin
  };
})();

三条执行路径:

  1. sandboxdocker exec -i [-t] -w workdir -e KEY=VAL ... container /bin/sh -lc cmd
  2. PTY 模式:通过 node-pty 启动伪终端,支持颜色输出、光标控制等 TTY 特性
  3. 普通子进程/bin/zsh -c cmd,关闭 stdin,标准管道传输

10.3 PTY 的 DSR 响应

当使用 PTY 模式运行时,有个微妙的细节:终端应用经常发送 DSR(Device Status Report)请求来查询光标位置,如果没有响应,应用会卡住等待:

// src/agents/bash-tools.exec-runtime.ts L442-454
const onSupervisorStdout = (chunk: string) => {
  if (usingPty) {
    const { cleaned, requests } = stripDsrRequests(chunk);
    if (requests > 0 && managedRun?.stdin) {
      for (let i = 0; i < requests; i += 1) {
        // 回复光标位置响应(固定值)
        managedRun.stdin.write(cursorResponse);
      }
    }
    handleStdout(cleaned);
    return;
  }
  handleStdout(chunk);
};

stripDsrRequests 从输出流中移除 DSR 请求序列(ESC [6n),同时向进程 stdin 写入固定的光标位置响应。这让 coding agent 类工具(如 claudecodex)在 PTY 模式下能正常运行,而不会因为终端响应缺失而挂起。

10.4 退出码的语义区分

// src/agents/bash-tools.exec-runtime.ts L520-587
const promise = managedRun.wait().then((exit): ExecProcessOutcome => {
  const exitCode = exit.exitCode ?? 0;
  // exit code 126: not executable(权限问题)
  // exit code 127: command not found(命令不存在)
  // 这两种是不可恢复的基础设施失败,不应被视为"正常退出"
  const isShellFailure = exitCode === 126 || exitCode === 127;
  const status: "completed" | "failed" =
    isNormalExit && !isShellFailure ? "completed" : "failed";

  markExited(session, exit.exitCode, exit.exitSignal, status);
  maybeNotifyOnExit(session, status);

  if (status === "completed") {
    const exitMsg = exitCode !== 0 ? `\n\n(Command exited with code ${exitCode})` : "";
    return { status: "completed", exitCode, ... };
  }

  const reason = isShellFailure
    ? exitCode === 127
      ? "Command not found"
      : "Command not executable (permission denied)"
    : exit.reason === "overall-timeout"
      ? `Command timed out after ${opts.timeoutSec} seconds. ...`
      : exit.reason === "no-output-timeout"
        ? "Command timed out waiting for output"
        : exit.exitSignal != null
          ? `Command aborted by signal ${exit.exitSignal}`
          : "Command aborted before exit code was captured";
  return { status: "failed", reason: aggregated ? `${aggregated}\n\n${reason}` : reason, ... };
});

退出码 126 和 127 被特殊处理为 failed(即使进程"正常退出"),因为它们代表执行环境本身的问题,而不是命令逻辑失败。模型看到这两种错误应该纠正执行环境,而不是继续重试。


十一、后台模式:yieldMs 与进程注册表

11.1 Background 机制设计

OpenClaw 的 exec 支持两种后台模式:

  • background=true:立即后台,不等待任何输出
  • yieldMs=N:等待 N 毫秒的输出窗口后后台
// src/agents/bash-tools.exec.ts L491-593
let yielded = false;
let yieldTimer: NodeJS.Timeout | null = null;

// abort 信号不杀死已后台的进程
const onAbortSignal = () => {
  if (yielded || run.session.backgrounded) {
    return;  // 已后台,忽略 abort
  }
  run.kill();
};

return new Promise<AgentToolResult<ExecToolDetails>>((resolve, reject) => {
  const resolveRunning = () =>
    resolve({
      content: [{
        type: "text",
        text: `Command still running (session ${run.session.id}, pid ${run.session.pid ?? "n/a"}).
               Use process (list/poll/log/write/kill/clear/remove) for follow-up.`,
      }],
      details: { status: "running", sessionId: run.session.id, ... },
    });

  const onYieldNow = () => {
    if (yielded) return;
    yielded = true;
    markBackgrounded(run.session);
    resolveRunning();  // 立即 resolve 工具调用,返回 "running" 状态
  };

  if (allowBackground && yieldWindow !== null) {
    if (yieldWindow === 0) {
      onYieldNow();  // background=true 时立即 yield
    } else {
      yieldTimer = setTimeout(() => {
        if (yielded) return;
        yielded = true;
        markBackgrounded(run.session);
        resolveRunning();
      }, yieldWindow);  // 超时后 yield
    }
  }

  // 如果进程在 yieldWindow 内就结束了,取消 timer,同步返回结果
  run.promise.then((outcome) => {
    if (yieldTimer) clearTimeout(yieldTimer);
    if (yielded || run.session.backgrounded) return;  // 已后台,忽略
    // 同步完成路径...
    resolve({ ... });
  });
});

yieldedrun.session.backgrounded 是两个独立的状态标志:

  • yielded:工具调用层面已 yield(Promise 已 resolve)
  • backgrounded:进程注册表层面已标记为后台

两者通常同步设置,但分开维护是为了让 abort signal 处理器能正确判断:工具调用已完成但进程仍在运行时,不应该因为父 session 结束而杀死子进程。

11.2 ProcessSession 注册表

// src/agents/bash-process-registry.ts L73-89
const runningSessions = new Map<string, ProcessSession>();
const finishedSessions = new Map<string, FinishedSession>();

export function addSession(session: ProcessSession) {
  runningSessions.set(session.id, session);
  startSweeper();  // 确保 sweeper 在运行
}

所有运行中的进程都注册在 runningSessions,完成后的后台进程移入 finishedSessions(TTL 默认 30 分钟)。process 工具(list/poll/kill/write)就是通过查询这两个 Map 来操作后台进程的。

11.3 输出缓冲管理

// src/agents/bash-process-registry.ts L104-132
export function appendOutput(session: ProcessSession, stream: "stdout" | "stderr", chunk: string) {
  // pending 缓冲用于 "增量 poll":只读取上次 poll 之后的新输出
  const buffer = stream === "stdout" ? session.pendingStdout : session.pendingStderr;
  const pendingCap = Math.min(
    session.pendingMaxOutputChars ?? DEFAULT_PENDING_OUTPUT_CHARS,
    session.maxOutputChars,
  );
  buffer.push(chunk);
  let pendingChars = bufferChars + chunk.length;
  if (pendingChars > pendingCap) {
    session.truncated = true;
    pendingChars = capPendingBuffer(buffer, pendingChars, pendingCap);
  }
  // aggregated 保留完整输出(受 maxOutputChars 截断)
  const aggregated = trimWithCap(session.aggregated + chunk, session.maxOutputChars);
  session.truncated =
    session.truncated || aggregated.length < session.aggregated.length + chunk.length;
  session.aggregated = aggregated;
  // tail 只保留最近 2000 字符,用于通知摘要
  session.tail = tail(session.aggregated, 2000);
}

三层输出缓冲:

  • pendingStdout/pendingStderr:增量缓冲(poll 后清空),上限 30KB
  • aggregated:完整输出(尾部截断),上限 200KB
  • tail:最近 2000 字符(通知摘要,实时更新)

11.4 进程退出时的资源回收

// src/agents/bash-process-registry.ts L161-213
function moveToFinished(session: ProcessSession, status: ProcessStatus) {
  runningSessions.delete(session.id);

  // 清理 child process stdio,防止 FD 泄漏
  if (session.child) {
    session.child.stdin?.destroy?.();
    session.child.stdout?.destroy?.();
    session.child.stderr?.destroy?.();
    session.child.removeAllListeners();
    delete session.child;
  }

  // 清理 stdin wrapper
  if (session.stdin) {
    if (typeof session.stdin.destroy === "function") {
      session.stdin.destroy();
    } else if (typeof session.stdin.end === "function") {
      session.stdin.end();
    }
    delete session.stdin;
  }

  // 只有 backgrounded 的进程才保存到 finishedSessions
  if (!session.backgrounded) {
    return;
  }
  finishedSessions.set(session.id, {
    id: session.id,
    command: session.command,
    startedAt: session.startedAt,
    endedAt: Date.now(),
    status,
    exitCode: session.exitCode,
    // ...
  });
}

非后台进程的退出不保存到 finishedSessions。这是合理的:同步执行的命令,工具调用本身就是返回值的载体,不需要通过 process poll 来查询结果。只有后台命令才需要进入 "finished" 状态以供后续查询。


十二、预检:Shell Bleed 检测

在进入 spawn 之前,还有一个针对 AI 模型常见失误的预检:

// src/agents/bash-tools.exec.ts L55-149
function extractScriptTargetFromCommand(command: string) {
  // 仅支持简单形式:python file.py 或 node file.js
  const pythonMatch = raw.match(/^\s*(python3?|python)\s+(?:-[^\s]+\s+)*([^\s]+\.py)\b/i);
  const nodeMatch = raw.match(/^\s*(node)\s+(?:--[^\s]+\s+)*([^\s]+\.js)\b/i);
  // ...
}

async function validateScriptFileForShellBleed(params: {
  command: string;
  workdir: string;
}): Promise<void> {
  const target = extractScriptTargetFromCommand(params.command);
  if (!target) return;

  // 沙箱路径检查
  await assertSandboxPath({ filePath: absPath, cwd: params.workdir, root: params.workdir });

  // 最大 512KB,超出跳过检查
  if (stat.size > 512 * 1024) return;

  const content = await fs.readFile(absPath, "utf-8");

  // 检测 Python/JS 文件中的 shell 变量语法($VAR_NAME)
  const envVarRegex = /\$[A-Z_][A-Z0-9_]{1,}/g;
  const first = envVarRegex.exec(content);
  if (first) {
    throw new Error(
      [
        `exec preflight: detected likely shell variable injection (${token}) in ${target.kind} script: ${path.basename(absPath)}:${line}.`,
        target.kind === "python"
          ? `In Python, use os.environ.get(${JSON.stringify(token.slice(1))}) instead of raw ${token}.`
          : `In Node.js, use process.env[${JSON.stringify(token.slice(1))}] instead of raw ${token}.`,
        "(If this is inside a string literal on purpose, escape it or restructure the code.)",
      ].join("\n"),
    );
  }

  // 检测 JS 文件以 NODE 开头(shell 命令误写为 JS)
  if (target.kind === "node") {
    const firstNonEmpty = content.split(/\r?\n/).find((l) => l.trim().length > 0);
    if (firstNonEmpty && /^NODE\b/.test(firstNonEmpty)) {
      throw new Error(`exec preflight: JS file starts with shell syntax (${firstNonEmpty}).`);
    }
  }
}

这个检测器解决了一个真实的 AI 失误场景:AI 生成了 Python 文件,但文件里写的是 $HOME$PATH 这样的 shell 变量语法(Python 里应该用 os.environ.get('HOME'))。如果不预检,运行这个文件时 shell 会展开 $PATH 再传给 Python 解释器,导致诡异错误。


十三、全链路状态图

综合以上所有分析,一条 exec 命令的完整链路如下:

AI 模型生成 exec 工具调用
         │
         ▼
┌─────────────────────────────────┐
│  1. createExecTool (工厂预计算)   │
│     - SafeBin Policy 解析        │
│     - 默认参数规范化              │
└──────────────┬──────────────────┘
               │
               ▼
┌─────────────────────────────────┐
│  2. Host 路由与提权裁决          │
│     - sandbox / gateway / node  │
│     - elevated 双重闸门检查      │
│     - security / ask 交叉矩阵   │
└──────────────┬──────────────────┘
               │
               ▼
┌─────────────────────────────────┐
│  3. 环境变量净化                 │
│     - sanitizeHostBaseEnv       │
│     - validateHostEnv (拦截注入)│
│     - buildSandboxEnv (重建)    │
│     - PATH 特殊处理              │
└──────────────┬──────────────────┘
               │
               ▼ (host=gateway/node 才走)
┌─────────────────────────────────┐
│  4. Shell 语法分析               │
│     - splitShellPipeline        │
│     - 命令链分组 (&&/||/;)      │
│     - heredoc 解析与标记         │
│     - argv 解析 + 可执行文件解析 │
└──────────────┬──────────────────┘
               │
               ▼
┌─────────────────────────────────┐
│  5. 白名单 + SafeBin 评估        │
│     - allowlist 模式匹配         │
│     - SafeBin 路径信任 + profile │
│     - Skill 自动授权             │
│     - 三源优先级决策             │
└──────────────┬──────────────────┘
               │
               ▼
┌─────────────────────────────────┐
│  6. 混淆检测                    │
│     - NFKC 规范化 + 隐形 Unicode │
│     - 14 种混淆模式匹配          │
│     - SAFE_CURL_PIPE_URLS 白名单 │
└──────────────┬──────────────────┘
               │
        ┌──────┴──────┐
        │ requiresAsk? │
        └──────┬───────┘
    YES ◄──────┤ NO
    │          │
    ▼          ▼
┌───────┐  ┌──────────────────────────┐
│ 7.审批│  │  Shell Bleed 预检         │
│  流程 │  │  validateScriptFileFor   │
│       │  │  ShellBleed              │
│ await │  └──────────┬───────────────┘
│ 用户  │             │
│ 决策  │             ▼
│       │  ┌──────────────────────────┐
│allow  │  │  runExecProcess          │
│──────►│  │  - Spawn 规格计算         │
└───────┘  │  - supervisor.spawn      │
           │  - PTY/child 路径选择    │
           │  - DSR 响应处理          │
           └──────────┬───────────────┘
                      │
                      ▼
           ┌──────────────────────────┐
           │  yieldMs / background    │
           │  - 前台同步等待结束       │
           │  - 超时后台              │
           │  - 即时后台              │
           └──────────┬───────────────┘
                      │
                      ▼
           ┌──────────────────────────┐
           │  ProcessSession 生命周期  │
           │  - exit code 语义区分    │
           │  - 资源回收              │
           │  - notifyOnExit         │
           └──────────────────────────┘

十四、设计哲学总结

回顾整个链路,有几个核心设计哲学贯穿始终:

1. Fail Closed(关闭失败) 所有安全决策在不确定时倾向于拒绝:解析失败 → 不走白名单;分析不可靠 → 触发审批;超出范围 → 抛出错误。宁可误报,不可漏报。

2. 出厂配置优先于运行时覆盖 AI 模型只能在出厂设置划定的边界内行动。安全级别只能收紧,不能放开;HOST 只能按配置,不能自行切换;PATH 只能继承,不能注入。

3. 静态分析 + 运行时防护双层次 Shell 语法分析、白名单评估、混淆检测都是 静态分析——在进程启动之前完成。但环境变量净化、PATH 特殊处理、沙箱隔离是 运行时 防护——即使静态分析被绕过,运行时防护也能兜底。

4. 审计可追溯 每个命令段被哪种机制放行(allowlist/safeBins/skills)都有记录;allowlist 命中记录了 lastUsedAtlastUsedCommand;审批流程有完整的 approvalId + approvalSlug 追踪链。

5. 可组合的安全策略 security × ask × host × elevated 形成了一个完整的策略矩阵,管理员可以针对不同 agent、不同来源渠道精细配置。这是 OpenClaw 多租户、多渠道场景下安全可扩展的关键设计。


附:核心源文件索引

本文涉及的主要源文件: