Skip to content

这里并存两个容易混淆的关注点:观察(某文件看到了什么、读到哪个版本)与强制(某次变更允许触碰什么)。dsh 把它们放在不同包中,通过 fs/* 事件接口与 ctx.sandboxPolicy 协同。

角色
packages/fs/fs-observation-policy仅事件的已观察文件跟踪;派生写入/编辑意图
packages/sandbox/sandbox-policyctx.sandboxPolicy:解析每个会话的模式、工作区根与覆盖
packages/fs/fs-sandboxctx.fs 的沙箱强制实现(SandboxedFileSystem
packages/fs/fsctx.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.tsFsTarget(不透明 targetKey + displayPath)、FsVersion(不透明新鲜度令牌)、FsObservation

ts
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-intentfs/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

SandboxedFileSystempackages/fs/fs-sandbox/src/index.ts)继承 LocalFileSystem 并注册为 ctx.fs。它继承全部文本存储机制(resolve、stat、读、列表、原子写、读匹配写的编辑临界区),并在两个变更操作 writeTexteditText 上增加逐调用策略围栏。读取原样放行:每种模式都允许读取。

ts
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):规范化工作区根加 /tmpos.tmpdir()。这与 Seatbelt 配置授予的集合相同,因此 fs 围栏与 bash 对 workspace-write 承诺什么永不起分歧。
  • 被拒抛结构化 FS_SANDBOX_DENIED——无需 stderr 文本推断(bash 是内核方言),因为进程内围栏知道它拒绝的到底是什么。工具层将其映射为面向模型的 [sandbox: …] 标记与升级提示。
  • 这是可信代码对模型控制路径的策略检查,不是内核边界——是包含(containment),不是安全兜底。对不可信代码的内核隔离仍是 ctx.shell 的职责。

策略如何推导,使围栏保持无会话状态

ctx.sandboxPolicypackages/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 漫游:

text
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(权威存在/缺失,作为普通读写在会话日志中对模型可见)。策略门本身对模型不可见,只通过其效果(createIfAbsentreplaceIfVersion 意图,以及 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.resolvesandbox/mode 覆盖折叠。