Skip to content

Sandbox Runtime 集成

本课交付结果

你将交付 SandboxRuntime.execute(command):先分类,只有 allow 才以固定工作区和 shell: false 调用执行器;ask/deny 原样返回决定且绝不执行。

岗位问题

单独正确的 Policy 和 Executor 仍可能在组合处失效:先执行后审批、cwd 落到宿主目录、ask 分支误当 allow。真正的安全性质必须在集成边界用调用次数证明。

前置检查

前置知识快照

验证第 12、13 课:pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 12pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 13

Command Guard 是 Policy Decision Point,Process Executor 是执行机制,Sandbox Runtime 是 Policy Enforcement Point。三者分离便于独立测试,但所有调用必须最终经过 Runtime;若其他模块仍能直接拿到 Executor,正确策略只是可选建议,不是安全边界。

本课最重要的不变量不是返回 askdeny,而是这两个分支没有启动任何进程。副作用缺失属于负性质,必须用 mock 调用次数、无文件变化或无进程事件证明,不能只看最终状态文字。

原理拆解

执行顺序不可交换:classify → 判断 decision → allow 时 run。Runtime 自己固定 cwd 与 shell 选项,不接受模型覆盖。结果同时保留 decision 和可选 execution,方便 Trace 重建因果链。

mermaid
flowchart TD
  A[结构化 Command] --> B[复制并冻结载荷]
  B --> C[Guard classify]
  C --> D{decision}
  D -- deny --> E[返回 decision,无 execution]
  D -- ask --> F[返回审批请求,无 execution]
  D -- allow --> G[Runtime 固定 cwd + shell:false]
  G --> H[Process Executor]
  H --> I[decision + execution]

顺序不能写成“先启动、同时审批”,因为命令可能在第一个 await 之前完成副作用。也不能把 ask 当成“暂时允许,之后补确认”;询问是暂停态。只有明确 allow 或携带经过验证的一次性批准,才进入 Executor。

Runtime 固定 workspace,而不是接受 command 中的 cwd。模型控制 cwd 就能把安全的 git status 转到另一个仓库,或让测试读取用户主目录。构造 Runtime 时绑定经过 realpath 的可信根,整个实例生命周期内保持一致。

shell:false 也由 Runtime 强制,即使下层 Executor 默认如此。安全关键选项在 Enforcement Point 显式传递,调用者不能覆盖。总项目可以让 Executor 类型根本不接受 shell:true,用类型和运行时双重收紧。

argv 在分类后复制,再传执行器,防止调用方在异步边界修改原数组。生产审批等待更长,应对规范 command 生成哈希,批准 token 绑定哈希、workspace、规则版本和会话;执行前重新核对,确保批准与执行完全一致。

结果保留原始 Decision 和可选 Execution。Trace 可回答“命令为何没有执行”或“哪条规则允许后得到什么退出码”。若只返回 execution,上层无法区分未执行与启动失败;若只返回 decision,又丢失实际结果。可辨识结构让因果链清晰。

依赖注入让集成安全性质可测试。Lab 注入 classifyrun,无需启动真实命令就能证明顺序和参数;正式 Runtime 注入受信 Guard 和 Executor 实例,但不把依赖暴露给模型或插件。

代码实验

失败实现

下面实现先启动再检查决定,已经无法撤回副作用:

ts
async function unsafe(command: Command) {
  const execution = run(command, { cwd: command.cwd, shell: true });
  const decision = classify(command);
  if (decision.decision === "deny") return { decision };
  return { decision, execution: await execution };
}

正确实现先分类,非 allow 立即返回:

ts
export class SandboxRuntime {
  constructor(
    private readonly workspace: string,
    private readonly deps: SandboxDeps,
  ) {}

  async execute(command: Command): Promise<SandboxResult> {
    const safeCommand = {
      executable: command.executable,
      args: [...command.args],
    };
    const decision = this.deps.classify(safeCommand);
    if (decision.decision !== "allow") return { decision };
    const execution = await this.deps.run(safeCommand, {
      cwd: this.workspace,
      shell: false,
    });
    return { decision, execution };
  }
}

Lab 的参考实现是在 run 前复制 args;更严格版本在 classify 前就复制,让 Guard 与 Executor观察同一快照。文章采用更强形态,迁移总项目时应确保测试断言一致。

打开本课 Lab

bash
pnpm --dir bootcamps/coding-agent/labs/15-sandbox-runtime/starter test
pnpm --dir bootcamps/coding-agent/labs/15-sandbox-runtime/solution test

Starter 声明 组合策略与执行器;Solution 应通过 4 项测试,mock 明确证明 ask/deny 的执行次数为零。

负副作用测试是本课核心:

ts
it.each(["ask", "deny"] as const)("%s 决定绝不执行", async (kind) => {
  const run = vi.fn();
  const runtime = new SandboxRuntime("/workspace", {
    classify: () => ({ decision: kind, reason: "blocked" }),
    run,
  });
  const result = await runtime.execute({ executable: "rm", args: ["x"] });
  expect(result.execution).toBeUndefined();
  expect(run).not.toHaveBeenCalled();
});

关键实现讲解

先复制 argv 再传给执行器,避免工具在异步期间修改命令。decision !== "allow" 立即返回;执行选项由 Runtime 构造为 { cwd: workspace, shell: false }

不要用 if (decision.decision === "deny") return 后让其他值执行,这会把未来新增状态或拼写错误当成 allow。只有精确等于 allow 才进入副作用路径,是默认拒绝原则在控制流中的体现。

审批不是 Runtime 内部弹窗。ask 结果交给上层 UI 或队列,获得绑定 token 后发起新的已批准执行请求;Runtime 验证 token 再调用。把 UI callback 塞入策略会让无界等待、重入和测试复杂化,也无法在 CLI、服务与 CI 复用。

运行与验证

真实运行输出

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 15
pnpm --filter @coding-agent/sandbox test

检查 allow 恰好执行一次,ask/deny 零次,执行器收到固定 /workspaceshell: false

实际运行中 Starter 四项全部失败,Solution 四项全部通过:

text
starter: 4 failed, 0 passed
solution: 4 passed, 0 failed
覆盖: allow 执行一次 · ask 零执行 · deny 零执行 · 固定 cwd 与 shell:false

再让调用方在 execute 后立即修改原 argv,确认 runner spy 仍收到原快照;让 classify throw,确认 run 未调用;让 run 失败,确认 Trace 或上层能够区分执行器故障与策略拒绝。

常见失败与排查

故障案例 1

界面显示拒绝,文件却已经变化。

症状:返回 decision 为 deny,但命令创建的文件或日志仍然存在。

根因:Runtime 先调用 run,再等待或检查分类结果;Promise 已经启动副作用。

定位:mock run 同步记录调用,deny 分支断言调用次数;用无害夹具检查是否产生标记文件。

修复:分类在任何副作用前完成,只有精确 allow 进入 run;将 Executor 只暴露给 Runtime。

故障案例 2

询问态在无人值守环境自动执行。

症状:没有用户响应时,ask 命令仍继续,三态实际上被转换为布尔非 deny。

根因:控制流只排除 deny,把 ask 与 allow 放在同一分支。

定位:为所有非 allow 状态执行表驱动负测试,检查 run 是否为零调用。

修复:采用 decision !== "allow" 立即返回;无人值守 ask 映射为暂停或拒绝,绝不自动批准。

故障案例 3

安全命令在错误目录执行。

症状git status 被 allow,却读取宿主仓库或用户目录,Trace 显示意外文件。

根因:cwd 来自模型参数、当前进程目录或可变全局状态,没有绑定 Runtime workspace。

定位:runner spy 断言精确 cwd;在两个临时仓库运行同一命令验证实例隔离。

修复:构造时绑定真实工作区,执行选项由 Runtime 创建;切换工作区创建新实例和审批身份。

课后作业

接入审批 token,确保 token 只对应一次精确命令;添加工作区 realpath 校验、Trace 事件和并发审批测试。

进阶要求是把 Runtime 做成显式状态机:classified、waiting_approval、approved、running、finished,并为每次转换记录关联 id。模拟 token 过期、重放、命令变更、策略热更新和两个并发审批,证明任何竞态都不能绕过唯一 allow 边。

验收 Rubric

维度通过标准常见扣分
顺序先策略后执行执行后补审批
副作用ask/deny 调用次数为零只看返回状态
边界Runtime 固定 cwd 与 shell:false接受模型覆盖

总项目增量

总项目包:@coding-agent/sandbox

总项目路径:packages/sandbox/src/sandbox-runtime.ts

总项目验证命令:pnpm --filter @coding-agent/sandbox test

Week 3 至此形成权限、命令规则、受限执行和失败解析闭环。

延伸阅读

方案对比与工程取舍

把分类、审批和执行写进一个大函数最少跳转,却难独立测试规则与进程资源;完全拆成自由组合组件又容易被调用方绕过。合理架构是内部组件分离、对外只暴露 Runtime 门面。Guard 与 Executor拥有单元测试,Runtime 用集成测试锁住调用顺序与参数。

装饰器或中间件链也能组合策略、Trace 和执行,扩展灵活,但顺序可能由注册配置改变。安全关键链更适合显式代码或不可变流水线:规范化、分类、审批验证、执行、记录。允许插件插入观察中间件时,不能让它跳过或重排强制步骤。

Runtime 叫 Sandbox 容易让人误解。它提供应用层命令策略、固定 cwd、关闭 shell 和资源限制,却不能阻止获准二进制利用系统调用读取宿主、访问网络或逃逸。面对不可信代码,需要容器、虚拟机、seccomp、namespace、最小权限用户和只读挂载。名称不应替代威胁模型说明。

Policy Decision Point 与 Enforcement Point 分离的好处,是规则可更新、回放和解释;风险是两者之间的时间差。决定后命令、资源、workspace 或策略版本发生变化,原决定可能失效。规范载荷哈希与短期 token 把决定绑定到执行事实,缩小这条缝。

审批 token 至少包含请求 id、会话、用户、workspace 标识、command 哈希、规则版本、过期时间和随机 nonce,并由受信服务签名。Runtime 原子检查并消费,任何字段不符或重复使用都拒绝。只传一个布尔 approved:true 没有来源与完整性,插件可以自行伪造。

批准一次与批准规则不同。一次 token 只执行一条精确命令;用户选择“本会话允许测试”时,应创建受限临时规则,仍由 Guard重新分类每次具体 command。不能把一个 token重复用于任意后续参数,否则一次确认变成隐形永久能力。

ask 请求在等待期间不应占用进程、文件句柄或 Executor slot。它只是持久化的不可变意图;用户批准后重新进入 Runtime,并再次检查工作区与策略。等待超时自动过期,界面明确显示动作尚未发生。

deny 不提供审批按钮,因为它表示产品安全底线;ask 才可批准。若 UI 对 deny 也允许“仍然执行”,三态语义被界面破坏。用户确实需要通用 shell 时,应在外部终端或更强隔离能力中完成,而不是给 Runtime 留后门。

命令分类可能依赖 cwd 或项目脚本,Runtime 应把可信 Context 传给 Guard,而不是让 Guard自行读任意磁盘。决定输入的所有证据形成快照与哈希;执行前若相关文件变化,例如 package script 被替换,应重新分类和审批。

Trace 事件建议包括 request_created、classified、approval_requested、approval_granted/denied/expired、execution_started、execution_finished。每个事件有单调序号、关联 id、规则版本和脱敏 command 哈希。缺少 started 但有 ask,能证明未执行;started 后无 finished,则提示崩溃或遗留进程。

不要在写 Trace 失败时默认执行。审计是高风险环境的强制条件时,先成功记录 classification 和 start,再运行;若日志服务不可用,降为 ask 或 deny。低风险本地模式可以容忍本地 Trace 失败,但策略必须显式配置,不能静默丢证据。

Runtime 的并发限制位于执行前。allow 不代表立即 spawn;任务进入受控队列,取消排队请求不会产生进程。排队期间若 token 过期或策略更新,出队时重新验证。资源调度与安全批准是两个条件,必须同时满足。

命令数组复制只是最小防变异。executable 字符串不可变,但依赖对象、options 与 Context 也应冻结或重新构造。不要把调用方对象直接传给 Executor,使插件能通过 getter、原型或共享引用改变行为。边界处创建普通数据快照更易审计。

错误传播需要保留阶段。classify 异常属于策略系统故障,run 启动失败属于执行基础设施,非零 code 是命令结果,ask/deny 不是错误。统一 {stage, code, message} 让上层采取正确动作,也避免把策略崩溃当默认 allow。

默认拒绝同样适用于未知 Decision 值。运行时数据可能来自插件或跨进程服务,即使 TypeScript 类型只列三种也要检查。只有字面量 allow 通行,其余全部不执行并记录协议错误。静态类型不能保护运行时不可信边界。

文件工具与命令 Runtime 应共享 workspace 身份。路径 realpath、命令 cwd、审批资源和 Trace 都指向同一个不可变 root id;如果各自只保存字符串,macOS 别名、链接或路径替换会造成不一致。启动时解析真实根并保存设备/目录身份,风险更低。

执行完成后可检查工作区差异,把获准命令的实际副作用与规则预期比较。只读规则产生修改时发出告警,测试规则修改锁文件时要求用户审查。行为验证不是阻止所有外部副作用,但能发现 Guard 假设漂移并自动降级规则。

回滚最好在临时工作树或快照中实现,而不是依赖命令反向操作。Runtime 可为自动任务创建隔离 worktree,执行后展示 diff,批准才合并。即使命令被错误允许,影响范围也限制在临时副本。应用层审批与工作区隔离结合,比任一单层可靠。

测试 Runtime 时,mock 证明调用次数和参数,真实无害夹具证明组件组合,容器集成测试证明系统隔离。三层测试回答不同问题:控制流正确、进程接口兼容、宿主边界有效。用单一 mock 全绿不能宣称拥有操作系统沙箱。

变异门禁应尝试把 ask 视为 allow、交换 classify/run、允许调用方 cwd、开启 shell、复用 token、修改 argv 和绕过 Runtime直接调用 Executor。每个变异都应被测试、类型或模块可见性阻止。安全目标必须转化为能杀死错误实现的证据。

Week 3 端到端演练可以这样做:检测到 pnpm test,Guard allow,Runtime固定临时 workspace,Executor用干净环境和预算运行,失败 Parser返回 assertion;再提交 pnpm install 得到 ask 且 run 零调用,提交 rm 得到 deny 且仍零调用。三条路径共同证明可用性、审批与底线。

第一条 allow 路径也不能只断言 run 被调用。应断言恰好一次、命令内容是分类时快照、cwd 是构造参数、shell 固定 false、没有模型提供的额外环境;结果同时含 allow 决定与 execution。调用两次应得到两次独立事件,不能复用上次 Promise 或结果对象。

第二条 ask 路径返回 reason,execution 字段不存在,run 为零调用。上层可以把完整不可变请求存入审批队列,但 Runtime 当前调用已经结束,不保留悬空回调。用户批准后携带 token 发起新流程,不能在旧 Promise 中继续使用可能已经变化的对象。

第三条 deny 路径同样零调用,但 UI 不应提供批准入口。Trace记录命中 deny rule 与资源摘要,Agent收到可行动替代建议。若模型反复提交同一 deny 命令,循环按 fingerprint 停止,而不是不断触发策略日志和浪费轮次。

classify 自身抛错时默认不执行。Runtime返回或抛出 POLICY_EVALUATION_FAILED,Trace标记阶段,绝不能为了“不中断任务”回退 allow。安全组件不可用时收紧能力,是 fail closed 的实际含义。对低风险读取可以有独立策略,但命令执行不应猜测。

run 抛出启动错误时,decision 仍然是 allow,因为策略确实允许;execution阶段失败另行表示。把结果改成 deny 会伪造历史原因,也让团队错误修改 Guard。决定与事实分层,才能准确诊断可执行文件缺失、cwd 错误或权限不足。

如果 Executor 返回非零 code,Runtime不应自行重试。上层 Failure Parser分类后决定修复或审批,Runtime只维持一次请求到一次执行的对应。自动重试写命令可能重复副作用,也会让审批一次实际运行多次。需要重试时创建新关联执行并保留原结果。

工作区在 Runtime 构造时需要存在且为目录,取得 realpath 后保存。若目录之后被替换,强隔离环境可用文件描述符或容器挂载保持身份;普通 CLI 至少在每次高风险执行前重新验证。只比较构造字符串无法抵抗链接替换。

多租户服务不能把 Runtime 作为跨用户单例。每个会话绑定租户、workspace、策略和凭据最小集,队列也按租户限流。共享 Guard 规则可以不可变复用,具体 Context 与审批 token 必须隔离,防止一个用户批准影响另一个工作区。

插件只能请求命令能力,不应获得 deps.run 引用。模块导出与依赖注入容器将 Executor设为内部,Registry注册的命令工具调用 Runtime 门面。代码审查可用静态搜索检查直接导入,架构测试则限制包依赖方向。边界若只能靠团队自觉,迟早被便捷调用绕过。

一次性批准消费需要事务语义。并发两个请求携带同一 token,只有一个原子成功,另一个在 spawn 前失败;先标记已用再进程启动失败时,是否允许恢复也要定义。安全优先通常不自动恢复 token,用户重新审批,避免不确定状态下重放。

审批过期与队列等待要协调。token 在排队中到期,出队时拒绝而非继续;界面显示需要重新确认。若允许在入队时消费,则必须确保调度失败不会永久卡住且不会被第二执行复用。状态机与事件日志能让这些边界可测试。

策略热更新中新增 deny 应废弃所有匹配的待执行 token。只在 token 中保存旧版本然后照常执行,会让紧急封禁无法生效;全部更新都废弃又增加摩擦。可以定义安全版本:权限收紧立即生效,非安全文案变化保持兼容,并用规则 diff 自动判断。

Trace 写入也必须脱敏。command args 可能包含 token、私有路径或用户文本;审批展示与日志采用 Schema 指定的敏感位置或通用秘密扫描,哈希仍绑定完整规范载荷。执行 stdout/stderr由 Executor预算与脱敏,Runtime只关联摘要,不复制无限内容。

隔离工作树能提高可恢复性,但不要把 .git 管理命令全部自动允许。创建与清理 worktree由受信基础设施完成,不通过模型命令;模型只在已准备的目录运行窄测试和补丁工具。控制面与任务数据面分开,避免 Agent修改自己的隔离机制。

容器启动配置同样是安全关键控制面。镜像、挂载、网络、用户、capabilities 和资源上限来自受信模板,模型不能提交任意 Docker argv。Runtime可以选择模板与任务参数,却不能让通用命令绕到宿主 Docker socket,否则容器隔离完全失效。

发布前做一次红队演练:模型依次请求 shell、下载器、force push、未知命令、获准测试、变异 argv、外部 cwd 和重放 token。检查 deny/ask 零进程、allow受限、变异重新审批、cwd固定、重放失败、Trace完整。演练结果与自动测试一起成为 Week 3 验收证据。

最后审计包依赖图与公开 API,确认只有 Sandbox Runtime 能构造或调用生产 Executor,插件、工具与 Agent Loop 都没有旁路。运行时测试证明正常入口正确,架构约束证明其他入口不存在。两类证据结合,策略才从“通常会执行”升级成“不可绕过的执行边界”。

这项约束应进入持续集成:任何新增直接依赖都立即失败,并要求开发者通过正式门面接入。安全架构只有在日常开发中自动守护,才不会因一次方便重构逐渐失效。

上线指标包括 allow/ask/deny比例、审批等待与拒绝率、执行超时、截断、策略错误、token 重放和只读命令产生写入的偏差。指标按规则版本分组,变更后能快速发现摩擦或权限扩张。不能只统计成功命令数,那会奖励过度放行。

方案之外的边界说明

即使 Runtime 全部测试通过,获准运行的编译器或测试仍是本机代码。陌生仓库可以在测试初始化阶段执行任意逻辑,读取所有当前账户可见资源。正式自动化必须把 workspace复制到隔离环境,只挂载必要目录,关闭默认网络,使用临时凭据和资源配额。应用层 Runtime 负责“应该运行什么”,操作系统隔离负责“即使运行也最多影响什么”。

下一课衔接

Week 3 至此形成完整安全执行链。下一周将进入补丁系统:模型根据受限上下文生成统一 diff,Runtime 解析、校验路径和 hunk,再应用到隔离工作区并运行本周建立的测试闭环。命令安全保证验证阶段可控,补丁安全将保证代码修改本身可审查、可回滚。

  • Policy Enforcement Point 与 Policy Decision Point。
  • 容器、seccomp、文件系统 namespace。
  • 审批 token 的重放防护。

重要边界:本课 Sandbox Runtime 只是应用层策略与资源限制,不是操作系统级隔离。面对不可信代码,仍需容器、最小权限账户、网络策略与只读挂载。

从零实现 Mini Code Agent Runtime