读完这篇你能:说清 DSH 里「模型不能乱动文件」到底由几层东西管、每层的真实语义和失败形态,知道策略是从哪儿冒出来的,以及作为插件作者哪些边界该自己守、哪些不该抢。
前置:20-DSH 升级后的版本与兼容性排查。约 30 分钟。
示例代码:dsh-fs-write-watch
DSH 版本:0.2.0-rc.2
12-在工具执行管线拦截危险调用讲过工具执行管线上的几个拦截点,也提过一句「沙箱是系统边界,guard() 是策略层」。那篇的重点在拦截点在哪,这篇把沙箱这一层的内部摊开:它到底有几个旋钮、每个旋钮的取值意味着什么、生效的策略从哪来、失败了会怎样。
先说结论:「模型不能乱动文件」不是一条规则,而是三层彼此独立的东西。它们经常被一起提到,但改一个不会动另一个。
目标
- 分清三层各管什么:能不能写(沙箱)、读没读过(观察策略)、怎么打包成给人用的一个开关(权限预设)。
- 弄清有效模式是怎么算出来的,以及为什么”切模式”是一次会话事件而不是一个设置项。
- 认识两种强制者的能力差异,以及 fail-closed 的落点。
- 知道加一层自己的边界该挂在哪,不该挂在哪。
DSH 设计
先看安全声明。DSH 写得很直白:沙箱、审批提示与权限控制「可以降低风险,但不保证隔离」。这里的沙箱是把一次命令关进「只能动指定文件」的围栏里执行,审批提示是动手前先问人。既然不存在一道能打包票的防线,DSH 就不假装有。它把「能不能写文件」「这个文件读没读过」「要不要先问人」做成三个相互独立的旋钮:各有各的取值表、各有各的改法,改一个不动另一个。另有一个叫「权限预设」的东西,只是把其中两个旋钮捆成一个具名组合供人挑选,选完再逐个旋钮写回去。预设自己不参与任何强制:界面上显示哪个预设,底下读的还是各旋钮自己的当前值(依据 local/deepseek-harness-dsh-v0.2.0-rc.2/docs/subsystems/permission-presets.zh.md)。
第一个旋钮是沙箱模式,它的取值表刻意只管文件。取值是闭集三档:只读、工作区可写、完全不围。类型定义的注释写明,网络访问、进程可见性不在这张表里。管什么、不管什么,是这条「能力缝」(seam:DSH 里一项可替换能力的接口边界)的明文边界声明,不是「还没做」的功能。所以「开了沙箱,网络就安全」从设计上就不成立,那份期待放错了地方。
再说策略的存在方式。策略类型的注释写明它按每次调用携带,不钉在执行者身上。两个使用方可以在同一瞬间按不同策略执行:一个终端命令按只读围,一个子代理需要自己的状态目录可写。一次获批的放宽重试,本质是一次带着更宽策略的新调用。所以「当前有效策略」要在每次调用的边界现算,而不是读一份全局状态。
失败这一侧全是明文契约,不是实现偏好。服务定义把「静默的无隔离透传永远不合法」写成规定:没有任何可用围栏后端时,必须拒绝执行,不能悄悄放行。同一契约还要求后端如实报告自己围得有多全,分 full 和 partial 两档,因为要求绝对保证的使用方必须知道这个区别。在一个公开声明尚未接受安全审计的项目里,如实说出「我只能围到这」比假装围住了更紧要。
契约还要求使用方先分辨两种失败:「被拒绝」和「跑不起来」必须分开报。围栏自身崩了,意味着命令根本没执行;围栏生效拦下了,才意味着「这次不许」。为此每个后端都带着两套错误指纹,一套指认「围栏生效的拒绝」,一套指认「围栏自身的故障」。把「没跑成」当成「被拒」,或者反过来,都会让后续处理走错路。
「文件观察」这个旋钮的存在方式又不一样:它挂在事件上,不是一个服务。观察策略记录哪些文件被读过、读到的版本新不新,但它不注册任何服务,只在文件读写意图的事件上作答。「事件」(event)是 DSH 里插件之间传话的广播:一个插件发出一件事,收听的插件可以给出答案。作答长这个样子:
ctx.on('fs/write-intent', (target, actor) => {
// 答案就是一个普通对象:没读过的文件,只允许「不存在才创建」
return { kind: 'createIfAbsent' }
})
工具只发事件、从不调用策略的方法,所以拿掉这个策略,工具照常工作。文件系统文档的措辞因此是:加载了文件工具的部署「应」加载观察策略——是建议,不是强制。这类事件还是「单槽瀑布」(waterfall):一种逐个委托的分发方式,第一个给出答案的收听者独占决定权,先注册者赢。
预设只出现在图的上半:它把值写进旋钮之后就退场,下半的求解与决策路径上没有它。三个旋钮的生效点也各不相同。这就是「彼此独立」在一次文件操作发生时的样子。
三层各自的职责
| 层 | 回答的问题 | 实现 |
|---|---|---|
| 沙箱 | 这次操作能不能落盘 | ctx.sandbox(服务定义)+ sandbox-local(提供方)+ bash-sandbox / fs-sandbox(消费者) |
| 观察策略 | 这个文件读过没有 | dsh-fs-observation-policy,一个 event-only 插件 |
| 权限预设 | 上面两个旋钮怎么打包给人选 | dsh-permission-presets |
先看第一层的形状——它就是 08-替换内置能力的提供方与消费者那个三件套的又一次出现:
ctx.sandbox 把一段子进程 argv 包成「在某个文件效果策略下执行」的 argv,唯一的抽象方法是:
abstract confine(argv: readonly string[], policy: SandboxPolicy, signal?: AbortSignal): Promise<ConfinedArgv>
所以第三层和第一层的关系是:预设不执行任何策略,它只负责”记录选择 + 通过各旋钮自己的写路径改值”。这是很容易搞错的一处——预设是个 UI 概念,不是一道防线。
模式只管文件效果
三个取值,闭集:
| 模式 | 含义 |
|---|---|
read-only |
只能读;写一律拒 |
workspace-write |
工作区(加临时目录)可写,其余只读 |
danger-full-access |
不围 |
词汇表里只有这些。网络访问、进程可见性、能创建多少子进程——这些不在沙箱模式的表达能力之内。子系统文档明确写了这条边界。所以「开了沙箱网络就安全了」这类期待是错的:它管的是文件效果,不是全部副作用。
有一个值得一提的细节:可写根不是”工作区”一个字符串,而是算出来的:
writableRoots(policy) // workspaceRoot + /tmp + os.tmpdir(),去重且 canonical
每个平台 runner 还有自己的拼法(bwrap 用 --ro-bind / / 覆盖全盘再放开,Seatbelt 与 Landlock 各自显式授予 /dev/null 之类)。写文章时不必记住细节,只要知道”可写根是算出来的、不是写死的”。
策略从哪来
有效模式按这个优先级算:
已批准的显式升级 > 会话日志里最后一条 sandbox/mode > 部署默认
第二项值得停一下:会话覆盖不是存在配置文件里的。切模式就是往会话日志里追加一条 sandbox/mode 事件({ mode, source? },log-only、不进模型对话),重放会话时自然恢复成当时的值。所以「模式是一次事件,不是一个设置项」——这也解释了为什么它会话级生效、为什么重放能复原。
部署默认这一头有个反直觉的地方:schema 的默认值是 read-only。而 base profile 显式把它改成 workspace-write 并配 ask:
# packages/bundle/base/cordis.patch.yml —— 部署配置样例
- name: '@deepseek-ai/dsh-sandbox-local'
- name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
- name: '@deepseek-ai/dsh-fs-observation-policy'
- name: '@deepseek-ai/dsh-fs-sandbox'
设计意图很清楚:默认收紧,要放开必须显式。所以你在自己机器上看到的是 workspace-write,那是 base 层特意放开的,不是”系统默认如此”。
两类强制者
围栏有两种实现,能力差别不小:
| 强制者 | 围什么 | 强度 | |
|---|---|---|---|
| 子进程沙箱 | bwrap / Landlock(Linux)、Seatbelt(macOS)、ACL restricted token(Windows) |
被拉起的那个子进程 | 内核级;Windows 恒为 partial |
| 进程内围栏 | fs-sandbox |
当前进程里走 ctx.fs 的写 |
策略级,不是安全边界 |
第二行的措辞不是我的评价,是包注释自己的话:This is containment, not a security boundary。它甚至明确接受一类残余 TOCTOU——祖先符号链接在复核与 syscall 之间被换掉。要隔离不信任的代码,那是子进程沙箱的事。
区分两种强制方式有实践意义:进程内围栏能挡住模型的手,挡不住同一个进程里恶意代码的手。所以 write 工具有沙箱不代表”在沙箱里跑”。
SandboxEnforcement 只有 full 和 partial 两个值,Windows ACL runner 恒定是 partial:它必须把 Everyone 留在两张 restricting 列表里(早期 DLL 初始化与 CNG 需要这个保活组),而 Low 完整性标签已经关掉了它过去带来的写权限;NTFS 硬链接仍能把一个已授权的工作区文件别名到根目录之外,读侧不受围栏约束。要求绝对边界的消费者应该自己拒绝 partial——不要假设 API 会给 full。
fail-closed 长什么样
这是设计上最硬的一条:没有任何 runner 可用时,命令不跑。不是”不围就跑”,是直接拒绝。
沙箱链走到 'unavailable' → 抛 SandboxUnavailableError
code: SANDBOX_UNAVAILABLE
Service Definition 的注释把这条写成了明文规定:silent unconfined passthrough is forbidden——静默不围是禁止的。所以 Linux 上没装 bwrap 且内核不支持 Landlock 时,表现是”所有需要围栏的 bash 都不执行”,而不是”悄悄放行”。
还有一个容易混的区分:“被拒绝”和”跑不起来”必须分开报。消费者先判 runner 失败再判拒绝,因为”runner 失败意味着命令根本没跑”。ConfinedArgv 为此带了两套 stderr 词表:
denialSignatures是围栏生效的指纹(bwrap 的 EROFS、Landlock 的 EACCES、Seatbelt 的 EPERM);runnerFailureRules是 runner 自己崩了的证据。
自己写 runner 或换 runner 时,这两套词表是最容易配错的东西——配错的后果是”拒绝”被当成”没跑起来”,或者反过来。
从命令发出到最终结论,走的是这条判定路径:
观察策略的真实语义
规划里原来写的”观察策略三档模式”在代码里并不存在。真实的它是一个 event-only 插件:不注册服务、不做任何文件 I/O,只在两个单槽瀑布上回答”这次写/改该怎么设防”,并把观察到的事实记进自己的一张 WeakMap。
它实现的是先读后写 + 版本 CAS:
| 文件状态 | 写(fs/write-intent) |
改(fs/edit-intent) |
|---|---|---|
| 没读过 | createIfAbsent |
FS_NOT_OBSERVED——直接拒 |
| 读过、现在不存在 | createIfAbsent |
FS_NOT_FOUND |
| 读过、存在 | replaceIfVersion(带上版本) |
版本守卫:版本变了就拒 |
也就是说:编辑一个没读过的文件会被拒;读过了但中途被别人改过,也会被拒。所谓”版本新鲜”是授权标准——注意它不是”你看全了”:窗口读也能授权整文件覆盖。
四处已知限制值得记在笔记里:
- 观察状态不跨会话恢复。resume 一个新会话之后,之前读过的文件又算”没读过”。
- 没有 agent session 的调用永远满足不了编辑条件——写一律是
createIfAbsent。 - 绕过工具直接调
ctx.fs的读不发fs/observed,所以也不会被记为”读过”。 - 授权标准是”版本新鲜”而非”看全”。
最后一条实践警告:fs/write-intent 和 fs/edit-intent 都是单槽瀑布、先注册者赢,fs-observation-policy 已经占了槽。想加自己的权限、审计、拦截,正确的位置是 tools/execute 瀑布,不要去抢这两个槽——抢了就是把内置策略顶掉。
越界时模型看到什么
这一层决定模型能不能自己纠正,所以值得单独看。越界写会抛 FS_SANDBOX_DENIED,工具层把它换成共享标记,并在同一轮附上升级提示:
[sandbox: file access denied under read-only mode]
[sandbox: escalation available — retry this exact <subject> once with …]
观察策略的两个码则被规范成给模型看的补救句:
cannot modify "<path>": file has not been read — read the file, then retry
<原因> — re-read the file, then retry
这两句是给模型的行动指令,不是给人看的日志。写插件时如果自己造了一类拒绝,照这个样子给一句”下一步做什么”,比只回一个错误码有用得多。
升级(改宽模式重试)本身也有约束:
sandbox_permissions必须与justification成对出现,理由不能为空;- 目标模式必须严格更宽才算数,否则直接抛错、根本不问人;
- schema 里的枚举是闭集,但”严格更宽”是执行期按当次有效模式判的。
权限预设打包了什么
就两个旋钮:
interface PresetSpec {
sandbox: SandboxMode
approval: ApprovalPolicy
name?: string
description?: string
}
内置两项:workspace-write(workspace-write + ask)和 danger-full-access(danger-full-access + never)。还有一个 'custom'——它是派生状态,不是表项、也不是事件值:当会话的实际组合对不上任何预设时显示为它。
预设的写路径是”先记选择,再逐旋钮写”:先 append 一条 permission/preset 事件(log-only 的用户意图),然后走各旋钮自己的写路径改值。它自己不执行任何策略,所以”预设名字看着对、实际值不对”这种情况不可能是预设造成的,一定是某个旋钮被单独改过。
边界观察器
demo 是一个只读的观察器:它不注册任何拦截器、不抢 fs/* 的单槽决策,只把三层的事实摊出来。三块内容刚好对应三层。
第一块,订阅 fs/observed 记录观察事实:
// fs/observed 是记录型事件:必须同步、不许抛,所以这里只做一次 Map.set。
ctx.on('fs/observed', (target, observation, actor) => {
const session = sessionOf(actor)
if (session === undefined) return
const byPath = observed.get(session) ?? new Map<string, string>()
const mark = observation.kind === 'present' ? `v:${observation.version}` : 'absent'
byPath.set(target.displayPath, mark)
observed.set(session, byPath)
})
用 WeakMap 以会话为键——插件的观察状态不该阻止会话被回收。注意 fs/observed 是同步、不许抛的记录型事件:工具层不兜这个 emit,抛异常会把工具变成 isError,但那时候写操作早就落盘了。
第二块,订阅会话事件看模式切换:
// sandbox/mode 是 log-only 事件:切模式只往会话日志里追加一条,重放即恢复。
ctx.on('session/event', (session, event) => {
if (event.type !== 'sandbox/mode') return
console.log(`[${name}] ${session.id} 文件效果模式 -> ${String(event.data.mode)}`)
})
第三块,注册一个无参数工具,读当前有效策略:
execute(_args, exec) {
// 有效模式 = 已批准的显式升级 > 会话日志里最后一条 sandbox/mode > 部署默认。
const session = exec.agent?.session
const policy = ctx.sandboxPolicy.resolve(session === undefined ? {} : { session })
const files = session === undefined ? undefined : observed.get(session)
return {
mode: policy.mode,
workspaceRoot: policy.workspaceRoot,
observedFiles: files?.size ?? 0,
}
},
ctx.sandboxPolicy.resolve() 是唯一该用来问”现在有效模式是什么”的入口——它把上面那条优先级链替你走完。不要自己去读配置或翻会话日志:链子以后可能变,而 resolve 不会。
验证
cd code/dsh_plugin/dsh-fs-write-watch && ./install.sh
三步,每步对应一层:
1. 会话里让模型调 policy_report
→ mode 应当和界面上权限选择器显示的一致
2. 让模型对同一个文件「先 read、再 edit」
→ 成功;然后换个没读过的文件直接 edit
→ 应当拿到 "file has not been read — read the file, then retry"
3. 把模式切到 read-only,让模型再写同一个文件
→ 应当拿到 [sandbox: file access denied under read-only mode]
第三步入如果切到 danger-full-access 再试一次,标记会消失——这正好证明围栏来自那两处,不是工具自带的。观察器自己什么都没做,它只是把事实读了出来。
注意事项
- 别把
fs围栏当安全边界。 它是策略围栏,包注释自己就写着containment, not a security boundary。要隔离不信任的代码,用子进程沙箱;要挡模型的手,fs-sandbox够用。这两件事不要互相替代。 - 加自己的边界去
tools/execute瀑布。fs/write-intent/fs/edit-intent是单槽先到先得,内置策略已经占了;插自己的处理器会把内置策略顶掉,而且不会报错——只是行为变成你的了。 fs/observed的监听器必须同步且不抛。 它走ctx.emit,工具层不兜;抛出来只会让工具变isError,而写已经落盘了。- 不要假设
ctx.get('sandbox')一定有值。 宿主没装围栏时它就是undefined,此时任何”我用沙箱兜住了”的说法都是假的。检查它,然后 fail-closed 或显式降级成”我不做这件事”。 - 观察状态不跨会话。 它存在内存里、按会话分;resume 之后文件都算”没读过”。要自己的持久观察记录,得自己落盘。
partial不是”差不多”。 Windows ACL runner 恒为partial。如果你的插件要求绝对边界,显式拒绝partial,别让它悄悄降级。- 改模式只有一条写路径。
setSandboxMode之外没有别的东西改模式状态,而它会话日志里追加事件。要自己实现”切换模式”的功能,走这条路径;直接改配置或内存状态会导致重放结果和当时不一致。