前言
AI agent 调用工具执行 shell 命令,这是现代 AI 编程助手里最强大也最危险的能力。强大在于它能直接操作文件系统、运行代码、安装依赖;危险在于一旦模型产生幻觉、被 prompt 注入,或者用户配置不当,这个能力就变成了攻击面。
OpenClaw 围绕这个问题构建了一套复杂的多层防御系统。我们今天把整套系统从头捋一遍:容器隔离怎么做的、命令审批流程是什么、elevated exec 权限升级机制、环境变量怎么脱敏、挂载路径怎么防止逃逸、工具策略怎么设计。
代码量比较大,文章会贴完整的关键源码,一边看代码一边讲原理。
一、整体架构:三层执行主机
先看最核心的设计决策。OpenClaw 的 exec 工具支持三种执行主机,定义在 src/infra/exec-approvals.ts:
typescript
export type ExecHost = "sandbox" | "gateway" | "node";
export type ExecSecurity = "deny" | "allowlist" | "full";
export type ExecAsk = "off" | "on-miss" | "always";
三种 host 代表三种完全不同的执行环境:
- sandbox:命令在 Docker 容器里运行,与宿主机完全隔离。这是最安全的模式,适合 multi-tenant 场景或者你不信任 AI 的操作时。
- gateway:命令在网关进程所在的宿主机上运行,但受到 allowlist 和审批机制约束。
- node:命令在远程 node 服务上运行,适合有多台机器的场景。
三种安全级别:
- deny:拒绝所有执行(最保守,默认)
- allowlist:只允许预先批准的命令
- full:允许所有命令执行
三种审批模式:
- off:不需要用户审批
- on-miss:allowlist 未命中时才弹审批
- always:总是需要用户审批
这九个枚举值组成了整套权限系统的基础词汇表。
二、exec 工具的完整执行决策树
createExecTool 在 src/agents/bash-tools.exec.ts 里,这个函数是整个 exec 工具的工厂函数,每次创建一个工具实例时就把默认配置烘焙进去。
来看最关键的决策逻辑,完整地贴出来:
typescript
// 解析 elevated 模式
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"
: "off";
const effectiveDefaultMode = elevatedAllowed ? elevatedDefaultMode : "off";
// 如果用户/模型请求 elevated,判断是否允许
const elevatedMode =
typeof params.elevated === "boolean"
? params.elevated
? elevatedDefaultMode === "full"
? "full"
: "ask"
: "off"
: effectiveDefaultMode;
const elevatedRequested = elevatedMode !== "off";
// elevated 但没有授权,硬拒绝
if (elevatedRequested) {
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
throw new Error(
[
`elevated is not available right now (runtime=${runtime}).`,
`Failing gates: ${gates.join(", ")}`,
...
].join("\n"),
);
}
}
// 决定使用哪个 host
const configuredHost = defaults?.host ?? "sandbox";
const requestedHost = normalizeExecHost(params.host) ?? null;
let host: ExecHost = requestedHost ?? configuredHost;
// 非 elevated 情况下不允许切换 host
if (!elevatedRequested && requestedHost && requestedHost !== configuredHost) {
throw new Error(
`exec host not allowed (requested ${renderExecHostLabel(requestedHost)}; ` +
`configure tools.exec.host=${renderExecHostLabel(configuredHost)} to allow).`,
);
}
// elevated 模式强制路由到 gateway
if (elevatedRequested) {
host = "gateway";
}
// security 取两者的较小值(更严格的优先)
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
const requestedSecurity = normalizeExecSecurity(params.security);
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
// elevated=full 时 security 升为 full
if (elevatedRequested && elevatedMode === "full") {
security = "full";
}
// ask 取两者的较大值(更宽松的优先,即更谨慎)
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";
}
这段代码体现了几个关键的安全原则:
1. minSecurity:严格度不可降级
typescript
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;
}
minSecurity 取两个 security 值中严格的那个。配置了 allowlist,模型请求 full,结果还是 allowlist------模型没有权力自己提权。
2. maxAsk:谨慎度不可降低
typescript
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;
}
maxAsk 取两个 ask 值中更谨慎的那个。配置了 on-miss,模型请求 off,结果还是 on-miss------模型不能绕过审批。
3. elevated 强制路由 gateway
当 elevated 被请求时,host 无论配置了什么都会被强制设置为 "gateway"。这意味着 elevated 命令永远不会在 sandbox 容器内执行,它们必须在宿主机环境的 gateway 进程中运行,并受到额外的门控检查。
三、sandbox 容器的生命周期
沙箱的核心入口是 resolveSandboxContext,在 src/agents/sandbox/context.ts:
typescript
export async function resolveSandboxContext(params: {
config?: OpenClawConfig;
sessionKey?: string;
workspaceDir?: string;
}): Promise<SandboxContext | null> {
const resolved = resolveSandboxSession(params);
if (!resolved) {
return null; // 当前 session 不需要沙箱
}
const { rawSessionKey, cfg } = resolved;
// 1. 清理过期容器
await maybePruneSandboxes(cfg);
// 2. 准备工作区目录结构
const { agentWorkspaceDir, scopeKey, workspaceDir } = await ensureSandboxWorkspaceLayout({
cfg,
rawSessionKey,
config: params.config,
workspaceDir: params.workspaceDir,
});
// 3. 自动推断容器 user(与宿主机目录的 uid:gid 对齐)
const docker = await resolveSandboxDockerUser({
docker: cfg.docker,
workspaceDir,
});
// 4. 确保容器存在并运行
const containerName = await ensureSandboxContainer({
sessionKey: rawSessionKey,
workspaceDir,
agentWorkspaceDir,
cfg: resolvedCfg,
});
// 5. 创建文件系统桥接层
sandboxContext.fsBridge = createSandboxFsBridge({ sandbox: sandboxContext });
return sandboxContext;
}
整个流程体现了"懒创建、热复用"的设计思想:容器不是每次 session 开始时就创建的,而是第一次真正需要 exec 的时候才创建,之后就复用已有容器。
容器 scope:session、agent 还是 shared?
SandboxContext 里有一个重要的 scope 字段:
typescript
export type SandboxScope = "session" | "agent" | "shared";
- session:每个 session 有独立的容器(最隔离,但资源消耗最高)
- agent:同一 agent 的所有 session 共享同一个容器
- shared:所有 session 共享同一个容器(类似全局环境)
scope 决定了容器的 scopeKey,而 scopeKey 是容器命名的依据。session scope 下,每个对话有自己的容器,不同对话间完全隔离;shared scope 下,AI 在不同对话中执行的命令会看到彼此的副作用。
workspaceAccess:三种工作区访问模式
typescript
export type SandboxWorkspaceAccess = "none" | "ro" | "rw";
- none:容器看不到宿主机工作区,完全隔离
- ro:容器以只读方式挂载宿主机工作区(可以读代码,但不能修改)
- rw:容器可以读写宿主机工作区
当 workspaceAccess === "ro" 时,还会在容器内创建 /agent 挂载点(SANDBOX_AGENT_WORKSPACE_MOUNT),让 AI 有个可写的私有工作区。
四、Docker 容器创建:安全参数的全貌
buildSandboxCreateArgs 在 src/agents/sandbox/docker.ts 里,这个函数把沙箱配置转换成 docker create 命令的参数列表。把关键部分完整贴出来:
typescript
export function buildSandboxCreateArgs(params: {
name: string;
cfg: SandboxDockerConfig;
scopeKey: string;
createdAtMs?: number;
labels?: Record<string, string>;
configHash?: string;
includeBinds?: boolean;
bindSourceRoots?: string[];
allowSourcesOutsideAllowedRoots?: boolean;
allowReservedContainerTargets?: boolean;
allowContainerNamespaceJoin?: boolean;
envSanitizationOptions?: EnvSanitizationOptions;
}) {
// 1. 先做安全校验,有问题直接抛出
validateSandboxSecurity({
...params.cfg,
allowedSourceRoots: params.bindSourceRoots,
...
});
const args = ["create", "--name", params.name];
// 2. 打 label,方便后续清理和识别
args.push("--label", "openclaw.sandbox=1");
args.push("--label", `openclaw.sessionKey=${params.scopeKey}`);
args.push("--label", `openclaw.createdAtMs=${createdAtMs}`);
if (params.configHash) {
args.push("--label", `openclaw.configHash=${params.configHash}`);
}
// 3. 只读根文件系统(可选)
if (params.cfg.readOnlyRoot) {
args.push("--read-only");
}
// 4. tmpfs 挂载(内存临时文件系统)
for (const entry of params.cfg.tmpfs) {
args.push("--tmpfs", entry);
}
// 5. 网络配置
if (params.cfg.network) {
args.push("--network", params.cfg.network);
}
// 6. 指定运行用户
if (params.cfg.user) {
args.push("--user", params.cfg.user);
}
// 7. 环境变量脱敏处理
const envSanitization = sanitizeEnvVars(params.cfg.env ?? {}, params.envSanitizationOptions);
if (envSanitization.blocked.length > 0) {
log.warn(`Blocked sensitive environment variables: ${envSanitization.blocked.join(", ")}`);
}
for (const [key, value] of Object.entries(markOpenClawExecEnv(envSanitization.allowed))) {
args.push("--env", `${key}=${value}`);
}
// 8. 删掉 Linux capabilities
for (const cap of params.cfg.capDrop) {
args.push("--cap-drop", cap);
}
// 9. 禁止权限升级(关键安全参数)
args.push("--security-opt", "no-new-privileges");
// 10. 自定义 seccomp / AppArmor
if (params.cfg.seccompProfile) {
args.push("--security-opt", `seccomp=${params.cfg.seccompProfile}`);
}
if (params.cfg.apparmorProfile) {
args.push("--security-opt", `apparmor=${params.cfg.apparmorProfile}`);
}
// 11. 资源限制
if (typeof params.cfg.pidsLimit === "number" && params.cfg.pidsLimit > 0) {
args.push("--pids-limit", String(params.cfg.pidsLimit));
}
const memory = normalizeDockerLimit(params.cfg.memory);
if (memory) args.push("--memory", memory);
if (typeof params.cfg.cpus === "number" && params.cfg.cpus > 0) {
args.push("--cpus", String(params.cfg.cpus));
}
// 12. ulimits
for (const [name, value] of Object.entries(params.cfg.ulimits ?? {})) {
const formatted = formatUlimitValue(name, value);
if (formatted) args.push("--ulimit", formatted);
}
return args;
}
然后创建容器时启动命令是 sleep infinity:
typescript
args.push(cfg.image, "sleep", "infinity");
await execDocker(args); // docker create ...
await execDocker(["start", name]); // docker start
容器以 sleep infinity 的形式保持运行,实际命令通过 docker exec 注入执行。这样容器就像一个沙盒池,随时接受新命令,不需要每次命令都重新启动容器。
--security-opt no-new-privileges:最重要的那一行
这个参数的作用是防止容器内的进程通过 setuid、setgid、文件能力等手段提权。这是防止容器逃逸的关键防线之一------即使容器内有 setuid 的二进制文件,这个 flag 会让它们失去提权的能力。
五、容器安全校验:多层 validate
validateSandboxSecurity 在 src/agents/sandbox/validate-sandbox-security.ts,在每次创建容器前都会运行,把危险配置拦在容器启动之前。
5.1 挂载路径的多重防护
typescript
// 这些路径绝对不能挂载进容器
export const BLOCKED_HOST_PATHS = [
"/etc",
"/private/etc",
"/proc",
"/sys",
"/dev",
"/root",
"/boot",
// Docker socket 的所有常见位置
"/run",
"/var/run",
"/private/var/run",
"/var/run/docker.sock",
"/private/var/run/docker.sock",
"/run/docker.sock",
];
Docker socket 的路径被显式列出了好几个------因为如果 AI 能访问 Docker socket,它就可以创建特权容器,完全逃出沙箱。这里把所有常见路径都封死。
挂载校验的逻辑分三步走:
typescript
export function validateBindMounts(
binds: string[] | undefined,
options?: ValidateBindMountsOptions,
): void {
for (const rawBind of binds) {
// 第一步:字符串级别的快速检查(无 I/O)
const blocked = getBlockedBindReason(bind);
if (blocked) {
throw formatBindBlockedError({ bind, reason: blocked });
}
// 第二步:检查是否挂载到容器保留路径
if (!options?.allowReservedContainerTargets) {
const reservedTarget = getReservedTargetReason(bind);
if (reservedTarget) {
throw formatBindBlockedError({ bind, reason: reservedTarget });
}
}
const sourceNormalized = normalizeHostPath(sourceRaw);
enforceSourcePathPolicy({ bind, sourcePath: sourceNormalized, ... });
// 第三步:符号链接逃逸加固
// 如果 /foo -> /etc,字符串检查不会发现,但这里会
const sourceCanonical = resolveSandboxHostPathViaExistingAncestor(sourceNormalized);
enforceSourcePathPolicy({ bind, sourcePath: sourceCanonical, ... });
}
}
第三步最重要:攻击者可能构造一个符号链接,让路径在字符串上看起来无害,但实际指向 /etc 之类的敏感目录。通过 resolveSandboxHostPathViaExistingAncestor 解析存在路径的真实路径,再做一次检查,这个攻击面就被堵住了。
5.2 网络模式防护
typescript
export function validateNetworkMode(
network: string | undefined,
options?: ValidateNetworkModeOptions,
): void {
if (blockedReason === "host") {
throw new Error(
`Sandbox security: network mode "${network}" is blocked. ` +
'Network "host" mode bypasses container network isolation. ' +
'Use "bridge" or "none" instead.',
);
}
if (blockedReason === "container_namespace_join") {
throw new Error(
`Sandbox security: network mode "${network}" is blocked by default. ` +
'Network "container:*" joins another container namespace and bypasses sandbox network isolation.',
);
}
}
--network host 和 --network container:xxx 都被封掉了。前者让容器直接共享宿主机网络,后者让容器加入另一个容器的网络命名空间------两者都会破坏网络隔离。
5.3 seccomp 和 AppArmor 防护
typescript
const BLOCKED_SECCOMP_PROFILES = new Set(["unconfined"]);
const BLOCKED_APPARMOR_PROFILES = new Set(["unconfined"]);
export function validateSeccompProfile(profile: string | undefined): void {
if (profile && BLOCKED_SECCOMP_PROFILES.has(profile.trim().toLowerCase())) {
throw new Error(
`Sandbox security: seccomp profile "${profile}" is blocked. ` +
"Disabling seccomp removes syscall filtering and weakens sandbox isolation.",
);
}
}
unconfined 的 seccomp 意味着不过滤任何系统调用,容器内进程可以调用任意内核功能。这是一个常见的容器逃逸路径,所以被封掉了。
六、环境变量脱敏:AI 绝不能看到 API key
src/agents/sandbox/sanitize-env-vars.ts 定义了完整的环境变量过滤逻辑:
typescript
const BLOCKED_ENV_VAR_PATTERNS: ReadonlyArray<RegExp> = [
/^ANTHROPIC_API_KEY$/i,
/^OPENAI_API_KEY$/i,
/^GEMINI_API_KEY$/i,
/^OPENROUTER_API_KEY$/i,
/^MINIMAX_API_KEY$/i,
/^ELEVENLABS_API_KEY$/i,
/^TELEGRAM_BOT_TOKEN$/i,
/^DISCORD_BOT_TOKEN$/i,
/^SLACK_(BOT|APP)_TOKEN$/i,
/^LINE_CHANNEL_SECRET$/i,
/^LINE_CHANNEL_ACCESS_TOKEN$/i,
/^OPENCLAW_GATEWAY_(TOKEN|PASSWORD)$/i,
/^AWS_(SECRET_ACCESS_KEY|SECRET_KEY|SESSION_TOKEN)$/i,
/^(GH|GITHUB)_TOKEN$/i,
/^(AZURE|AZURE_OPENAI|COHERE|AI_GATEWAY|OPENROUTER)_API_KEY$/i,
// 兜底规则:任何以 API_KEY、TOKEN、PASSWORD、PRIVATE_KEY、SECRET 结尾的变量
/_?(API_KEY|TOKEN|PASSWORD|PRIVATE_KEY|SECRET)$/i,
];
const ALLOWED_ENV_VAR_PATTERNS: ReadonlyArray<RegExp> = [
/^LANG$/,
/^LC_.*$/i,
/^PATH$/i,
/^HOME$/i,
/^USER$/i,
/^SHELL$/i,
/^TERM$/i,
/^TZ$/i,
/^NODE_ENV$/i,
];
还有一个值检测机制:
typescript
export function validateEnvVarValue(value: string): string | undefined {
if (value.includes("\0")) {
return "Contains null bytes"; // 直接 block
}
if (value.length > 32768) {
return "Value exceeds maximum length"; // 警告
}
if (/^[A-Za-z0-9+/=]{80,}$/.test(value)) {
return "Value looks like base64-encoded credential data"; // 警告
}
return undefined;
}
注意:ALLOWED_ENV_VAR_PATTERNS 是在 strictMode 下才生效的。默认模式下只做黑名单过滤,严格模式下则是白名单------只有明确允许的变量才能进入容器。
最后兜底的那条正则 /_?(API_KEY|TOKEN|PASSWORD|PRIVATE_KEY|SECRET)$/i 很重要------即使新增了某个第三方服务的 API key 环境变量,只要命名符合惯例,也会被自动拦截,不需要逐个显式列举。
七、Exec Approvals 审批系统:allowlist 的完整实现
审批系统的核心数据结构定义在 src/infra/exec-approvals.ts:
typescript
export type ExecApprovalsFile = {
version: 1;
socket?: {
path?: string; // Unix domain socket 路径,用于实时审批通信
token?: string; // 安全令牌
};
defaults?: ExecApprovalsDefaults;
agents?: Record<string, ExecApprovalsAgent>;
};
export type ExecAllowlistEntry = {
id?: string;
pattern: string; // glob 或绝对路径模式
lastUsedAt?: number; // 最近一次使用时间
lastUsedCommand?: string; // 最近一次使用的命令
lastResolvedPath?: string; // 命令的解析路径
};
export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny";
这个文件持久化在 ~/.openclaw/exec-approvals.json,权限设置为 0o600(只有文件所有者可读写)。
审批所需条件
typescript
export function requiresExecApproval(params: {
ask: ExecAsk;
security: ExecSecurity;
analysisOk: boolean;
allowlistSatisfied: boolean;
}): boolean {
return (
params.ask === "always" ||
(params.ask === "on-miss" &&
params.security === "allowlist" &&
(!params.analysisOk || !params.allowlistSatisfied))
);
}
翻译成人话:
ask=always:无论如何都要审批ask=on-miss:security 是allowlist模式,且命令分析失败或 allowlist 没命中,才需要审批
实时审批:Unix domain socket
审批请求通过 Unix domain socket 发送,不是轮询文件,而是实时推送。requestExecApprovalViaSocket 把请求发到 socket,然后等待决策:
typescript
export async function requestExecApprovalViaSocket(params: {
socketPath: string;
token: string;
request: Record<string, unknown>;
timeoutMs?: number;
}): Promise<ExecApprovalDecision | null> {
const { socketPath, token, request } = params;
const payload = JSON.stringify({
type: "request",
token,
id: crypto.randomUUID(),
request,
});
return await requestJsonlSocket({
socketPath,
payload,
timeoutMs: params.timeoutMs ?? 15_000,
accept: (value) => {
const msg = value as { type?: string; decision?: ExecApprovalDecision };
if (msg?.type === "decision" && msg.decision) {
return msg.decision;
}
return undefined;
},
});
}
这里有个 15 秒的默认超时。请求通过带 token 的 JSON 消息发到 socket,UI 端(比如 macOS menubar app)监听这个 socket,在用户界面展示命令让用户决策,决策结果再通过 socket 返回。
allow-always 的持久化
当用户选择 allow-always 时,命令路径会被写入 allowlist:
typescript
export function addAllowlistEntry(
approvals: ExecApprovalsFile,
agentId: string | undefined,
pattern: string,
) {
const target = agentId ?? DEFAULT_AGENT_ID;
const agents = approvals.agents ?? {};
const existing = agents[target] ?? {};
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
const trimmed = pattern.trim();
if (allowlist.some((entry) => entry.pattern === trimmed)) {
return; // 已存在,不重复添加
}
allowlist.push({ id: crypto.randomUUID(), pattern: trimmed, lastUsedAt: Date.now() });
agents[target] = { ...existing, allowlist };
approvals.agents = agents;
saveExecApprovals(approvals);
}
每个 allowlist 条目有唯一的 UUID,这方便后续精确删除某条记录。lastUsedAt 和 lastUsedCommand 记录使用历史,让用户能看到每条规则最近被用来执行了什么命令,方便审计。
八、Allowlist 分析引擎:shell 命令解析
Allowlist 的核心挑战是 shell 命令非常复杂:管道、链式调用、shell wrapper(bash -c "...")......你不能用简单的字符串匹配来判断一条命令是否被允许。
evaluateShellAllowlist 在 src/infra/exec-approvals-allowlist.ts:
typescript
export function evaluateShellAllowlist(
params: {
command: string;
env?: NodeJS.ProcessEnv;
} & ExecAllowlistContext,
): ExecAllowlistAnalysis {
// 含行续行符(\)的命令,解析结果依赖 shell 实现,保守地失败
if (hasShellLineContinuation(params.command)) {
return analysisFailure();
}
// 把命令分割成链式部分(&&、||、; 分隔)
const chainParts = isWindowsPlatform(params.platform) ? null : splitCommandChain(params.command);
if (!chainParts) {
// 简单命令,直接分析
const analysis = analyzeShellCommand({ ... });
const evaluation = evaluateExecAllowlist({ analysis, ...allowlistContext });
return { analysisOk: true, ...evaluation };
}
// 链式命令:每个部分必须都满足 allowlist
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, ... };
}
关键点:git status && rm -rf /,因为 rm -rf / 不在 allowlist 里,整条命令会被拒绝,哪怕 git status 是被允许的。
三种通过 allowlist 的途径
evaluateSegments 函数里,一个命令段可以通过三种方式满足:
typescript
export type ExecSegmentSatisfiedBy = "allowlist" | "safeBins" | "skills" | null;
const by: ExecSegmentSatisfiedBy = match
? "allowlist" // 匹配了 allowlist 条目
: safe
? "safeBins" // 是已知安全的系统工具
: skillAllow
? "skills" // 是由 Skill 安装的工具,且 autoAllowSkills=true
: null; // 不满足,需要审批
safeBins 是一个内置的"已知安全的只读命令"列表,比如 ls、cat、echo 等读取类工具,它们在受控参数下可以直接放行,不需要用户逐条添加 allowlist。
skills 路径更有意思------当用户启用了 Skill 的 autoAllowSkills,由 Skill 安装的工具(比如某个 Skill 安装了 gh CLI),AI 用这个工具时可以自动放行,不需要手动添加 allowlist。
Shell Wrapper 解包
当命令是 bash -c "git status" 这样的 shell wrapper 调用时,简单匹配 bash 显然不对,应该匹配内部的 git。resolveAllowAlwaysPatterns 会解包这层 wrapper:
typescript
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 会递归解包,最深 3 层(depth >= 3 时停止),提取最内层的真实可执行文件路径。这样 allow-always 记录的是真实被执行的程序,而不是 bash 这个 wrapper。
九、Elevated Exec:跨越沙箱边界的权限升级
有时候 AI 需要执行一些在沙箱里做不了的操作,比如安装系统依赖、修改主机配置。这就是 elevated 模式的用途。
Elevated 的关键设计原则是:权限是从人传给 AI 的,不是 AI 自己声明拥有的。
从配置上看,elevated 权限需要两个条件都满足:
typescript
export type ExecElevatedDefaults = {
enabled: boolean; // tools.elevated.enabled 必须为 true
allowed: boolean; // 当前 sender 在 allowFrom 列表里
defaultLevel: "on" | "off" | "ask" | "full";
};
enabled 是全局开关,allowed 是基于发送者的访问控制。allowFrom 配置按 provider 分组:
plaintext
tools.elevated.allowFrom.telegram = ["@alice", "@bob"]
tools.elevated.allowFrom.discord = ["alice#1234"]
只有这些特定用户从这些特定渠道发来的消息,才能使用 elevated 模式。
有个安全审计会检查 allowFrom 里是否有通配符(*):
typescript
if (normalized.includes("*")) {
findings.push({
checkId: `tools.elevated.allowFrom.${provider}.wildcard`,
severity: "critical",
title: "Elevated exec allowlist contains wildcard",
detail: `tools.elevated.allowFrom.${provider} includes "*" which effectively approves everyone on that channel for elevated mode.`,
});
}
如果你配置了 *,安全审计会产生一个 critical 级别的 finding------这表示所有人都能触发 elevated 模式,这几乎肯定是配置错误。
Elevated 的两个级别:
- ask:elevated 命令还是需要审批
- full :完全绕过审批,同时 security 也升级为
full
当 elevated=full 时:
typescript
const bypassApprovals = elevatedRequested && elevatedMode === "full";
if (bypassApprovals) {
ask = "off"; // 绕过审批
}
if (elevatedRequested && elevatedMode === "full") {
security = "full"; // 解除 security 限制
}
这个配置很强大,也很危险。它适合的场景是:用户已经明确信任这个 AI 实例,在特定对话里给它临时的完全执行权。
十、工具策略(Tool Policy):沙箱里可以用什么工具
沙箱并不是只限制 exec 命令,它对 agent 可以使用的工具本身也有限制。SandboxToolPolicy 定义在 src/agents/sandbox/types.ts:
typescript
export type SandboxToolPolicy = {
allow?: string[];
deny?: string[];
};
默认的 allow 和 deny 列表在 src/agents/sandbox/constants.ts:
typescript
export const DEFAULT_TOOL_ALLOW = [
"exec",
"process",
"read",
"write",
"edit",
"apply_patch",
"image",
"sessions_list",
"sessions_history",
"sessions_send",
"sessions_spawn",
"sessions_yield",
"subagents",
"session_status",
] as const;
// Provider docking: keep sandbox policy aligned with provider tool names.
export const DEFAULT_TOOL_DENY = [
"browser",
"canvas",
"nodes",
"cron",
"gateway",
...CHANNEL_IDS, // 所有渠道工具(telegram、discord、slack 等)
] as const;
注意默认 deny 里包含了所有渠道工具(...CHANNEL_IDS)------在沙箱里,AI 不能给 Telegram 发消息、不能操作 Discord,完全不能接触外部通信渠道。这防止了沙箱里的 AI 将数据外泄到外部通信渠道。
isToolAllowed 的逻辑是先检 deny,后检 allow(deny 优先):
typescript
export function isToolAllowed(policy: SandboxToolPolicy, name: string) {
const normalized = normalizeGlob(name);
// deny 优先:匹配了 deny 就直接拒绝
const deny = compileGlobPatterns({ raw: expandToolGroups(policy.deny ?? []) });
if (matchesAnyGlobPattern(normalized, deny)) {
return false;
}
// allow 为空意味着允许所有(除了已 deny 的)
const allow = compileGlobPatterns({ raw: expandToolGroups(policy.allow ?? []) });
if (allow.length === 0) {
return true;
}
return matchesAnyGlobPattern(normalized, allow);
}
这里工具名支持 glob 模式,你可以用 sessions_* 来允许所有 sessions 开头的工具。
十一、FS Bridge:文件操作的透明代理
在沙箱模式下,文件读写不能直接操作宿主机文件系统。SandboxFsBridge 在 src/agents/sandbox/fs-bridge.ts 提供了一个透明代理层:
typescript
export type SandboxFsBridge = {
resolvePath(params: { filePath: string; cwd?: string }): SandboxResolvedPath;
readFile(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<Buffer>;
writeFile(params: {
filePath: string;
cwd?: string;
data: Buffer | string;
encoding?: BufferEncoding;
mkdir?: boolean;
signal?: AbortSignal;
}): Promise<void>;
mkdirp(params: { filePath: string; cwd?: string; signal?: AbortSignal }): Promise<void>;
remove(params: {
filePath: string;
cwd?: string;
recursive?: boolean;
force?: boolean;
signal?: AbortSignal;
}): Promise<void>;
rename(params: { from: string; to: string; cwd?: string; signal?: AbortSignal }): Promise<void>;
stat(params: {
filePath: string;
cwd?: string;
signal?: AbortSignal;
}): Promise<SandboxFsStat | null>;
};
SandboxResolvedPath 有三个维度:
typescript
typescript
export type SandboxResolvedPath = {
hostPath: string; // 宿主机上的真实路径
relativePath: string; // 相对于工作区的路径
containerPath: string; // 容器内的对应路径
};
Bridge 内部维护了宿主机路径和容器路径之间的映射(mounts 表),所有文件操作在实际执行前都会经过路径解析,确保路径映射正确,防止路径逃逸。
写操作还会额外调用 pathGuard.assertPathSafety,通过在容器内运行 stat 来验证目标路径的真实位置:
typescript
async writeFile(params: { ... }): Promise<void> {
const target = this.resolveResolvedPath(params);
this.ensureWriteAccess(target, "write files");
// 路径安全检查(会实际在容器内运行命令)
await this.pathGuard.assertPathSafety(target, {
action: "write files",
requireWritable: true,
});
...
}
这是一个「先在容器外解析路径,再在容器内验证」的双重检查机制,防止路径解析和实际操作之间的 TOCTOU(Time-of-check to time-of-use)攻击。
十二、Shell 脚本预检:消灭一类常见的模型 Bug
这是一个比较独特的功能,在 validateScriptFileForShellBleed 里:
typescript
async function validateScriptFileForShellBleed(params: {
command: string;
workdir: string;
}): Promise<void> {
const target = extractScriptTargetFromCommand(params.command);
if (!target) {
return;
}
const content = await fs.readFile(absPath, "utf-8");
// 检测 Python/JS 文件里是否有 shell 环境变量语法
const envVarRegex = /$[A-Z_][A-Z0-9_]{1,}/g;
const first = envVarRegex.exec(content);
if (first) {
const line = ...;
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}.`,
].join("\n"),
);
}
// 检测 JS 文件是否以 shell 语法开头(模型生成了错误的代码)
if (target.kind === "node") {
if (firstNonEmpty && /^NODE\b/.test(firstNonEmpty)) {
throw new Error(
`exec preflight: JS file starts with shell syntax (${firstNonEmpty}).`
);
}
}
}
注释写得很直白:「Common failure mode: shell env var syntax leaking into Python/JS.」
这是专门针对大模型生成代码时常见 bug 的预检------有时候模型会生成像这样的 Python 代码:
python
import os
print($API_KEY) # 错误!这是 shell 语法,不是 Python
这段代码在执行前就会被检测到并报错,避免模型陷入「执行 → 报错 → 尝试修复 → 再次报错」的无限循环,浪费 token 和时间。
十三、容器热复用与配置哈希
最后一个值得深挖的机制是容器的热复用。HOT_CONTAINER_WINDOW_MS = 5 * 60 * 1000(5分钟),在这个时间窗内,如果已经有一个运行中的容器,就直接复用,不重新创建。
但如果沙箱配置变了(比如你改了 docker 镜像、环境变量、挂载配置),旧容器就不能复用了,必须重新创建。这通过 configHash 来判断:
typescript
async function readContainerConfigHash(containerName: string): Promise<string | null> {
return await readDockerContainerLabel(containerName, "openclaw.configHash");
}
配置哈希在创建容器时作为 Docker label 打上去,下次复用前对比哈希,不一致就删掉重建。这确保了配置修改能立即生效,而不是被旧容器状态污染。
小结
OpenClaw 沙箱执行系统的核心设计思想可以归纳为几条原则:
1. 默认最小权限
默认 security=deny,什么都不允许。用户需要主动配置才能解锁能力。
2. 约束只能收紧,不能放松
minSecurity 确保模型不能自己提权,maxAsk 确保模型不能绕过审批。权限边界由人定,模型只能在边界内活动。
3. 多层纵深防御
容器隔离 → 安全校验 → 环境变量脱敏 → 工具策略 → allowlist → 审批机制,每一层都是独立的防线,单层失效不意味着全线崩溃。
4. 把危险从执行时移到配置时
validateSandboxSecurity 在容器启动前运行,把大量安全检查提前到配置验证阶段,而不是等到运行时才发现问题。
5. 可审计、可溯源
allowlist 条目有 UUID、使用时间、使用命令的完整记录。容器有 config hash label。这些都支持事后审计。
本文涉及的主要源文件:
- github.com/openclaw/op...
- github.com/openclaw/op...
- github.com/openclaw/op...
- github.com/openclaw/op...
- github.com/openclaw/op...
- github.com/openclaw/op...
- github.com/openclaw/op...
- github.com/openclaw/op...
- github.com/openclaw/op...
- github.com/openclaw/op...
- github.com/openclaw/op...
- github.com/openclaw/op...