本文基于 OpenClaw 源码深度分析,完整还原一条
exec工具调用从 AI 模型产出到操作系统进程启动的全过程,重点拆解其中七道安全闸门的实现细节。
一、引子:工具调用为什么复杂
当你在 OpenClaw 对话框里发出 "帮我跑一下这个脚本" 时,AI 模型不会直接操控你的终端。它会发出一个结构化的 工具调用(tool call),由 OpenClaw 的执行层接管。这看起来很简单——不就是 child_process.spawn 吗?
实际上,从模型产出 exec 调用到操作系统真正 fork 出进程,中间至少经历七道完整的处理阶段:
- 工具注册与参数规范化 — 决定这个工具是谁、能做什么
- Host 路由与提权裁决 — 命令要在哪里跑、是否需要 elevated 权限
- 环境变量净化 — 阻止宿主机敏感信息泄漏进子进程
- Shell 语法分析 — 静态解析命令链,建立可审计的执行段列表
- 白名单与 SafeBin 评估 — 每个命令段对照允许列表做多源匹配
- 混淆检测 — 拦截 base64/eval/curl-pipe-shell 等绕过模式
- 审批流程 — 需要人工确认时挂起执行,等待授权信号
只有全部通过,才会进入真正的 spawn 阶段。本文将逐层拆解这七道闸门的源码实现。
二、工具的骨架:createExecTool 与 execSchema
一切从 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 模型在生成工具调用时必须符合该结构。注意 host、security、ask 都是可选字符串而非枚举类型——这是有意为之的设计:运行时规范化比编译时严格枚举更灵活,同时后续会通过 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"),
);
}
}
提权需要同时满足:
tools.elevated.enabled = true(全局开关)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 语法分析
在评估白名单之前,需要先把命令字符串解析成结构化的执行段列表。这是 analyzeShellCommand 和 splitShellPipeline 的职责。
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 这样的命令链,解析器需要:
- 识别
&&、||、;这三种链操作符 - 把每个独立命令提取为一个
ExecCommandSegment - 对每个 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 是一种特殊的快速放行机制——对于已知安全的工具(如 git、npm、cat),只要路径来自可信目录、参数符合 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
};
})();
三条执行路径:
- sandbox:
docker exec -i [-t] -w workdir -e KEY=VAL ... container /bin/sh -lc cmd - PTY 模式:通过 node-pty 启动伪终端,支持颜色输出、光标控制等 TTY 特性
- 普通子进程:
/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 类工具(如 claude、codex)在 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({ ... });
});
});
yielded 和 run.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 后清空),上限 30KBaggregated:完整输出(尾部截断),上限 200KBtail:最近 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 命中记录了 lastUsedAt、lastUsedCommand;审批流程有完整的 approvalId + approvalSlug 追踪链。
5. 可组合的安全策略
security × ask × host × elevated 形成了一个完整的策略矩阵,管理员可以针对不同 agent、不同来源渠道精细配置。这是 OpenClaw 多租户、多渠道场景下安全可扩展的关键设计。
附:核心源文件索引
本文涉及的主要源文件: