在 DeepSeek Harness 的工具执行流水线里,有两个环节最让高级开发者又爱又怕:一个是决定命令跑在什么边界里的 ctx.sandbox,一个是决定这一次具体操作到底放不放行的 ctx.approval。前者管约束,后者管授权,看上去各司其职,但它们共享同一条底线——默认失败关闭。换句话说,当系统无法确认一件事是否安全时,答案不是放过,而是拦下。这篇文章面向已经写过工具插件、理解插件生命周期与工具注册机制的高级读者,会沿着「危险操作要穿过哪两道闸门」这条主线,把沙箱决策、审批流程、SandboxMode 三档权限、enforcement 完整度报告、策略三层回退顺序以及 workspaceRoot 的规范化语义逐一拆开。读完上半段,你应该能准确回答一个问题:当 Agent 想执行一条危险命令时,Harness 究竟在哪些点上把它关进了笼子,以及每个笼子上的锁具体锁住了什么。

沙箱决策 + 审批流程全景:一次危险操作要穿过哪两道闸门

先把两个服务放在同一张图里看,理解它们是在回答同一个问题的两个侧面:Agent 想做一件有风险的事,怎么把它约束住。左侧是沙箱如何包装 argv,右侧是审批如何做出一次性决策。沙箱把「进程能碰哪些文件」圈起来,审批把「是否放行这一次操作」交给应答者决定。二者都不是在事后补救,而是在动作真正落到操作系统之前完成裁决。

示意图
沙箱决策与审批流程并排展示:左边按策略包装 argv,右边做一次性放行判断,两道闸门共同约束 Agent 的危险操作。

这里最需要建立的第一直觉是职责切分。沙箱关心的不是「这个操作该不该做」,而是「如果要做,进程能触达的文件系统范围有多大」。它是边界问题。审批关心的不是「进程能跑多远」,而是「这一次、这个具体操作,当前是否被允许」。它是授权问题。把两者混为一谈是很多插件作者最早踩的坑:以为进了沙箱就万事大吉,或者以为审批过了就可以无视沙箱。实际上,一个操作可以既被沙箱包裹、又被审批放行,也可以在沙箱里被拒绝、或者被审批驳回,两条路径各自独立生效。

沙箱决策的入口是 ctx.sandbox.confine(argv, policy),它消费确切的 argv,返回一个包装结果。审批流程则在沙箱之前或之外,对「是否执行」给出一次性判断。注意「一次性」这个词很关键:审批不是给某个工具永久开绿灯,而是针对某一次调用做出的决定。下次再来同样的操作,仍然要重新走一遍。这种设计把授权颗粒度压到了单次调用级别,避免了一个宽泛的「允许」被后续调用悄悄继承。

还有一个贯穿全篇的结论必须提前说清:沙箱和审批都默认失败关闭。所谓失败关闭,是指当依赖的组件不可用、信息不完整、或者执行完整度达不到承诺时,系统倾向于拒绝执行而不是放行。这听起来保守,但对于一个能读写文件、能起进程的 Agent 框架来说,任何「静默放行」都是潜在的灾难入口。失败关闭不是一句口号,它在代码路径上有具体体现:受限策略下 ctx.sandbox.confine 在没有可用后端时会抛出 SandboxUnavailableError,错误码为 SANDBOX_UNAVAILABLE;消费方拿到 partial 的强制执行完整度时,如果它要求绝对边界,就必须把 partial 当作「不够」来处理。这些细节我们后面逐一展开。

把两道闸门的输入输出对齐一下,有助于建立工程上的精确感:沙箱那侧的输入是确切 argv 加一份策略对象,输出是替换后的 argv 加上后端实际达成的强制执行事实;审批那侧的输入是一次具体操作请求,输出是允许或拒绝的一次性判定。两条路径的共同点是都不信任「默认执行」,都要求显式地经过某个受控步骤。理解了这一点,再去看具体的模式档位和回退顺序,就不会觉得它们是零散的规则,而是同一套安全哲学在不同层面的投影。

ctx.sandbox.confine(argv, policy):为什么必须交出确切 argv 而非 shell 字符串

进程沙箱的模型可以概括成一句话:消费方交出确切的 argv,后端按文件效果策略把它包装起来。这里的每一个词都有分量。「消费方」指的是调用沙箱的那个工具或插件,「后端」指的是真正负责实施隔离的底层实现,「文件效果策略」指的是这次包装要达成的文件系统约束目标。

为什么强调「确切 argv」而不是 shell 字符串?因为沙箱要判断和约束的是进程实际会执行什么。一个 shell 字符串在被解释之前,你很难说清它到底会触碰哪些路径、会不会因为变量展开而逃逸预期边界。而 argv 数组是已经拆分好的程序名加参数列表,语义确定、无二次解析。这正是安全边界最需要的性质:没有歧义。所以如果一个消费方天然是 shell 形态的,它必须把命令包成 ['bash', '-c', command] 这样的 argv 形式交给沙箱,而不是直接把裸字符串塞进去。这不是风格偏好,而是模型要求:沙箱消费的接口签名就是 argv。

返回值的结构同样值得拆解。ctx.sandbox.confine 返回的是 ConfinedArgv,它包含两部分信息:替换后的 argv,以及后端的强制执行事实。第一部分好理解——后端可能需要在原始 argv 外面套一层包装器,或者改写可执行文件路径,让真正的进程在受控环境下启动。第二部分才是高级读者应该重点关注的:它回答的是「后端声称它做到了多少」。这两个信息缺一不可。如果只看替换后的 argv 就直接 spawn,你可能忽略掉后端其实只实现了部分隔离;如果只看 enforcement 而不用替换后的 argv,你的 spawn 就根本没进沙箱。正确姿势是两个都消费

下面这段 TypeScript 示例把「解析策略 → 交出 argv → 区分危险全开与受限模式 → 消费 ConfinedArgv」这条路径完整串起来,可以直接作为插件骨架粘贴使用。

// 文件路径: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.',
    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' },
        }
      }

      return `confined echo ${args.text} (enforcement: ${confined.enforcement})`
    },
  }))
}

这段代码里有几个工程要点值得单独点出:

  • inject 声明依赖:插件显式注入 tools、sandbox、sandboxPolicy 三个服务,保证在 apply 阶段它们已经可用,避免运行时才发现沙箱服务缺失。
  • argv 必须自洽:用 JSON.stringify 包裹用户输入再拼进命令,是为了让 echo 的参数在 shell 里保持为一个整体,同时也提醒读者,真正的安全边界靠沙箱而非字符串拼接。
  • danger-full-access 分支自行 spawn:这一档根本不调用 ctx.sandbox,消费方对自己的行为负全责。
  • partial 的显式处理:非 read-only 模式下拿到 partial,示例直接返回错误。这是把「不够就是不够」落到代码里的具体做法。

还有一个容易忽略的细节:confine 可能抛异常。当没有可用后端时,它不是返回一个降级的 ConfinedArgv,而是直接抛 SandboxUnavailableError,错误码 SANDBOX_UNAVAILABLE。这意味着消费方必须用 try/catch 或者让错误向上冒泡,绝不能因为拿不到包装结果就自己拼一个原始 spawn 顶上——那恰好是「静默的无隔离透传」,是被明确禁止的行为。失败关闭在这里体现为:宁可让调用失败,也不让它在没有隔离的情况下悄悄跑起来

SandboxMode 三档权限:read-only、workspace-write、danger-full-access 的边界

SandboxMode 是整个沙箱策略里最直观的部分,但它有一个必须记住的限定:它只管控文件系统效果,不包含网络与进程可见性。也就是说,这一档档模式解决的是「进程能读写哪些文件」,而不是「进程能不能联网」「进程能不能看到别的进程」。把这层边界记牢,后续讨论 enforcement 时才不会混淆。

三档权限按开放程度递进,逐档说明如下:

  1. read-only:只允许必需的数据接收端,例如 /dev/null 这类丢弃写入的目标,其余写入一律拒绝。它适合那些只需要读数据、做计算、产出结果给外层消费方的工具。注意这里的关键词是「必需的数据接收端」——并不是所有写入都被一刀切拒绝,而是保留了一组白名单性质的接收端。
  2. workspace-write:在 read-only 的基础上,额外允许在工作区根目录以及后端承诺的临时区域下写入。这一档是大多数代码类工具的默认工作区间:既能在项目目录里生成文件,又不会越界去动系统路径。这里「后端承诺的临时区域」是一个诚实的措辞——临时区具体在哪、有多大,取决于后端实现,而不是模式定义本身。
  3. danger-full-access:直接绕过隔离,消费方自己 spawn 原始 argv,根本不调用 ctx.sandbox。它不进入沙箱流程,因此也没有「后端达成了多少强制执行」这一说。这一档只应在明确知道风险且无从隔离的场景下使用。

用一张表把三档权限的文件系统效果对比清楚,是排查「为什么我的写入被拒」这类问题最快的办法:

SandboxMode文件系统读取文件系统写入是否进入沙箱流程典型适用场景
read-only允许仅允许 /dev/null 等必需数据接收端,其余拒绝是,交给提供方只读分析、纯计算、格式化输出
workspace-write允许允许工作区根目录及后端承诺的临时区域是,交给提供方生成代码、写日志、构建产物
danger-full-access无隔离约束无隔离约束否,消费方自行 spawn确需全局访问且无从隔离的特殊操作

工程上有一个高频误区:把 SandboxMode 当成「网络开关」。它不是。read-only 并不意味着进程不能联网,workspace-write 也不意味着网络被限制。如果你需要一个只读且断网的环境,需要在沙箱之外另行处理网络策略。另一个误区是认为 workspace-write 的写入范围是「当前目录及其子目录」这种朴素理解。实际语义是工作区根目录下的写入外加后端承诺的临时区域,根目录怎么确定,取决于后面要讲的 workspaceRoot 派生规则。如果 cwd 里含 symlink 或 ..,你的直觉很可能和进程实际运行的目录不一致,这一点我们放到 workspaceRoot 小节展开。

最后,三档权限还有一个不对称性需要记住:只有 read-only 和 workspace-write 会发给提供方,danger-full-access 根本不进沙箱。这个不对称不是省略,而是安全性质的一部分,下一节专门讲。

为什么 danger-full-access 不进沙箱:受限执行必然到达 ctx.sandbox 的安全性质

很多人第一次看到 danger-full-access 不进沙箱时,会觉得这只是实现上的偷懒——反正都全开了,何必再走一遍沙箱流程。但把它放在安全性质的框架里看,会发现这个设计是刻意的,甚至是必须的。

先明确事实:只有 read-only 和 workspace-write 这两种模式会发给提供方,danger-full-access 根本不进沙箱。这意味着沙箱的 confine 调用只会在真正受限的场景下发生。由此导出一个关键的安全性质:受限执行必然到达 ctx.sandbox,静默的无隔离透传永远不合法

把这条性质拆开理解。所谓「受限执行」,是指策略处于 read-only 或 workspace-write 状态。在这种状态下,消费方如果想让进程跑起来,唯一的正规路径就是调用 ctx.sandbox.confine。如果它绕过沙箱直接 spawn,那就是「无隔离透传」——名义上受限、实际上裸奔。这条性质要保证的就是:这种透传永远不会被系统默许。它不会给你一个「反正没后端,就当全开算了」的降级路径;相反,没有可用后端时 confine 会抛 SANDBOX_UNAVAILABLE,让受限执行无法在无隔离的情况下完成。

反过来看 danger-full-access 为什么不进沙箱,答案就清晰了:因为它的语义本来就是「无隔离」,如果还让它调用 confine,沙箱要么返回一个什么都没包的 argv(那是自欺欺人),要么就必须为「无隔离」这种模式也定义一套后端行为(那是把危险常态化了)。与其如此,不如让它显式地不进沙箱,让「全开」这件事在代码路径上可见、可审计。消费方在 danger-full-access 分支里自己 import child_process 自己 spawn,读代码的人一眼就能看出这里没有隔离。

这条性质对插件作者的实践要求是:

  • 受限模式下,不要写任何 try/catch 之后 fallback 到原始 spawn 的逻辑。catch 到 SANDBOX_UNAVAILABLE 应该向上暴露,而不是自行降级。
  • 不要试图通过判断「反正读取也拦不住」来给受限执行开后门。模块的边界是文件系统效果,写入被拒就是被拒。
  • 如果确实需要全局访问,应显式把模式切到 danger-full-access,让这个决定暴露在策略层,而不是藏在某个 if 里。

换句话说,沙箱设计拒绝「隐式的全开」。全开可以,但必须明说。这就是为什么这条性质值得被当作公理来记:它把「隔离是否发生」从一个运行时的偶然事实,变成了一个可以在策略层审查的确定事实。

enforcement: 'full' | 'partial':后端如何报告它实际达成的强制执行完整度

ConfinedArgv 里的第二个信息——强制执行事实——是通过 enforcement 字段表达的,取值只有两个:'full''partial'。它是一个自报的完整度指标,而不是一个布尔的是否发生了隔离。

语义上,full 表示后端管控了该模式承诺的所有文件效果。注意「该模式承诺的」这个限定:不同模式承诺的范围不同,read-only 承诺的是只允许必需数据接收端并拒绝其余写入,workspace-write 承诺的是工作区根与临时区域可写。full 意味着这些承诺全部兑现。partial 则表示后端只管控了其中的一个子集——它做了事,但没做全。

官方文档目前列举的部分强制执行情形有两种:较旧的 Landlock ABI,以及 Windows ACL runner 的 Everyone 与硬链接边界。前者是 Linux 侧的内核接口版本差异,较旧的 ABI 能表达的约束有限,后端无法覆盖该模式承诺的全部效果;后者是 Windows 侧通过 ACL runner 实施隔离时,遇到 Everyone 主体与硬链接边界时的局限。这两类情形都不是「后端坏了」,而是「后端在特定平台上只能做到这么多」。把它们如实报告为 partial,比默默假装 full 要安全得多。

消费方面对 partial 的正确处理方式,取决于它对边界的要求有多严格。文档给出的原则是:要求绝对边界的消费方必须把 partial 当作「不够」处理,拒绝或向上暴露这一区别。这句话里有两个动作选项——拒绝,或者把区别暴露给上层。选择哪个取决于工具的性质:如果这个操作的后果不可逆、影响面大,直接拒绝更稳妥;如果上层有能力根据 partial 做出更细的判断,那就把 enforcement 值透传上去,让决策者知晓。

用一张表把 full 与 partial 的处理策略对照清楚:

enforcement 取值含义当前已知触发情形对边界要求严格的消费方应如何处理
full后端管控了该模式承诺的全部文件效果无(正常达成)可继续执行
partial后端只管控了承诺效果的一个子集较旧的 Landlock ABI;Windows ACL runner 的 Everyone 与硬链接边界视为「不够」,拒绝或向上暴露这一区别

工程上有几个细节容易被忽略。第一,partial 不是错误码,confine 不会因为 partial 而抛异常,它照常返回 ConfinedArgv,把判断权交给消费方。这与 SANDBOX_UNAVAILABLE 的性质不同:后者是「根本没有后端可用」,前者是「有后端但能力不全」。第二,示例代码里只对非 read-only 模式把 partial 当作不够,这暗示了一个分寸——read-only 模式下即使 enforcement 是 partial,其承诺范围本就很小,风险相对可控;而 workspace-write 承诺了可写,partial 意味着写入边界的某些维度没被完全管住,这时拒绝更合理。第三,消费方不应把 enforcement 当作可选的装饰信息,它是安全决策的输入之一,必须显式消费。

还有一个实践建议:在调试或日志里记录 enforcement 的实际取值,尤其是跨平台部署时。同一个工具在 Linux 和 Windows 上可能拿到不同的 enforcement,把它记录下来,能帮助你在出问题时快速判断是策略问题还是平台能力问题。这也是「把区别向上暴露」的一种低成本实现方式。

策略解析的三层回退顺序:已批准显式模式 > 会话 sandbox/mode 事件 > 部署默认模式

一次工具调用最终用哪个 SandboxMode,不是单一来源决定的,而是走一套明确的回退顺序。官方文档把它归纳为三层,按优先级从高到低排列:

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

逐层拆解。最高优先级是已批准的显式模式。场景是「一次性提权重试」:某个操作在默认策略下被拦了,用户或上层决定给这一次放行,于是传入一个 mode。这个 mode 直接压过会话级策略。为什么它要最高?因为它是人的显式决定,是最有语境、最知情的一次授权。但注意,它是一次性的,不会写回会话策略,下次同样的调用仍然回到默认轨道。这与前面说的审批「一次性决策」是同一个设计哲学。

其次是会话最后一次 sandbox/mode 事件。会话过程中可能发生过模式切换,每次切换都会产生一个 sandbox/mode 事件,最后一次事件决定了当前会话的有效模式。关键性质是:这个事件随会话日志持久化,可回放重建。这意味着模式不是某个内存里的易失变量,而是会话历史的一部分。你可以从日志回放重建出「在那一刻,这个会话处于哪个模式」,这对审计、复现问题和事故分析都极其重要。对于高级读者,这里隐含一个要求:任何改变模式的代码路径都应该发事件,而不是偷偷改一个变量,否则回放就失真了。

最低一层是部署默认模式。当没有 agent 参与调用、或者会话没有 cwd 时,策略解析落到配置的根目录和默认模式上。这一层是兜底,保证任何调用至少有一个确定的起点,而不是「无策略可用」。注意它同时也决定了 workspaceRoot 的取值:无 cwd 的会话使用配置的根目录。

把三层串起来看,逻辑是:单次显式授权 > 会话当前状态 > 全局默认。这个顺序符合直觉,但工程上有几个坑:

  • 别把一次性 mode 缓存成会话级。提权之所以安全,正因为它不持久。一旦缓存,就等价于默默抬高了会话策略。
  • 模式变更必须发事件。如果你的插件能在运行中切模式,务必要发 sandbox/mode 事件,否则回放重建会得到错误的历史。
  • 无 agent 调用要意识到自己走的是默认层。如果你的工具支持无 agent 场景,要确认部署默认模式是否符合预期,不要假设调用者一定带着会话。
  • resolve 时要传对 session。示例里 resolve({ session: exec.agent?.session }) 在 agent 缺失时会自然落到回退层,这个 ?. 不是随手写的,它对应了「无 agent 调用」这条真实路径。

第一段代码示例已经展示了 resolve 的调用形态,这里再补一个更偏运维视角的片段,演示如何把解析出的策略字段用于日志与自检,方便你在真实部署里排查模式来源问题:

import type { Context } from '@deepseek-ai/cordis'

export const name = 'policy-inspector'
export const inject = ['sandbox', 'sandboxPolicy']

export function apply(ctx: Context) {
  // 在工具执行前先解析一次策略,打日志、做自检。
  function inspect(session: unknown) {
    const policy = ctx.sandboxPolicy.resolve({ session })
    // 模式与工作区根是后续所有边界判断的起点。
    console.log('[policy] mode=%s workspaceRoot=%s', policy.mode, policy.workspaceRoot)

    if (policy.mode === 'danger-full-access') {
      // 全开不进沙箱,这里明确留痕,便于审计。
      console.warn('[policy] sandbox bypassed: danger-full-access')
    } else {
      // 受限模式:确认确实会走 confine,且能拿到后端。
      try {
        const confined = ctx.sandbox.confine(['bash', '-c', 'true'], {
          mode: policy.mode,
          workspaceRoot: policy.workspaceRoot,
        })
        console.log('[policy] enforcement=%s', confined.enforcement)
        if (confined.enforcement !== 'full') {
          // partial 视为不够,向上暴露,而不是静默继续。
          throw new Error(`partial enforcement rejected: ${confined.enforcement}`)
        }
      } catch (err) {
        // 无后端时 confine 抛 SANDBOX_UNAVAILABLE,不允许无隔离透传。
        console.error('[policy] confined probe failed', err)
        throw err
      }
    }
    return policy
  }

  // 这里的 inspect 可在工具执行入口或健康检查里调用。
  void inspect
}

这段代码把本段的几个核心概念串成了一次可观测的自检:解析策略、判断是否走沙箱、消费 enforcement、在无后端时让错误浮出。它不替代真正的沙箱调用,但能让你在部署时快速确认「策略解析出来的模式是否符合预期」「后端是否真的可用」。

workspaceRoot 从哪来:不可变 cwd 的规范化与 symlink/.. 的实际目录语义

前面反复提到工作区根目录,现在把它的来源说清楚。普通工具调用会从调用会话的不可变 cwd 派生 workspaceRoot。注意「不可变」三个字:cwd 在会话层面是固定的,不会随进程运行中被 chdir 之类操作改来改去。这保证了同一个会话里所有工具调用看到的边界是一致的。

派生出来之后,root 要经过两阶段规范化

  1. 先按文件系统语义规范化:这一步会解析 symlink,把路径还原成它在文件系统上真正指向的位置。软链接在这时被「展开」。
  2. 再做词法规范化:处理 .. 和 . 这类相对路径成分,把路径折叠成最简形式。

这个顺序很关键,也解释了一个容易踩的坑:包含 symlink/.. 的 cwd 会标识进程实际运行的目录。如果先做词法规范化,a/b/.. 会被折叠成 a,但若 b 是一个指向别处的 symlink,文件系统语义下有可能是完全不同的位置。先按文件系统语义解析 symlink,再折叠 ..,得到的结果才是进程真正所在的目录。这就是为什么顺序不能颠倒。

对插件作者的实际影响是:

  • 不要自己拼 workspaceRoot。它是策略解析的产物,自己拼容易和沙箱后端的理解不一致,导致「我以为在工作区里、后端认为在工作区外」这类矛盾。
  • 不要对 cwd 做假设。如果你的工具逻辑依赖「当前目录就是某个已知路径」,在含 symlink 的部署环境里很可能出错。相信规范化后的 root。
  • 无 cwd 的会话走配置根目录。这与策略回退的最后一层一致,意味着你的工具在无会话场景下拿到的 workspaceRoot 来自部署配置,而非某个隐式默认。
  • 回放时 root 可重建。由于 cwd 不可变、会话事件持久化,workspaceRoot 在回放中是可复现的,这对复现线上问题非常重要。

把 workspaceRoot 放进整段的安全链路里看,它是「边界」这个概念的物理落点:SandboxMode 决定允许做什么类型的文件操作,enforcement 报告后端做到了多少,workspaceRoot 则定义了写入可以在哪个位置发生。三者合在一起,才构成一个完整的、可审查的文件系统约束。

到这里,上半段把沙箱这条线讲完了:confine 的接口形态、三档模式的边界、danger-full-access 为何不进沙箱、enforcement 的完整度语义、策略的三层回退,以及 workspaceRoot 的规范化来源。但危险操作要穿过的是两道闸门,沙箱只是其中一道。另一道——审批——决定了「这一次具体操作是否被允许」,它处理的是与沙箱正交的授权问题,并且在默认失败关闭上有自己的一套机制。下一段将展开 ctx.approval 的审批流程、一次性决策的实现细节,以及沙箱与审批如何协同构成完整的约束体系。

上一段我们把 ctx.sandboxctx.approval 的分工、SandboxMode 的档位语义、以及三层策略回退顺序拆开讲了一遍,也看到了 enforcement 里 fullpartial 这两个字段值的存在。这一段我们不再停留在概念层面,而是直接从错误码、示例代码、决策分支和验收清单四个方向,把「沙箱这套笼子到底怎么落地」补完。中心结论只有一句:受限策略下,静默的无隔离透传永远不合法,而 consumer 必须对 partial enforcement 负起显式判断的责任。

SandboxUnavailableError 与 SANDBOX_UNAVAILABLE:没有可用后端时的故障关闭

沙箱这个服务最核心的设计前提是 fail-closed(失败关闭),而不是 fail-open(失败放行)。这两者在语言层面只差一个词,在安全语义上却是天壤之别:fail-open 意味着「不确定就放过去」,fail-closed 意味着「不确定就拦下来」。当宿主环境里压根没有可用的沙箱后端时,系统不能假装隔离已经生效、然后把原始 argv 直接 spawn 出去——这么做等于在策略声明为 read-only 或 workspace-write 的情况下,偷偷执行了一次无隔离的命令,笼子形同虚设。

为此,ctx.sandbox.confine(argv, policy) 在找不到任何可用后端时不会返回一个「降级但可用」的结果,而是直接抛出 SandboxUnavailableError。这个错误对象携带的错误码是 SANDBOX_UNAVAILABLE。把它设计成抛出异常而不是返回某种 sentinel 值,是有工程考量的:调用方无法通过「不检查返回值」来悄悄跳过隔离,因为它必须写 try/catch 或者让错误向上冒泡。任何试图忽略它的写法,最轻的后果是工具调用失败,最坏的后果是被上层统一错误处理路径捕获并记录,但无论如何都不会变成「无隔离执行成功」。

这里要特别强调的是素材里那句反复出现的话:受限策略下,静默的无隔离透传永远不合法。这句话有两层含义。第一层是行为层:在 read-only 或 workspace-write 下,consumer 绝不能因为「沙箱不可用」就自作主张改成直接 spawn 原始 argv,哪怕这样能让工具看起来「跑通了」。第二层是架构层:受限执行路径必须强制经过 ctx.sandbox.confine,这是唯一的合法入口;只有 danger-full-access 这种本身就声明放弃隔离的模式,才允许绕过 ctx.sandbox 直接 spawn。也就是说,「绕过隔离」必须是一次显式的、写在策略里的决定,而不能是一次隐式的、由于后端缺失而发生的意外

从错误处理实践看,consumer 对 SandboxUnavailableError 通常有三种合理反应:

  • 原样向上抛出:让上层工具框架把它变成一次失败的调用,错误信息里保留 SANDBOX_UNAVAILABLE 错误码,便于运维定位「这台机器没装好后端」。
  • 转成结构化错误返回:如果工具框架要求 execute 返回结构化结果而非抛异常,就把错误码与 message 放进 isError 结构里,仍然不执行任何命令。
  • 显式切换到 full-access:只有在业务语义确实允许、并且经过审批链同意的情况下,才能把模式改成 danger-full-access 再走直连 spawn。注意这时语义变了:不再是「沙箱失败兜底」,而是「经批准的无隔离执行」。

绝对不推荐的一种写法是 try { confine() } catch { spawn(argv) }。这段代码表面上让工具「更健壮」了,实际上把 fail-closed 悄悄改成了 fail-open,是沙箱机制里最典型也最危险的反模式。如果你的代码库里出现类似结构,应当把它视作安全缺陷而不是容错增强。

代码走读:sandboxed_echo 工具如何解析策略、包装 argv、再 spawn

下面结合素材里的 my-plugins/sandbox-demo/src/index.ts 示例,把「解析策略 → 包装 argv → spawn」这条链路逐段拆开。为了让代码可以直接粘进工程里跑,我把省略的 spawn 收集输出部分补全,其余结构与素材保持一致。

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

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. consumer 交出确切 argv(程序加参数),不是 shell 字符串。
      const argv = ['bash', '-c', `echo ${JSON.stringify(args.text)}`]

      // 3. danger-full-access 直接 spawn;其余交给沙箱包装。
      if (policy.mode === 'danger-full-access') {
        const result = await runAndCollect(argv)
        return result.stdout.trim()
      }

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

      // 5. 按 confined.enforcement 决定是否接受本次执行。
      if (confined.enforcement === 'partial' && policy.mode !== 'read-only') {
        return {
          isError: true,
          error: { message: 'partial enforcement is not acceptable for runoob demo' },
        }
      }

      const result = await runAndCollect(confined.argv)
      return `confined echo ${args.text} (enforcement: ${confined.enforcement})`
    },
  }))
}

// 小工具:spawn 一个 argv 并收集 stdout/stderr,统一按 UTF-8 解码。
function runAndCollect(argv: string[]): Promise<{ stdout: string; stderr: string; code: number | null }> {
  return new Promise((resolve, reject) => {
    const child = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] })
    let stdout = ''
    let stderr = ''
    child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8') })
    child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8') })
    child.on('error', reject)
    child.on('close', (code) => resolve({ stdout, stderr, code }))
  })
}

逐段解释。首先是 inject 数组:['tools', 'sandbox', 'sandboxPolicy']。这三者缺一不可——tools 用来注册工具,sandbox 用来做 argv 包装,sandboxPolicy 用来解析本次调用应当使用的模式与工作区根目录。把依赖写在 inject 里,意味着 Cordis 会在应用启动阶段检查这些服务是否存在;如果某个服务没被安装,插件会直接启动失败,而不是等到运行期才发现 ctx.sandbox 是 undefined。这种「把依赖前置声明」的方式,本质上也是一种 fail-closed:宁可启动不了,也不要跑到一半才发现笼子没装好。

第二步,ctx.sandboxPolicy.resolve({ session: exec.agent?.session })。注意括号里的参数是 { session },不是 { mode },也不是裸的 session 对象。为什么要传 session?因为策略解析需要沿素材里描述的三层回退顺序来定模式:最高优先级是「已批准的显式模式」,也就是一次性提权重试时显式传进来的 mode,它压过会话策略;其次是「会话最后一次 sandbox/mode 事件」,这类事件随会话日志持久化,因此可以回放重建;最后才回退到「部署默认模式」。resolve 返回的 policy 对象里,至少包含 modeworkspaceRoot 两个字段。普通工具调用不会自己编造 workspaceRoot,而是从调用会话的不可变 cwd 派生出来:这个 cwd 会先按文件系统语义规范化,再做一次词法规范化,所以哪怕路径里包含 symlink 或者 ..,最终得到的也是进程实际运行的那个目录,而不是字符串层面看起来像的目录。

第三步就是 argv 形态的问题。示例里清楚写着:

const argv = ['bash', '-c', `echo ${JSON.stringify(args.text)}`]

注意这是一个数组,不是一整条 shell 字符串。素材特意强调了这一点:进程沙箱的模型是「consumer 交出确切的 argv,后端按文件效果策略把它包装起来」,关键点是确切 argv 而不是 shell 字符串。如果某个 consumer 天然是以 shell 形态工作的,它也必须显式交出 ['bash', '-c', command] 这种形式,把「我要起一个 shell」这件事明白地写在 argv 里,而不是让沙箱去猜一段字符串该怎么拆。这样做的好处是双重的:一方面后端能准确知道将要执行的程序名与参数边界;另一方面,参数里出现的引号、空格、重定向符号都不会被二次解释,避免注入类问题。

第四步是分支处理。danger-full-access 模式下,consumer 直接 spawn 原始 argv,完全不调用 ctx.sandbox。这不是「沙箱失败后的兜底」,而是这个模式本身就是「绕过隔离」的显式声明。素材里说得很硬:只有 read-only 和 workspace-write 这两种模式会发给提供方,danger-full-access 根本不进沙箱。这带来一个很优雅的安全性质:受限执行必然到达 ctx.sandbox,任何受限模式下的执行都不可能绕过这个入口;反过来,一旦代码路径没经过 ctx.sandbox,它就必然处于 danger-full-access,也就必然是一次被显式授权的无隔离执行。这两条路径互斥且都可审计。

第五步,受限分支调用 ctx.sandbox.confine(argv, { mode, workspaceRoot })。它的返回值类型是 ConfinedArgv,按素材的说法,这个结构包含「替换后的 argv 加上后端的强制执行事实」。也就是说,consumer 拿到的不是原 argv,而是后端根据文件效果策略加工后的 argv;同时还附带一个 enforcement 字段,告诉调用方后端实际达成了多少隔离。拿到结果之后,consumer 用 confined.argv 去 spawn,而不是用原来的 argv。这里有个常见坑:如果 consumer 出于习惯去 spawn 原始 argv,那么即使 confine 被调用了,隔离也没有真正生效。正确姿势是始终 spawn confine 返回的那个 argv。

消费方如何按 confined.enforcement 做决策:partial 在非 read-only 下直接报错

素材里的这段判断是整个示例里最值得反复读的一处:

if (confined.enforcement === 'partial' && policy.mode !== 'read-only') {
  return { isError: true, error: { message: 'partial enforcement is not acceptable for runoob demo' } }
}

它表达的策略是:当且仅当模式是 read-only 时,partial 可以接受;在 workspace-write 或更强的模式下,partial 一律视为不可接受,直接返回 isError。为什么这样设计?因为 enforcement 字段的语义是「后端实际达成的强制执行完整度」,只有两个取值:'full' 表示后端管控了该模式承诺的所有文件效果,'partial' 表示只管控了子集。对 read-only 来说,它承诺的效果主要是「拒绝写入、只允许必需的数据接收端(例如 /dev/null)」,即使后端只能达成子集,剩下的风险面也相对有限,而且通常这种子集差异不会引入写入能力。但对 workspace-write 来说,它承诺的是「允许在工作区根目录及后端承诺的临时区域下写入」,一旦是 partial,就意味着工作区边界可能并不牢固,某些本应被拦住的写入路径可能漏出去,这时把 partial 当成 full 用就是在自欺欺人。

这里要给 consumer 一条明确的行为准则:要求绝对边界的 consumer 必须把 partial 当作「不够」处理,拒绝或向上暴露这一区别。素材的原话就是这个意思,而示例里的写法正是一种具体落地——它选择了「拒绝」,把 partial 变成一次失败的工具调用;另一种合规做法是「向上暴露」,比如把 enforcement 值原样带进返回结构或日志里,让上层审批人看到「本次执行只得到了部分隔离」,由人来决定是否放行。无论选哪种,核心都是不要让 partial 悄悄伪装成 full。

下面这张表把 SandboxMode 的三个档位在「是否进沙箱」「承诺的文件效果」「partial 是否可接受」三个维度上并列对比,方便在设计 consumer 时对照使用:

SandboxMode 是否进入 ctx.sandbox 承诺的文件系统效果 enforcement = partial 时的推荐处理
read-only 只允许必需的数据接收端(如 /dev/null),拒绝写入 可接受,但建议把 partial 记录进日志备查
workspace-write 在工作区根目录及后端承诺的临时区域下允许写入 不可接受,应拒绝执行或向上暴露该差异
danger-full-access 否,直接 spawn 原始 argv 无隔离承诺,绕过文件效果策略 不适用(confine 不会被调用)

需要再次澄清一个容易混淆的点:SandboxMode 只管控文件系统效果,不包含网络与进程可见性。也就是说,即便你选了 read-only,也不要误以为这个模式会把网络访问或进程列表可见性一并管住——它不承诺这些。如果你的威胁模型里包含「Agent 偷偷发网络请求」或「Agent 探测宿主机上的其他进程」,那需要在沙箱之外另做机制,而不是指望改一个 mode 就万事大吉。把模式的能力边界写清楚,比把模式描述成「全安全」要负责任得多。

defineTool 输出契约与沙箱的配合:output.schema、execute(args, exec) 与 exec.agent?.session

工具定义侧看起来只是几个字段声明,但它和沙箱决策之间存在直接的耦合关系,值得单独拆出来讲。回到示例里的工具定义:

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) { /* ... */ },
}))

先看 parameters 里的 required。素材示例中 text 字段标了 required: true,意思是这个参数是必填的;同时配了 description: 'Text to echo'。别小看 required 与 description 这两个属性,它们同时承担着两种职能:一是给模型看的契约说明,模型需要知道这个参数干什么用、是不是必须给;二是给工具框架做入参校验,缺了必填字段的调用会在进入 execute 之前就被拦下。对 sandboxed_echo 来说,把 text 设为必填,可以避免「空输入导致命令无参数」这类边界情况。

再看 output.schema。示例里写的是 { schema: { type: 'string' } },也就是要求这个工具的返回值是一个字符串。注意这与 execute 内部的 return 是配套的:正常路径下 return 的是一段字符串(例如 confined echo xxx (enforcement: full)),而我们在 partial 分支里却返回了一个对象 { isError: true, error: { message: ... } }。这说明工具结果类型通常是「成功值符合 output.schema,或者走一个统一的结构化错误通道」这两条路。设计建议是:把沙箱相关的诊断信息尽量放进错误结构里,而不是硬塞进 output.schema 规定的成功值里。因为一旦你把 enforcement 之类字段混进成功字符串,任何依赖该 output.schema 的下游都可能被这段额外文本干扰。

最关键的耦合点是 execute(args, exec) 的第二个参数。示例第一步就是:

const policy = ctx.sandboxPolicy.resolve({ session: exec.agent?.session })

为什么从 exec.agent?.session 拿 session,而不是从别的地方?因为策略解析的回退链需要会话上下文:会话最后一次 sandbox/mode 事件是随会话日志持久化的,可以回放重建;一次性提权重试时传入的显式 mode 优先级最高;都没有时才回退到部署默认模式。没有 session,就无法完成这套解析。而 exec.agent?.session 用了可选链,意味着 session 可能是缺省的。

这里有个需要留意的工程细节:素材在回退顺序里提到,「无 agent 的调用」与「没有 cwd 的会话」会使用配置的根目录。也就是说,当 exec.agent?.session 为 undefined、或者会话没有 cwd 时,策略解析会回退到部署默认模式,并采用配置的根目录作为 workspaceRoot。对 consumer 来说,这带来一个设计问题:你是接受这个回退,还是把「缺少 session」当作不能安全执行而拒绝?如果工具的语义涉及写操作,建议显式要求 session 存在;如果只是只读探测,接受回退到部署默认模式是可以的。判断依据仍然是你的威胁模型,而不是代码写起来省不省事。

把上述三点串起来,可以把「工具定义与沙箱的配合」总结成下面这张字段级对照表:

字段 / 参数 作用 与沙箱决策的关系
parameters.text.required 声明入参必填,框架在进入 execute 前校验 避免空输入导致命令形态异常,间接减少受限模式下的意外行为
parameters.text.description 给模型看参数语义 帮助模型构造符合预期的 argv 内容
output.schema 约束成功返回值的类型(此处为 string) 沙箱诊断信息应走错误结构,不污染成功值
execute 第二参数 exec 携带运行时上下文 exec.agent?.session 是策略解析回退链的输入

沙箱与审批的职责切分:谁圈定进程能碰哪些文件,谁决定这一次是否放行

很多刚接触这套机制的开发者会把沙箱和审批混着用,甚至把审批当成沙箱的兜底。素材对这两个服务的定位划得非常清楚,值得原文意思复述一遍:沙箱把「进程能碰哪些文件」圈起来,审批把「是否放行这一次操作」交给应答者决定。换句话说,沙箱解决的是「命令跑在什么边界里」,审批解决的是「这个具体操作是否被允许」。一个是空间性的约束,一个是事件性的决策。

从时间维度看,两者的介入点也不同。沙箱是在命令即将执行的那一刻,按文件效果策略把 argv 包装起来——它发生在每一次受限执行之前,是被动的、必然的。审批则是在某个有风险的操作被提出时,交由应答者做一次性决策:这次放行还是拒绝。素材把它称为「一次性决策」,这个词很重要,它意味着审批的结论不构成对后续同类操作的普遍授权;下一次操作仍然需要重新判断。

那为什么要同时有这两个机制?因为它们防的是不同形态的风险。沙箱防的是「命令一旦跑起来,会不会越界碰到不该碰的文件」——哪怕这个命令本身是经过审批的,沙箱仍然要保证它跑起来以后的动作范围受限。审批防的是「这个操作从一开始就不该被允许」——哪怕沙箱能把风险圈小,某些操作本身也应该由人拍板。两者叠加,才能既限制执行边界,又限制执行意愿。

还有一个共同点必须强调:两者都默认失败关闭。沙箱失败关闭的表现就是我们前面讲的 SandboxUnavailableError 与 SANDBOX_UNAVAILABLE;审批的失败关闭则体现在「没有明确放行就不能执行」。把这两个 fail-closed 叠在一起,就得到了整套机制的核心安全性质:Agent 想做一件有风险的事,那么它既需要越过审批这道门,又只能在沙箱给定的边界内活动;任何一环拿不到确定的「可以」,执行就不会发生

下面把两个服务的职责、触发时机、失败模式并列对比,方便在写新工具时快速定位「这个需求应该落在哪一侧」:

维度 ctx.sandbox ctx.approval
解决的问题 命令跑在什么边界里(进程能碰哪些文件) 这个具体操作是否被允许
决策者 后端按文件效果策略自动包装 argv 应答者做一次性决策
触发时机 受限执行的每一次 spawn 之前 有风险操作被提出时
失败关闭表现 抛 SandboxUnavailableError(SANDBOX_UNAVAILABLE) 无明确放行即不执行
典型误用 confine 失败后直接 spawn 原始 argv 把一次放行当成对同类操作的普遍授权

受限模式下的排查清单:从 policy.mode 到 enforcement 的逐项核对路径

当一个沙箱化工具在真实环境里表现异常——比如本该写入工作区的命令被拒、或者本该拒绝的操作居然通过了——最有效的做法不是拍脑袋改代码,而是按一条固定的路径逐项核对。下面这条顺序贴合素材里的各个字段与错误码,可以直接拿来做排查清单。

  1. 确认 policy.mode 是否落入受限档。第一步先看 policy.mode 到底是 read-onlyworkspace-write 还是 danger-full-access。因为如果它已经是 danger-full-access,那么受限执行路径根本不会触发,你排查的方向就应该转向「为什么这个会话被解析成了 full-access」,而不是继续查沙箱。常见原因是会话日志里有一次显式提权重试埋下了高优先级模式,或者部署默认模式本身就是 full-access。
  2. 确认 confine 是否抛 SANDBOX_UNAVAILABLE。如果工具报错里出现 SandboxUnavailableError 或错误码 SANDBOX_UNAVAILABLE,说明宿主环境当次没有可用的沙箱后端。这时正确的处理是安装或修复后端,或者在业务允许且经过审批的前提下显式切到 danger-full-access;绝对不能加一段 catch 然后直接 spawn 原始 argv。
  3. 确认 confined.enforcement 是否 full。如果执行能跑但结果与预期不符,检查 enforcement 值。素材给出的两种 partial 情形是:较旧的 Landlock ABI,以及 Windows ACL runner 的 Everyone 与硬链接边界。如果你的工具依赖「绝对边界」,那么遇到 partial 就应当按示例里的写法拒绝执行,或把这一区别向上暴露。
  4. 确认 workspaceRoot 规范化结果是否符合预期。workspaceRoot 是从会话的不可变 cwd 派生的:先按文件系统语义规范化,再做词法规范化,所以包含 symlink 或 .. 的 cwd 会正确标识进程实际运行的目录。如果发现命令被允许写入了不该写的位置,第一件事是打印出最终 workspaceRoot,看看它是不是你心里想的那个目录——symlink 场景下很容易出现「看上去在工作区里、规范化后其实在别处」的情况。

把这条清单再压缩成一句话:先看模式、再看后端可用性、再看 enforcement、最后看边界解析。四步之中任意一步定位到问题,就不用继续往下查。反之,如果四步都通过而问题依旧,那么问题很可能不在沙箱侧,而应转向审批侧或工具自身的 argv 构造逻辑。

这里补两个高频工程坑及其解决思路。第一个坑是「argv 里带了 shell 语法但没显式起 shell」:有人直接写 ['echo', args.text],却在 text 里放了重定向符号,结果完全没有被解释,命令行为与预期不符。按素材的原则,shell 形态的 consumer 必须显式交出 ['bash', '-c', command],所以要么老老实实拆成 argv 数组,要么显式写明要起 bash。第二个坑是「拿到 ConfinedArgv 后仍旧 spawn 原始 argv」:隔离实际未生效,但代码看起来完全正确。解决办法是统一封装一个 runConfined(confined) 辅助函数,让所有受限执行都必须传 ConfinedArgv,从类型上堵住误用。

2026 年 9 月最新实践:把 partial enforcement 当作一等公民来对待

把时间拨到 2026 年 9 月,当前这套机制在实践中演化出的最重要共识,就是不要再把 enforcement 当成一个可以忽略的附带字段,而要把 partial enforcement 当作一等公民来对待。「一等公民」的意思是:它拥有独立的类型位、独立的分支处理、独立的日志与验收项,而不是被塞进某个 default case 里悄悄放过。

具体到消费侧,有两条落地要求。第一,显式区分 full 与 partial。不要写 if (confined.enforcement !== 'full') { /* 什么都不做 */ } 这种形式,而应当分别处理两个取值,让每种取值都有明确的归宿:full 正常执行,partial 按下面的第二条处理。示例里的 if (confined.enforcement === 'partial' && policy.mode !== 'read-only') 就是一个典型的显式分支——它读起来毫无歧义:只有在 read-only 下 partial 才被容忍。

第二,拒绝或向上暴露 partial 的区别。素材的原话是「要求绝对边界的 consumer 必须把 partial 当作『不够』处理,拒绝或向上暴露这一区别」。这句话给了两个合规选项。选择「拒绝」意味着直接返回 isError,让这次调用失败;选择「向上暴露」意味着把 enforcement 的取值原样带到上层,让审批人或运维看到「本次执行只拿到了部分隔离」。两种都可以,关键是不能让 partial 静默退化成 full。

在验收层面,2026 年 9 月的实践已经把两个具体的 partial 情形纳入常规验收项,而不是当成罕见边角:Windows ACL runner 的 Everyone 与硬链接边界,以及较旧的 Landlock ABI。这意味着在 CI 或发布检查里,应当专门针对这两类环境跑一遍沙箱化工具,确认 consumer 在 partial 场景下的行为符合预期——要么干净地拒绝,要么清晰地暴露。把这两个场景留到生产才第一次遇到,是最典型的验收疏漏。

最后要重申 read-only 与 partial 的组合语义。按示例的写法,read-only 下 partial 是可以接受的。但这并不意味着 read-only 就完全不需要关心 enforcement——建议仍然把 partial 记录进日志。原因在于,read-only 只管控文件系统效果,本身不包含网络与进程可见性;如果你恰好处在网络敏感的环境中,read-only 的 partial 记录可以帮助你判断某次执行是否落在了预期外的后端上,从而为后续更严格的策略调整提供依据。

总结与最佳实践

把全文——包括上一段的沙箱决策与审批流程,以及这一段的错误码、代码走读、enforcement 决策与 2026 年 9 月实践——压缩成一份可以直接照着执行的清单:

  1. 受限执行必须经过 ctx.sandbox.confine。read-only 与 workspace-write 这两种模式必然到达沙箱入口,受限策略下静默的无隔离透传永远不合法。只有 danger-full-access 才允许绕过 ctx.sandbox 直接 spawn 原始 argv。
  2. 把 SandboxUnavailableError 与 SANDBOX_UNAVAILABLE 当作硬失败。无可用后端时 confine 会抛出该错误,绝不要写「catch 后直接 spawn 原始 argv」这类把 fail-closed 改成 fail-open 的代码。
  3. 始终交出确切 argv,不要交 shell 字符串;需要 shell 时显式写 ['bash', '-c', command]。并始终 spawn confine 返回的 confined.argv,而不是原始数组。
  4. 用 ctx.sandboxPolicy.resolve({ session }) 解析本次完整策略,依赖会话日志里持久化的 sandbox/mode 事件与部署默认模式的回退链;从 exec.agent?.session 取会话上下文,注意无 agent 调用与无 cwd 会话会回退到部署默认模式与配置根目录。
  5. 把 partial 当一等公民。用 if (confined.enforcement === 'partial' && policy.mode !== 'read-only') 这类显式分支处理,read-only 之外的 partial 一律拒绝或向上暴露,绝不让它伪装成 full。
  6. 记住 SandboxMode 只管文件系统效果,不承诺网络隔离与进程可见性;网络与进程相关威胁需要在沙箱之外另行设计机制。
  7. 拉通沙箱与审批的职责边界:沙箱圈定进程能碰哪些文件,审批决定这一次是否放行,且两者都默认失败关闭;审批的放行是一次性决策,不构成对后续同类操作的普遍授权。
  8. 把 partial 场景纳入常规验收:较旧的 Landlock ABI、Windows ACL runner 的 Everyone 与硬链接边界,都应当有对应的测试用例,验收 consumer 在 partial 下的拒绝或暴露行为。
  9. 排查时按固定顺序走:policy.mode 是否落入受限档 → confine 是否抛 SANDBOX_UNAVAILABLE → confined.enforcement 是否 full → workspaceRoot 规范化结果是否符合预期。
  10. 工具定义与沙箱保持配套:parameters.required 与 description 帮助模型构造正确输入,output.schema 约束成功值,沙箱诊断信息走结构化错误而不是污染成功返回值。

如果要把这篇文章浓缩成一句可以直接贴在代码评审里的话,那就是:让每一次受限执行都必然穿过沙箱入口,让每一次隔离不足都显式可见,让每一次绕过隔离都留下审批痕迹。做到这三点,Agent 的危险动作才算真正被关进了笼子。