上一章节讲了工具执行流水线,其中两个环节最让人关心:沙箱怎么约束命令,审批怎么决定「能不能做」。

本章节将深入 ctx.sandbox 与 ctx.approval 两个服务,讲清它们各自的分工与故障关闭原则。

一句话:沙箱管「命令跑在什么边界里」,审批管「这个具体操作是否被允许」,两者都默认失败关闭。


先看整体:沙箱决策 + 审批流程

下面的图把两个服务放在一起看:左边是沙箱如何包装 argv,右边是审批如何做出一次性决策。

沙箱决策与审批流程图

它们共同回答了同一个问题:Agent 想做一件有风险的事,怎么把它约束住。

沙箱把「进程能碰哪些文件」圈起来,审批把「是否放行这一次操作」交给应答者决定。


ctx.sandbox:按策略包装 argv

进程沙箱的模型是:消费方交出确切的 argv,后端按文件效果策略把它包装起来。

关键点是「确切 argv」而不是 shell 字符串——一个 shell 形态的消费方要传 ['bash', '-c', command]

ctx.sandbox.confine(argv, policy) 返回一个 ConfinedArgv,也就是「替换后的 argv 加上后端的强制执行事实」。

SandboxMode(沙箱模式)只管控文件系统效果,不含网络与进程可见性。

read-only:只允许必需的数据接收端(如 /dev/null),拒绝写入。

workspace-write:还允许在工作区根目录及后端承诺的临时区域下写入。

danger-full-access:绕过隔离,消费方直接 spawn 原始 argv,不调用 ctx.sandbox。

只有前两种模式会发给提供方,danger-full-access 根本不进沙箱。

这就保证了一个安全性质:受限执行必然到达 ctx.sandbox,静默的无隔离透传永远不合法。


强制执行程度与回退规则

后端报告它实际达成的强制执行完整度:enforcement: ‘full’ | ‘partial’。

full 表示后端管控了该模式承诺的所有文件效果;partial 表示只管控了子集。

当前的部分强制执行情形包括较旧的 Landlock ABI,以及 Windows ACL runner 的 Everyone 与硬链接边界。

要求绝对边界的消费方必须把 partial 当作「不够」处理,拒绝或向上暴露这一区别。

策略的解析有明确的回退顺序,官方文档把它归纳为三层:

优先级来源说明
最高已批准的显式模式一次性提权重试时传入的 mode,胜过会话策略
其次会话最后一次 sandbox/mode 事件随会话日志持久化,可回放重建
回退部署默认模式无 agent 的调用与没有 cwd 的会话使用配置的根目录

普通工具调用从调用会话的不可变 cwd 派生 workspaceRoot

root 会先按文件系统语义规范化,再做词法规范化,因此包含 symlink/.. 的 cwd 会标识进程实际运行的目录。

故障关闭:当没有可用后端时,ctx.sandbox.confine 会抛出 SandboxUnavailableError,错误码 SANDBOX_UNAVAILABLE

受限策略下,静默的无隔离透传永远不合法。


动手示例:受限模式下跑一次 bash

用代码把「沙箱化 bash」的调用路径串起来看看。

实例

// 文件路径:my-plugins/sandbox-demo/src/index.ts
// 演示沙箱化消费方:先解析策略,再让 ctx.sandbox 包装 argv。
import type { Context } from ‘@deepseek-ai/cordis’
import { defineTool } from ‘@deepseek-ai/dsh-tools’

export const name = ‘sandbox-demo’
export const inject = [’tools’, ‘sandbox’, ‘sandboxPolicy’]

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: ‘sandboxed_echo’,
    description: ‘Run echo inside the sandbox. The runoob demo command.’,
    parameters: {
      text: { type: ‘string’, required: true, description: ‘Text to echo’ },
    },
    output: { schema: { type: ‘string’ } },
    async execute(args, exec) {
      // 1. 解析本次调用的完整策略:会话 cwd 就是工作区边界。
      const policy = ctx.sandboxPolicy.resolve({ session: exec.agent?.session })

      // 2. 消费方交出确切 argv(程序加参数),不是 shell 字符串。
      const argv = [‘bash’, ‘-c’, `echo ${JSON.stringify(args.text)}`]

      // 3. danger-full-access 直接 spawn;其余交给沙箱包装。
      if (policy.mode === ‘danger-full-access’) {
        const { spawn } = await import(’node:child_process’)
        // … spawn(argv) 并收集输出
        return `echo ${args.text}` // 演示:正常返回
      }

      // 4. 受限模式:confine 返回替换后的 argv,无后端则抛 SANDBOX_UNAVAILABLE。
      const confined = ctx.sandbox.confine(argv, { mode: policy.mode, workspaceRoot: policy.workspaceRoot })

      // 5. 消费方再 spawn confined.argv,并按 confined.enforcement 决定是否要求 full。
      if (confined.enforcement === ‘partial’ && policy.mode !== ‘read-only’) {
        return { isError: true, error: { message: ‘partial enforcement is not acceptable for runoob demo’ } }
      }
      return `confined echo ${args.text} (enforcement: ${confined.enforcement})`
    },
  }))
}

说明:上面代码是演示性质的参考写法,省略了 spawn 与收集输出的细节。

生产级的消费方是 dsh-bash-sandbox,它同时负责 spawn 与结果归因。

真正的难点在结果分类:要把「沙箱 runner 失败」与「沙箱正常工作但拒绝」区分开。

沙箱还带回两种正交的 stderr 分类器:

denialSignatures:识别沙箱正常工作时、受限命令被阻止的情况(bwrap 的 EROFS 文本、Landlock 的 EACCES、Seatbelt 的 EPERM)。

runnerFailureRules:识别 runner 在执行命令之前拒绝或失败的情况(Landlock 失败要求退出码 125 加一行致命诊断)。

消费方应先把 runner 失败作为沙箱基础设施故障上报,而不是普通任务失败。


ctx.approval:一次性权限决策

审批服务回答一个很窄的问题:这个具体操作是否可以继续?

它通过 approval/request waterfall 把问题分发给应答者(answerer)。

应答者在负责处理该请求时返回结果,否则调用 next() 委托;第一个应答者占据唯一的决策槽位。

结果类型 ApprovalOutcome 是闭合的:

结果含义调用方的行为
allowed-once一次性放行唯一放行结果,只授权所询问的那一个操作
rejected明确拒绝拒绝
cancelled请求被撤回(如 signal 中止)拒绝
unavailable无应答者、应答者抛异常或返回值不合规失败关闭,一律拒绝

关键安全性质:缺失、不负责该请求、抛异常或不合规的应答者都会产生 unavailable,而不是放行。

调用方对 rejectedcancelledunavailable 一律执行拒绝。


按会话审批策略:ask 与 never

ApprovalPolicy 决定在交互式应答者运行之前发生什么。

策略行为典型场景
ask(默认)委托给组合的应答者链;无应答者时回退为 unavailable交互式 UI,需要人做决定
never确定性返回 rejected,不分发任何应答者CI、无人值守运行的严格无头姿态

生效值是会话日志中最后一条 approval/policy 事件,回退到服务配置。

setApprovalPolicy(session, policy) 是唯一的写入路径,因此回放能重建覆盖值。

never 在服务内部、waterfall 分发之前强制执行,所以即使后来用 prepend 注册的应答者也无法绕过它。

审计与模型可见性的区别:审批的审计事件对(approval/askedapproval/decided)只写日志,不进入模型 transcript。

模型可见的行为是调用方派生的工具结果与当前运行时上下文快照。


权限预设:两个 knob 捆绑成具名预设

沙箱模式(sandbox/mode)与审批策略(approval/policy)是两个相互独立的 knob。

权限预设层 ctx.permissionPresets 把它们捆绑成具名预设,供客户端作为单个「权限」选择器提供。

预设沙箱模式审批策略含义
workspace-writeworkspace-writeask可写工作区,敏感操作问用户
danger-full-accessdanger-full-accessnever绕过沙箱、不询问,只能跑在可丢弃环境

预设表是配置驱动的,默认表自带上面两个;名字 custom 被保留给「派生的非预设状态」。

current(events) 从两个 knob 折叠实际生效的预设,而不只是看预设自己的事件。

两个预设共享同一个 knob 组合时,permission/preset 事件让 current() 仍能保住用户选的究竟是哪一个。

重要:权限预设只描述它实际管辖的能力。

官方事故复盘 0002 就提醒过:组合时的文件系统访问无法安全地跟随运行时的 bash-only 预设。

在 runoob 生产环境里,danger-full-access 只应配给一次性、可丢弃的沙箱环境。


小结自测

沙箱与审批把「能力边界」与「决策授权」分开:沙箱用文件效果策略包装 argv,审批用瀑布式应答者做一次性放行。

自测题:

问题参考
消费方想跑 danger-full-access 模式,它会调用 ctx.sandbox 吗?不会,直接 spawn 原始 argv,不进沙箱
审批链没有任何应答者,结果是什么?unavailable,失败关闭,调用方拒绝
在 CI 里想「绝不询问、确定性拒绝所有审批」,用哪个策略?approval/policy: never