这里并存两个容易混淆的关注点:观察(某文件看到了什么、读到哪个版本)与强制(某次变更允许触碰什么)。dsh 把它们放在不同包中,通过 fs/* 事件接口与 ctx.sandboxPolicy 协同。
| 包 | 角色 |
|---|---|
packages/fs/fs-observation-policy | 仅事件的已观察文件跟踪;派生写入/编辑意图 |
packages/sandbox/sandbox-policy | ctx.sandboxPolicy:解析每个会话的模式、工作区根与覆盖 |
packages/fs/fs-sandbox | ctx.fs 的沙箱强制实现(SandboxedFileSystem) |
packages/fs/fs | ctx.fs 服务定义 + fs/* 事件词汇 |
packages/workspace/workspace | 持久化工作区注册表;规范化工作区根 |
观察 vs 强制
| 关注点 | 作用 | 位置 |
|---|---|---|
| 观察 | 记录某文件的权威存在/缺失,以及它被读到的版本 | fs/observed 事件,由 fs-observation-policy 折叠 |
| 强制 | 决定某次变更在当下模式/路径策略下是否被允许 | SandboxedFileSystem 基于 writableRoots 的围栏 |
观察是仅事件的:fs-observation-policy 不注册任何服务——它监听文件系统提供方发出的 fs/* 事件。强制是沙箱提供方内的一个策略围栏。
fs/* 事件接口
@deepseek-ai/dsh-fs 服务定义(packages/fs/fs/src/index.ts)声明了三个观察策略监听的事件:
| 事件 | 签名 | 含义 |
|---|---|---|
fs/write-intent | (target, actor, next) => FsWriteIntent | 写入前决定写入的新鲜度保护 |
fs/edit-intent | (target, actor, next) => { version } | 决定编辑的 CAS 版本(必须先被观察) |
fs/observed | (target, observation, actor): void | 记录某目标被权威性看到(存在或缺失) |
词汇位于 packages/fs/fs/src/types.ts:FsTarget(不透明 targetKey + displayPath)、FsVersion(不透明新鲜度令牌)、FsObservation:
export type FsObservation =
| { readonly kind: 'present'; readonly version: FsVersion }
| { readonly kind: 'absent' }观察策略门(fs-observation-policy)
packages/fs/fs-observation-policy/src/index.ts 构建一个 ObservedStateGate——一个 WeakMap<owner, Map<FsTargetKey, FsObservation>>,首先按 owner 对象(弱引用持有,故被回收的会话会释放其状态)键控,再按目标键键控。owner 从事件 actor 派生:通常是调用 agent 的会话(FsObservationActor.agent.session)。无法派生出 owner 的调用(无 agent)可自由读取,但永远不能满足写/编辑的先观察策略。
它的三个监听器钩住 fs/* 事件:fs/write-intent 与 fs/edit-intent 占据各自 intent waterfall 的单一决策槽(不调用 next()),而 fs/observed 是 @mode emit 记录器:
fs/write-intent→ 未见或确认缺失 ⇒createIfAbsent;确认存在 ⇒ 在观察到的版本上replaceIfVersion。fs/edit-intent→ 未见拒绝FS_NOT_OBSERVED;确认缺失拒绝FS_NOT_FOUND;存在则提供观察到的版本作为 CAS 基础。fs/observed→ 记录存在(带版本)或缺失。必须保持同步且不抛错:变更已经提交,而emit不 await promise。
没有此插件时,工具保留裸提供方的无条件变更行为。因此读前编辑检查位于 tool-fs 之下的 fs/* 事件上——模型不能静默编辑一个从未读过的文件,也不能覆盖一个已在背后变化的文件。README 的组合规则界定了这一点:它必须作为文件系统后端的兄弟插件加载。
强制围栏(fs-sandbox)
SandboxedFileSystem(packages/fs/fs-sandbox/src/index.ts)继承 LocalFileSystem 并注册为 ctx.fs。它继承全部文本存储机制(resolve、stat、读、列表、原子写、读匹配写的编辑临界区),并在两个变更操作 writeText、editText 上增加逐调用策略围栏。读取原样放行:每种模式都允许读取。
private async checkedTarget(target: FsTarget, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsTarget> {
const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()
const { mode } = policy
if (mode === 'danger-full-access') return target
if (mode === 'read-only') {
throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED')
}
// workspace-write: containment on the FRESH canonical path ...
const fresh = await this.resolve(target.displayPath)
let contained = false
for (const root of writableRoots(policy)) {
if (await isPathUnder(fresh.targetKey, root)) { contained = true; break }
}
if (!contained) {
throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')
}
return fresh
}关键性质:
- 检查针对新鲜的规范化路径,在委派前立即重新解析,且变更以该目标委派——缩窄"在这里检查、在那里写入"的 TOCTOU(check 与 syscall 之间被换掉的符号链接祖先是本威胁模型接受的残余)。
- 可写根集合来自
@deepseek-ai/dsh-sandbox的共享writableRoots辅助函数(packages/sandbox/sandbox/src/roots.ts):规范化工作区根加/tmp与os.tmpdir()。这与 Seatbelt 配置授予的集合相同,因此 fs 围栏与 bash 对workspace-write承诺什么永不起分歧。 - 被拒抛结构化
FS_SANDBOX_DENIED——无需 stderr 文本推断(bash 是内核方言),因为进程内围栏知道它拒绝的到底是什么。工具层将其映射为面向模型的[sandbox: …]标记与升级提示。 - 这是可信代码对模型控制路径的策略检查,不是内核边界——是包含(containment),不是安全兜底。对不可信代码的内核隔离仍是
ctx.shell的职责。
策略如何推导,使围栏保持无会话状态
ctx.sandboxPolicy(packages/sandbox/sandbox-policy/src/index.ts)是模式 + 工作区根的单一所有者,每次调用解析一次(SandboxPolicyService.resolve({ session?, mode? }))。优先级:经批准的显式 mode 覆盖 → 会话最近一条 sandbox/mode 事件 → 部署默认值(read-only)。workspace-write 根是会话不可变的 cwd;配置的 workspaceRoot 是 agentless 调用与无 cwd 会话的回退。
策略所推导的工作区是调用方会话的 cwd,而工作区注册表(packages/workspace/workspace/src/index.ts,@deepseek-ai/dsh-workspace)把它当作持久化、已规范化的边界:每个工作区是一个规范化路径,其会话成员身份由真实的 realpath 规范化 cwd 校验。沙箱策略消费适用于当前调用的那些根,而不是自行再推导——一个规范化含义,由强制与工作区记账共享。
一次变更的数据流
workspace-write 下一次 write 调用的 ASCII 漫游:
model tool call: tool-fs/write(filePath, content)
→ tool-fs 通过 sandbox.resolvePolicy('write', …) 解析逐调用策略
→ tool-fs 调用 ctx.fs.writeText(target, content, intent, signal, sandboxPolicy)
→ SandboxedFileSystem.checkedTarget:
read-only → 抛 FS_SANDBOX_DENIED
workspace-write → 重新解析新鲜目标,要求在 writableRoots 下包含
danger-full-access → 不设围栏地委派
→ 继承的原子 writeText 提交
→ tool-fs 发出 fs/observed(present/absent) → 观察策略记录bash 写入路径不同:ctx.sandbox.confine(['bash','-c', command], policy) 把整个 argv 包进内核 runner,拒绝常从 runner 的 stderr 方言推断,而不是由进程内围栏抛出——ctx.sandboxPolicy.resolve() 向两者提供完全相同的模式 + 根。
审计事件
观察发出 fs/observed(权威存在/缺失,作为普通读写在会话日志中对模型可见)。策略门本身对模型不可见,只通过其效果(createIfAbsent 与 replaceIfVersion 意图,以及 FS_NOT_OBSERVED/FS_NOT_FOUND 拒绝)体现。观察没有单独的审计表面;强制拒绝以 FS_SANDBOX_DENIED 呈现,在工具结果里映射为 [sandbox: …] 标记。
延伸阅读
- 沙箱架构:总览——文件影响模式、runner 链与升级阶梯。
- 权限与审批——
approveEscalation如何为一次调用授予更宽的mode,随后由围栏兑现。 - Landlock:原生 Linux runner——支撑 bash 侧同一策略的文件影响授权。
packages/fs/fs-observation-policy/src/index.ts——ObservedStateGate及其三个监听器。packages/fs/fs-sandbox/src/index.ts——SandboxedFileSystem的变更围栏。packages/sandbox/sandbox-policy/src/index.ts——SandboxPolicyService.resolve与sandbox/mode覆盖折叠。