OpenClaw 沙箱执行系统深度解析:一条 exec 命令背后的安全长城

前言

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 工具的完整执行决策树

createExecToolsrc/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 容器创建:安全参数的全貌

buildSandboxCreateArgssrc/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:最重要的那一行

这个参数的作用是防止容器内的进程通过 setuidsetgid、文件能力等手段提权。这是防止容器逃逸的关键防线之一------即使容器内有 setuid 的二进制文件,这个 flag 会让它们失去提权的能力。


五、容器安全校验:多层 validate

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

翻译成人话:

  1. ask=always:无论如何都要审批
  2. 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,这方便后续精确删除某条记录。lastUsedAtlastUsedCommand 记录使用历史,让用户能看到每条规则最近被用来执行了什么命令,方便审计。


八、Allowlist 分析引擎:shell 命令解析

Allowlist 的核心挑战是 shell 命令非常复杂:管道、链式调用、shell wrapper(bash -c "...")......你不能用简单的字符串匹配来判断一条命令是否被允许。

evaluateShellAllowlistsrc/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 是一个内置的"已知安全的只读命令"列表,比如 lscatecho 等读取类工具,它们在受控参数下可以直接放行,不需要用户逐条添加 allowlist。

skills 路径更有意思------当用户启用了 Skill 的 autoAllowSkills,由 Skill 安装的工具(比如某个 Skill 安装了 gh CLI),AI 用这个工具时可以自动放行,不需要手动添加 allowlist。

Shell Wrapper 解包

当命令是 bash -c "git status" 这样的 shell wrapper 调用时,简单匹配 bash 显然不对,应该匹配内部的 gitresolveAllowAlwaysPatterns 会解包这层 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:文件操作的透明代理

在沙箱模式下,文件读写不能直接操作宿主机文件系统。SandboxFsBridgesrc/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。这些都支持事后审计。


本文涉及的主要源文件:

相关推荐
天才聪2 小时前
鸿蒙开发vs前端开发1-父子组件传值
前端
卡尔特斯2 小时前
Android Studio 代理配置指南
android·前端·android studio
李剑一2 小时前
同样做缩略图,为什么别人又快又稳?踩过无数坑后,我总结出前端缩略图实战指南
前端·vue.js
Jolyne_2 小时前
Taro样式重构记录
前端
恋猫de小郭3 小时前
Google 开源大模型 Gemma4 怎么选,本地跑的话需要什么条件?
前端·人工智能·ai编程
文心快码BaiduComate3 小时前
Comate搭载GLM-5.1:长程8H,对齐Opus 4.6
前端·后端·架构
熊猫钓鱼>_>3 小时前
AI驱动的Web应用智能化:WebMCP、WebSkills与WebAgent的融合实践
前端·人工智能·ai·skill·webagent·webmcp·webskills
毛骗导演3 小时前
OpenClaw Pi Agent 深度解析:嵌入式 Agent 运行时的架构设计与实现
前端·架构