DeepSeek Harness · 审批与沙箱

审批与权限:两个旋钮,一个下拉框

沙箱模式和审批策略是两个独立旋钮,预设只是常用组合。核心源码:packages/interaction/user-approvalpackages/interaction/permission-presets

课程目标读完你能说清三件事:为什么 plan mode、auto-accept、YOLO 这些产品概念在 DSH 底层只拆成 sandbox/modeapproval/policy 两个正交变量;预设下拉框为什么只是给旋钮组合起名字,切预设时日志里到底写了哪几条事件;以及审批为什么在每个环节都 fail-closed(拿不到答案就当拒绝)。
交互演示 · 双旋钮控制台

下面是一个可以拧的权限控制台。左边两个旋钮各管一件事,右边下拉框是预设。先选一个操作,点「播放」看这次工具调用一路闯关的过程;然后随便拧旋钮、换预设,再跑一遍,看同一个操作的命运怎么变。

旋钮一 · 沙箱模式

sandbox/mode(管文件效果) read-only workspace-write danger

旋钮二 · 审批策略

approval/policy(管问不问人) ask never

预设下拉框

permission preset(旋钮快捷方式)
会话日志追加的事件
模型发起工具调用write ./src/app.ts
沙箱层按旋钮一裁决文件效果
审批层被拦后想提权,按旋钮二决定问不问
结局等待运行
审批弹窗:Agent 想为这一次调用把沙箱临时放宽到 danger-full-access,允许吗?(只授权这一个操作)
点「播放」运行当前组合,或滚动到此处自动播放一遍。
演示为教学化模拟。旋钮取值、预设表与提权审批流程对应 docs/subsystems/permission-presets.zh.mddocs/subsystems/approval.zh.mdpackages/sandbox/sandbox/src/escalation.ts;审批弹窗期间你真的可以点按钮替用户做决定。
先分清两个旋钮各管什么

先给结论:DSH 把权限这个大词拆成了两个互不打听的小问题。旋钮一 sandbox/mode 回答的是这条命令的文件效果允许到什么程度,取值 read-only、workspace-write、danger-full-access 三档,只管文件系统,网络和进程可见性都不在它的词汇表里(docs/subsystems/sandbox.zh.md 开头的定义)。旋钮二 approval/policy 回答的是碰到需要人拍板的事问不问,取值 ask、never 两档。

两个旋钮在日志里也是两种独立事件:sandbox/modeapproval/policy。执行、提示词、回放,全都只读这两种旋钮事件的折叠结果。这就是正交的实际含义:拧任何一个,另一个纹丝不动。

旋钮一 · sandbox/mode
  • read-only:后端必须拒绝写入,只保留 /dev/null 这类 shell 必需的接收器。
  • workspace-write:工作区根目录与后端承诺的临时区可写,界外一律拦。
  • danger-full-access:绕过隔离。消费方直接 spawn 原始命令,根本不调用 ctx.sandbox
旋钮二 · approval/policy
  • ask(默认):交给组合的应答者链。一个应答者都没有?链走到头返回 unavailable,照样按拒绝处理。
  • never:确定性返回 rejected,不分发任何应答者。CI 与无人值守的标准姿势。
  • 唯一的放行值是 allowed-once,且只授权所询问的那一个操作。rejected、cancelled、unavailable 三种结果调用方全部执行拒绝。
预设:只是给格子起名字

然后是那个下拉框。ctx.permissionPresets 维护一张表:名字映射到一个旋钮组合。默认表就两行,workspace-write 对应 workspace-write 加 ask,danger-full-access 对应 danger-full-access 加 never(permission-presets/src/index.ts 第 167 至 176 行的默认配置)。它是可选能力,不在 agent loop 主干上,也不拥有任何强制执行。

切预设时发生什么?三条事件,顺序固定:先追加一条只记日志的 permission/preset 记下你的意图,再通过两个旋钮各自的规范 setter 写 sandbox/modeapproval/policy,而且只写生效值真的变了的那个。重新选中当前预设?什么都不追加。回放时执行层只认旋钮事件,preset 事件的唯一作用是当两个预设共享同一组合时,记住你当初选的到底是哪一个名字。

还有 custom。它是保留字,表里不许有叫这个名字的条目(插件加载时就抛异常)。当你手动把旋钮拧到一个表里没有的组合,current() 才派生出 custom 给客户端显示。它只出不进:可以是当前状态,永远成不了切换目标,也绝不出现在事件 payload 里。所以演示里的下拉框把它做成了灰色不可选项,这正是源码的行为。

fail-closed 贯穿始终

没有应答者、应答者抛异常、返回了词汇表外的怪值、弹窗期间用户关掉了界面,统统归一化成 unavailable 或 cancelled,调用方一律当拒绝。任何一环拿不到明确的 allowed-once,就不放行。

审批不进模型上下文

approval/askedapproval/decided 成对写入会话日志,只做审计。模型看到的是工具结果和运行时上下文快照。ApprovalRequest 还故意不带工具参数,用 callId 指向已经流式输出的那次调用,避免渲染一份会漂移的副本。

提权是一次性的

写操作被沙箱拦下后,模型可以带 sandbox_permissions 请求提权重试,审批通过给 allowed-once,这次重试以显式更宽的模式解析一次策略,用完即弃。会话的旋钮设置不会因此改变。

关键证据 · never 在瀑布之前就拦死

有个边界问题值得抠:如果一个插件在审批服务挂载之后,用 prepend 往应答者链最前面塞一个全部放行的应答者,能不能绕过 never?答案是不能,因为 never 根本不在链里执行。看 decide() 的开头:

packages/interaction/user-approval/src/index.ts第 304 至 312 行节选
  private async decide(req: ApprovalRequest, session: Session): Promise<ApprovalOutcome> {
    const signal = req.signal
    if (signal?.aborted) return 'cancelled'
    // The 'never' policy is decided HERE, before any dispatch: a listener
    // registered with `prepend: true` after this service mounts would sit
    // ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
    // documented promise that 'never' rejects deterministically regardless
    // of registration order — only the service's own request path can.
    if (this.effectivePolicy(session) === 'never') return 'rejected'
源码快照说明:依据本地仓库 deepseek-harness-master,核对文件 packages/interaction/user-approval/src/index.ts,核对日期 2026-08-13。代码块保留源码原文。

注释把设计意图讲完了:never 的裁决点在服务自己的请求路径上,任何监听器形态的门都保不住与注册顺序无关这个承诺。往后看第 317 至 329 行,ask 路径的兜底同样密不透风:链的无应答默认值是 unavailable,抛异常的应答者被折叠成 unavailable,返回怪值的也归一化成 unavailable。

切预设的完整写入路径

预设只是快捷方式,这句话的全部实现是一个十四行的私有方法 apply()。它做三件事,顺序固定。第一步 resolve(name) 查表,名字不在表里直接抛异常,异常消息还会把已知的表键列出来。第二步对照 current() 的派生结果:你选的名字和当前生效的预设不同,才追加一条 permission/preset 事件记下意图;重选当前预设,这一步什么都不写。第三步挨个检查两个旋钮,预设声明的沙箱模式与事件折叠出来的生效值(拿不到就用部署默认值兜底)不同,就调 setSandboxMode() 写一条旋钮事件;审批策略同理,走传入的 setApproval 写入。谁的生效值没变就跳过谁。

所以切预设没有第三个执行路径,也没有隐藏状态:一条意图日志,加上至多两次走各自规范 setter 的旋钮写入,日志里追加了什么,执行层就折叠出什么。派生 custom 的逻辑在同文件 derive():先看上次选的预设还配不配当前旋钮值,配就保住它;不配就按声明顺序找第一个匹配的表项;都不匹配才返回 custom。

出处:packages/interaction/permission-presets/src/index.ts 第 379 至 392 行(apply())、第 309 至 321 行(derive()),核对日期 2026-08-13。

横向对比 · 单一模式轴 vs 双旋钮
Claude Code:一根模式轴 + 规则表

Claude Code 把同一块地盘做成了单一的 permission mode 轴:default、plan、acceptEdits、bypassPermissions、dontAsk 五档(还原源码 restored-src/src/utils/permissions/PermissionMode.ts 第 44 至 91 行,见书稿第 7 章),外加一张 allow / deny / ask 三种行为的规则表,规则内容可以细到 Bash(git commit:*) 这种命令前缀。

对照着看,折叠的代价就显出来了。dontAsk 大约等于 DSH 的 never,bypassPermissions 大约等于 danger-full-access 加 never,但这五档在一根轴上滑动,沙箱收多紧和问不问人被绑在一起卖。DSH 的双旋钮能表达 read-only 加 ask 这种组合:沙箱只读兜底,遇到确需写入的操作弹窗问一次,一次放行。在 CC 的模式轴上找不到直接对应的档位。反过来 CC 也有 DSH 双旋钮表达不了的东西:规则表是按工具和命令前缀的细粒度控制,DSH 的两个旋钮都是会话级别的全局值,工具粒度的门禁要靠 hook 层去做。两边各自把复杂度放在了不同的地方。Grok Build 的授权链走的又是另一条路(从工具请求到受限执行逐层放行),站内 Grok 完整授权链 一课有逐层拆解,可对照阅读。

课堂练习
01

推演两个刁钻场景

场景一:审批弹窗弹出后,用户直接关掉了浏览器,UI 应答者随 dispose 被移除。这次请求最终以什么结果结算,工具调用是放行还是拒绝?场景二:策略是 never,一个插件用 prepend 注册了一个永远返回 allowed-once 的应答者。写出请求从 request() 进入到返回值的路径,说明为什么这个应答者一次都不会被调用。(提示:两题的答案都在本页的源码面板和它后面那段话里。)

Takeaway:权限在 DSH 底层只有两个正交变量,sandbox/mode 管文件效果,approval/policy 管问不问人,预设是给组合起名字的表键,custom 只出不进。审批全链路 fail-closed:唯一的放行值是 allowed-once,且只管那一个操作;never 在服务内部、瀑布分发之前就拦死,谁都插不了队。