一切皆插件:DeepSeek Harness 源码精读 9 / 14

第 9 章

文件系统 capability 与策略

源码核对基于 deepseek-ai/deepseek-harness commit `b150a551b8`(2026-08-21),tag `course-anchor-20260823`对比语料 anthropics/claude-code commit `45bdfa96`(2026-08-23),tag `v2.1.241`本章字数:约 8300 字(不含代码与图)

场景还原

模型在一个会话里第一次 write 文件 A,动作是「创建」,它没见过内容。几轮之后第二次 write 同一个文件,动作变成「更新」,模型依然没见过内容,直接覆盖。用户昨天手工改过这个文件,改动全没了。这个事故在单工具实现里几乎必然发生:工具拿到路径和内容,写就是了,它不知道调用方见过什么。

防它需要一条规则:写已存在的文件之前,调用方必须先读过它,而且读到的版本必须还是当前版本。规则放哪,是个架构问题。放 write 工具里,每多一个写路径的工具就得多复制一遍;放 provider 里,存储层就得懂会话和模型;放独立服务里,工具就离不开它。dsh 的答案是把策略挂在三个 fs/* 事件上:工具发事件,策略插件监听并返回决策,provider 只做原子核对。本章跟着一次 write 的调用链读完这条链路,再看这个拆法的每个理由,以及不拆的时候历史上踩过什么。

逐行精读

先看一次 write 的完整执行体,后面所有内容都围绕它展开。

packages/fs/tool-fs/src/write.ts102:129
102    async execute(args: WriteToolArgs, exec) {103      const input = parseWriteArgs(args)104      // Resolve the per-call sandbox policy (approved mode > session override105      // > backend default, plus the session cwd root) BEFORE anything executes;106      // an escalating call throws its distinct text on any non-grant.107      const sandboxPolicy = await sandbox.resolvePolicy('write', args, exec)108      const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath, sandboxPolicy?.workspaceRoot))109      // Single-slot decision: the policy plugin produces createIfAbsent/110      // replaceIfVersion; the bare default is undefined (unconditional). No stat.111      const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)112      let outcome: FsWriteOutcome113      try {114        outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy)115      } catch (error: unknown) {116        // A sandbox denial becomes the shared [sandbox: …] marker (the model117        // recognizes it from bash); stale/not-observed failures gain their118        // model-facing remedy; anything else passes through.119        throw remediateFsError(sandbox.mapError(error, sandboxPolicy))120      }121      // Record the present observation (a no-op when no policy plugin listens).122      ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)123      return {124        path: target.displayPath,125        operation: outcome.operation,126        before: outcome.before,127        after: outcome.after,128      }129    },

执行体五步:校验参数、解析沙箱策略、ctx.fs.resolve 归一化路径、ctx.waterfall('fs/write-intent') 向事件闸门要一个写入意图、ctx.fs.writeText 提交、最后 ctx.emit('fs/observed') 记录观察结果。注意两个细节:工具自己不做任何路径字符串处理,原样交给 resolve;工具的 next 默认值是 () => undefined,意思是「没有策略插件时,就是无条件的裸写」。工具不知道策略插件在不在,它只提供最保守的底。

这张时序图回答:一次 write 从模型请求到观察记录,依次经过工具、事件闸门、沙箱栅栏、provider 锁内核对这几层,每层分别做什么决策。下面按常见部署画:fs-sandbox 当 provider,策略插件在场。

sequenceDiagram participant Model as 模型 participant Tool as dsh-tool-fs participant Gate as fs-observation-policy participant Sandbox as 沙箱栅栏 participant Local as 存储层 Model->>Tool: write 路径与内容 Tool->>Tool: parseWriteArgs 校验参数 Tool->>Tool: sandbox.resolvePolicy 定本次模式 Tool->>Local: ctx.fs.resolve 归一化路径 Tool->>Gate: fs/write-intent 查观察状态 Gate-->>Tool: createIfAbsent 或 replaceIfVersion Tool->>Sandbox: ctx.fs.writeText 带意图 Sandbox->>Sandbox: checkedTarget 路径栅栏 alt 路径不在允许区 Sandbox-->>Tool: FS_SANDBOX_DENIED else 放行 Sandbox->>Local: 委托超类 锁内核对版本 alt 版本过期或未读覆盖 Local-->>Tool: FS_STALE_VERSION 或 FS_NOT_OBSERVED else 核对通过 Local-->>Sandbox: 原子写并铸新版本 end end Tool->>Gate: fs/observed 记录 present 版本 Tool-->>Model: 返回路径与新旧版本

还有一个注册时刻的细节。FileSystem 是抽象类,构造时把 'fs' 这个键名交给父类构造器,ctx.fs 这个名字由此进入 cordis 的服务表:

packages/fs/fs/src/index.ts86:89
86export abstract class FileSystem extends Service {87  constructor(ctx: Context) {88    super(ctx, 'fs')89  }

Service Definition 落成抽象类,这是仓库词汇表钉死的(docs/glossary.md:9 的 seam 条目写明它 never a TypeScript interface)。interface 只能描述形状,抽象类能携带注册行为,还能被 provider 用 extends 继承:LocalFileSystem extends FileSystemFsSandbox extends LocalFileSystem,每加一层实现,工具和策略看到的接口都不变。事件词汇也靠同一次声明合并挂上(fs/src/index.ts:44-78),后面第二层会贴到完整声明。

第一层:路径归一化在 provider

resolve 的实现在 fs-local,真正的逻辑在 fsio.tsresolveLocalTarget。这一层是「路径校验和归一化」的唯一起点。

packages/fs/fs-local/src/fsio.ts146:194
146export async function resolveLocalTarget(cwd: string, path: string): Promise<LocalTarget> {147  if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')148  const displayPath = resolve(cwd, path)149  try {150    // Prefer the file's own realpath (resolves a symlinked file to its target).151    return { displayPath, targetKey: FsTargetKey(await realpath(displayPath)) }152  } catch (error: unknown) {153    // A path component is a file, not a directory (e.g. "afile/child.txt" where154    // "afile" is a regular file): the target can neither exist nor be created,155    // so surface the structured taxonomy instead of a raw Node ENOTDIR.156    /* v8 ignore next -- Windows reports this case as ENOENT and repairs it in the ancestor walk below. */157    if (isENOTDIR(error)) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')158    /* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to ancestor resolution. */159    if (!isENOENT(error)) throw error160  }161  // File absent: realpath the nearest existing ancestor and re-append the162  // missing suffix (the file basename plus any not-yet-created intermediate163  // dirs), so the key is stable across creation of those dirs.164  const missing = [basename(displayPath)]165  let ancestor = dirname(displayPath)166  while (true) {167    try {168      const realAncestor = await realpath(ancestor)169      // On Windows, realpath of a regular file succeeds where POSIX returns170      // ENOTDIR (the OS reports ENOENT for `regular-file/child`, not ENOTDIR).171      // Stat the ancestor to restore the semantic distinction: a non-directory172      // ancestor means the target passes through a file and can never be created.173      /* v8 ignore start -- native Windows coverage exercises this repair; POSIX reports ENOTDIR before this point. */174      if (process.platform === 'win32') {175        const parentInfo = await stat(realAncestor)176        if (!parentInfo.isDirectory()) {177          throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')178        }179      }180      /* v8 ignore stop */181      return { displayPath, targetKey: FsTargetKey(join(realAncestor, ...missing)) }182    } catch (error: unknown) {183      /* v8 ignore next -- native Windows coverage exercises the FsError raised by the repair above. */184      if (error instanceof FsError) throw error185      /* v8 ignore next -- a non-ENOENT realpath failure needs a permission/IO fault. */186      if (!isENOENT(error)) throw error187      const parent = dirname(ancestor)188      /* v8 ignore next -- the filesystem root always realpaths, so the walk terminates before parent === ancestor. */189      if (parent === ancestor) return { displayPath, targetKey: FsTargetKey(displayPath) }190      missing.unshift(basename(ancestor))191      ancestor = parent192    }193  }194}

这段回答三个问题。空串和纯空白路径拒绝,FS_NOT_FOUND(147 行)。相对路径以 cwd 为基准归一化成绝对路径,displayPath 是给人看的原样绝对路径,targetKey 是 realpath 身份(151 行),两个东西分开。文件不存在时,realpath 最近的已存在祖先,把缺失后缀重新拼回去(164 到 181 行),这样「先创建目录再写文件」前后 targetKey 保持一致。平台分支也在这里:Windows 上 regular-file/child 报 ENOENT 而不是 ENOTDIR,需要 stat 祖先来恢复语义(174 到 179 行)。CRLF 行尾的处理在同一个包的 fsio.ts:620-648,写回时恢复原行尾。这些归一化细节全部收在 provider 里,工具层、策略层、模型层都看不到路径字符串处理。

第二层:事件闸门产出写入意图

工具把 resolved 的 target 和 exec 作为 opaque actor 扔进 waterfall。策略插件在 fs-observation-policy 包,它注册三个监听器,不注册任何服务,不 inject fs

packages/fs/fs-observation-policy/src/index.ts65:71
65  writeIntent(target: FsTarget, actor: object | undefined): FsWriteIntent {66    const owner = this.owner(actor)67    const prior = owner ? this.get(owner, target.targetKey) : undefined68    return prior?.kind === 'present'69      ? { kind: 'replaceIfVersion', version: prior.version }70      : { kind: 'createIfAbsent' }71  }

决策完全基于观察状态:owner 之前见过这个文件且版本在案,就给 replaceIfVersion;没见过或确认过不存在,就给 createIfAbsent。两个意图的语义在 types.ts

packages/fs/fs/src/types.ts123:125
123export type FsWriteIntent =124  | { kind: 'createIfAbsent' }125  | { kind: 'replaceIfVersion'; version: FsVersion }

「无条件覆盖」不存在于这个联合类型里,它是省略 expected 参数表达出来的。edit 的闸门更严:

packages/fs/fs-observation-policy/src/index.ts78:88
78  editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {79    const owner = this.owner(actor)80    const prior = owner ? this.get(owner, target.targetKey) : undefined81    if (!owner || prior === undefined) {82      throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')83    }84    if (prior.kind === 'absent') {85      throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND')86    }87    return { version: prior.version }88  }

从未读过,FS_NOT_OBSERVED;确认过不存在,FS_NOT_FOUND;读过且在案,返回观察到的版本作为 CAS 基准。owner 从 opaque actor 里结构性推导,不 import 任何 agent 或 session 包:

packages/fs/fs-observation-policy/src/index.ts36:41
36  private owner(actor: object | undefined): object | undefined {37    // tsgolint treats object as assignable to weak FsObservationActor, while tsc still requires the structural cast for property access.38    // See the analyzer-divergence consequence in .agents/notes/implemented/process/2026-07-29-oxlint-linter.md.39    // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- The analyzers disagree on this weak type.40    return (actor as FsObservationActor | undefined)?.agent?.session41  }

第三层:provider 的原子核对

闸门只决定「该不该带 guard」,版本是否还新鲜由 provider 在锁内核对。fs-localwriteText

packages/fs/fs-local/src/index.ts178:188
178      if (expected?.kind === 'replaceIfVersion') {179        // Stale guard: the file must still exist at the version the owner observed.180        if (!existing) throw new FsError(`cannot write "${target.displayPath}": file no longer exists`, 'FS_STALE_VERSION')181        if (existing.version !== expected.version) {182          throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')183        }184      } else if (expected?.kind === 'createIfAbsent' && existing) {185        // createIfAbsent onto an existing file: a blind overwrite — require a read first.186        throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')187      }188      // No expectation means an unconditional but still atomic write.

editText 同样在锁内先核对版本再匹配字面量:

packages/fs/fs-local/src/index.ts221:239
221  override async editText(222    target: FsTarget,223    edit: FsEditRequest,224    expected?: { version: FsVersion },225    signal?: AbortSignal,226  ): Promise<FsEditOutcome> {227    return this.withLock(target.targetKey, async () => {228      const existing = await probe(target.targetKey)229      // Stale guard before literal matching: an edit based on an old read reports230      // FS_STALE_VERSION, not FS_EDIT_NOT_FOUND/FS_AMBIGUOUS_EDIT against newer content.231      // Missing targets use the same stale code on guarded and unconditional edit paths.232      if (!existing) throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')233      if (existing.type !== 'file') throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')234      // expected === undefined: unconditional edit of the current content — no235      // version guard. Still inside the per-target lock, so the read→match→write236      // window is serialized and atomic.237      if (expected && existing.version !== expected.version) {238        throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')239      }

核对在锁内做,读、核对、写、返回新版本是一个不可分割的区间。并发写同一个文件时,一个成功,其余拿到 FS_STALE_VERSION。三个事件词汇由 dsh-fs 拥有,工具和策略插件都依赖它,谁也不用 import 谁:

packages/fs/fs/src/index.ts44:78
44declare module '@deepseek-ai/cordis' {45  interface Context {46    fs: FileSystem47  }4849  interface Events {50    /**51     * Single-slot decision for the next {@link FileSystem.writeText}. Calling52     * `next()` yields the bare provider's unconditional write; the first listener53     * that returns an intent owns the decision rather than composing with peers.54     * @param target - the resolved target about to be written.55     * @param actor - the opaque tool-execution context the decider keys off.56     * @mode waterfall57     */58    'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>59    /**60     * Single-slot decision for the next {@link FileSystem.editText}. Calling61     * `next()` yields an unconditional edit; the first returned guard wins.62     * @param target - the resolved target about to be edited.63     * @param actor - the opaque tool-execution context the decider keys off.64     * @mode waterfall65     */66    'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>67    /**68     * Record an authoritative positive or negative observation. Listeners must69     * be synchronous recorders: throws fail the tool call and returned promises70     * are not awaited.71     * @param target - the target whose presence or absence was observed.72     * @param observation - present with its version, or confirmed absent.73     * @param actor - the observing tool-execution context; undefined records nothing useful.74     * @mode emit75     */76    'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void77  }78}

事件携带的只有 FsTargetFsObservationFsWriteIntent 这些 provider 词汇,外加一个 opaque object actor,没有行号、没有窗口、没有任何模型侧概念。FsObservation 是 present/absent 判别联合:

packages/fs/fs/src/types.ts52:54
52export type FsObservation =53  | { readonly kind: 'present'; readonly version: FsVersion }54  | { readonly kind: 'absent' }

闸门的三个监听器在 apply 里注册,waterfall 不调 next(),把决策槽整个占住:

packages/fs/fs-observation-policy/src/index.ts106:130
106export function apply(ctx: Context): void {107  const gate = new ObservedStateGate()108109  ctx.effect(() => () => {110    // Drop all recorded state on disposal so a reloaded plugin starts clean111    // (HMR safety). The WeakMap itself would be GC'd, but replacing it makes the112    // release observable and immediate for tests.113    gate.clear()114  }, 'fs-observation-policy observed-state teardown')115116  // fs/write-intent: occupy the single decision slot — do NOT call next().117  // Deferred through Promise.resolve().then so the declared Promise return type118  // holds (a throw rejects, never escapes synchronously through the waterfall).119  ctx.on('fs/write-intent', (target, actor) => Promise.resolve().then(() => gate.writeIntent(target, actor)))120121  // fs/edit-intent: occupy the single decision slot — do not call next().122  ctx.on('fs/edit-intent', (target, actor) => Promise.resolve().then(() => gate.editIntent(target, actor)))123124  // fs/observed must remain synchronous and non-throwing: emit does not await125  // promises, and successful mutations have already committed. WeakMap.set126  // satisfies that contract for both presence and absence.127  ctx.on('fs/observed', (target, observation, actor) => {128    gate.observe(target, observation, actor)129  })130}

还有第二个 Consumer:tool-str-replace-editorcreate 命令走同一个闸门,但它的裸默认值是 createIfAbsent,因为它本身就只许创建:

packages/fs/tool-str-replace-editor/src/index.ts249:257
249  if (await ctx.fs.stat(target, exec.signal) !== undefined) {250    throw new Error(`File already exists at: ${target.displayPath}. Cannot overwrite files using command \`create\`.`)251  }252  const intent = await ctx.waterfall(253    'fs/write-intent',254    target,255    exec,256    () => ({ kind: 'createIfAbsent' } as const),257  )

同一个闸门、同一个决策槽,两个 Consumer 各带各的裸默认值,策略插件一装,两者都被盖住。这是「策略可注入」的验收画面。

第四层之外:沙箱栅栏是另一个 provider

上文整条链都是裸 provider 的。部署里常见的组合是把 fs-local 换成 fs-sandbox,它继承 LocalFileSystem,文本存储机制全部原样继承,只给两个变更操作加一道路径栅栏:

packages/fs/fs-sandbox/src/index.ts126:148
126  private async checkedTarget(target: FsTarget, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsTarget> {127    const policy = sandboxPolicy ?? this.ctx.sandboxPolicy.resolve()128    const { mode } = policy129    if (mode === 'danger-full-access') return target130    if (mode === 'read-only') {131      throw new FsError(`cannot write "${target.displayPath}": file access denied under read-only mode`, 'FS_SANDBOX_DENIED')132    }133    // workspace-write: containment on the FRESH canonical path (catches a134    // symlink ancestor swapped since the tool resolved this target), and the135    // mutation delegates with THIS fresh target — never the stale one.136    const fresh = await this.resolve(target.displayPath)137    let contained = false138    for (const root of writableRoots(policy)) {139      if (await isPathUnder(fresh.targetKey, root)) {140        contained = true141        break142      }143    }144    if (!contained) {145      throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')146    }147    return fresh148  }

read-only 模式直接拒绝一切变更(131 行);workspace-write 模式把目标重新归一化一次(136 行),防的是工具 resolve 之后符号链接祖先被换掉,再用新鲜路径查可写根集合(138 到 143 行);danger-full-access 原样放行(129 行)。统一错误码 FS_SANDBOX_DENIED,模型侧靠 [sandbox: …] 前缀识别(write.ts:116-118 的注释)。模块头注释交代了定位:栅栏是可信代码里对模型可控路径的策略检查,内核级隔离是 ctx.shell 的活(fs-sandbox/src/index.ts:13-14)。换 provider 的代价是一行配置,examples/acp-agent/cordis.yml 里把 dsh-fs-local 换成 dsh-fs-sandbox(168 到 171 行),策略插件照常正交叠加,工具代码一行不动。这个「无 fork 换实现」和第 7 章 shell 的 provider 交换是同一个形状。

能力事实驱动工具外观

工具自己不知道装的是哪个 provider,但它决定向模型宣传什么参数。tool-fs 的沙箱控制器在构造时读 ctx.fs.sandboxMode,这是能力事实,不是配置:

packages/fs/tool-fs/src/sandbox.ts43:50
43  constructor(private readonly ctx: Context) {44    const defaultMode = ctx.fs.sandboxMode45    this.escalationModes = defaultMode === undefined ? [] : ESCALATION_TARGETS46    this.policy = defaultMode === undefined ? undefined : ctx.get('sandboxPolicy')47    if (defaultMode !== undefined && this.policy === undefined) {48      throw new Error('tool-fs: the mounted filesystem confines but ctx.sandboxPolicy is missing')49    }50  }

裸 provider 的 sandboxMode getter 恒返回 undefined(fs/src/index.ts:103-105),工具就宣传空升级字段;沙箱 provider 返回部署默认模式,工具的 schema 才展开升级字段(write.ts:75 的 sandbox.schemaFields())。装了什么 provider,决定模型看到什么参数,工具代码本身不变。构造时还顺手做一次一致性校验:装了会限制的 provider 却没挂 sandboxPolicy 服务,直接抛错(48 行),配置错误在装载时刻就暴露,不用等到第一次写文件。

设计决策分析

事件附策略,不 import 循环

决策记录在 .agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md。初版不是这样:ctx.fileContext 是一个方法服务,工具 inject 它,所有读写都走它的方法。那个形状的问题在笔记第 9 行说得很直白:

.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md9:9
9[The split-fs-seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) put `ctx.fileContext` between the model-facing tools and the `ctx.fs` provider: `dsh-tool-fs` injects `fileContext` and routes every `read`/`write`/`edit` through its methods. That makes `fileContext` **in-path and mandatory**. The tool cannot reach `ctx.fs` without it, the policy layer owns the fs I/O and the read windowing, and a deployment that does not want observed-state policy cannot simply drop the package — `dsh-tool-fs` would fail to resolve `ctx.fileContext`.

策略包一旦在调用链上,就变成工具启动的前置条件,摘掉它工具直接崩。控制流反转之后:

.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md21:21
21Invert the control flow. **`dsh-tool-fs` becomes the executor and calls `ctx.fs` directly**; **`dsh-fs-observation-policy` becomes a gate + recorder plugin** that participates through events, never through a method the tool calls and never by registering a `ctx.fileContext` service.
.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md34:34
34The model is additive: bare `ctx.fs` performs atomic, unconstrained text I/O, while `dsh-fs-observation-policy` adds observed state, read-before-edit, and version guards. Removing the policy therefore leaves the tools usable but unconstrained. Shipped agent configs load the policy; the bare mode exists to keep policy optional at the service boundary, not as the normal deployment stance.

「可摘除」是设计目标本身。事件词汇放 dsh-fs,这是依赖方向的必然:发事件的是 dsh-tool-fs,监听的是策略包,两者共同依赖 dsh-fs,词汇放在共同依赖里,工具永不 import 策略包。策略包也永不 import 工具、agent 或 session,它只对 opaque actor 做结构性窄化(36 到 41 行的 owner)。整个系统里唯一的「策略」就是这三个监听器加一个 WeakMap。

还有个派发模式上的细节。笔记 111 行点明,决策事件是 unbound 的:

.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md111:111
111The `fs/*` decision events are **unbound waterfalls dispatched by the tool** (like `agent/request`, which the loop dispatches with no `this`), not service-bound waterfalls (like `llm/stream`). The dispatcher is the `dsh-tool-fs` plugin, which is not a service.

发事件的是工具插件本身,没有绑定任何服务实例,事件回调的 this 也不指向服务。对比 llm/stream 这类服务绑定 waterfall,决策事件要的就是不绑定:监听者可以是任何插件,包括不注册服务的策略包。词汇归属、派发模式、默认值语义,三件事都写在 dsh-fs 的声明里,策略插件只负责监听。

路径校验和归一化只在一层

所有路径字符串处理都在 resolveLocalTarget(fsio.ts:146-194),工具层不碰路径(write.ts:108 原样传递),策略层不碰路径(它只查 targetKey 和版本)。这样每个新工具不需要复制归一化逻辑,换远程 backend 时归一化语义跟着 provider 走。README 里 tool-fs 的职责边界写得很清楚:

packages/fs/README.md13:13
13| `fs-observation-policy/` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit, via the `fs/*` event gate | (no service — `fs/*` listeners) |

策略用 CAS 核对,不 stat 比较

为什么不在闸门里自己 stat 再比版本?笔记 42 到 47 行给了理由:

.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md42:47
42`dsh-fs-observation-policy` enforces "you must write/edit based on the version you read" **without ever calling `stat` or comparing versions itself**. It supplies the observed version as the CAS basis and lets the provider's mutation critical section detect staleness:4344- "What did this owner last observe?" is the one thing `dsh-fs-observation-policy` decides locally — a `WeakMap` lookup, no I/O. No record means unseen; an absent record permits only guarded creation; a present record carries the replacement/edit basis.45- "Is the version still current, or is the create target still absent?" is decided **inside the provider's atomic mutation boundary**. `dsh-fs-observation-policy` supplies `replaceIfVersion` or `createIfAbsent`; the provider raises `FS_STALE_VERSION` for a moved version and `FS_NOT_OBSERVED` when a guarded create loses to another creator.4647This is deliberate. If `dsh-fs-observation-policy` stat-ed and compared versions in its waterfall handler, there would be a TOCTOU gap between that check and the tool's actual write — the file could change in between, so the check would be a false guarantee that the provider's lock has to back up anyway. Putting the version check in the provider's critical section is both race-free and zero extra `stat`. So `dsh-fs-observation-policy` does **no** filesystem I/O; the "must be based on the latest read" guarantee is *realized* by CAS, and `dsh-fs-observation-policy` only chooses the basis (`vObserved`) and gates on prior observation.

check-then-act 中间永远有一道窗口,文件可以在检查后、写入前被改掉,那道检查是假的保证,最后还是得靠 provider 的锁兜底。直接把版本核对放进锁内的临界区,既没有 TOCTOU,还省掉一次 stat。策略包因此零文件 I/O,纯 WeakMap 查询。

不这样做会出什么事

把策略写进工具而不是挂事件,后果有三条,都能在仓库里找到对应物。第一,每个写路径的工具有各自的门,漏掉一个就是洞;tool-fstool-str-replace-editor 就是两个写路径,一个走闸门一个自带 stat 检查(249 到 251 行),stat 检查那条路有 TOCTOU 窗口,闸门那条路没有。第二,策略与工具的生命周期绑死,想给「禁止写某路径」的部署加规则得改工具代码。第三,历史事故:对比侧 claude-code 把路径规则做在工具层,CHANGELOG 里反复修「规则没覆盖到别的调用路径」的洞,见下一节。

docs/subsystems/filesystem.md 把整个事件词汇的约束意图总结成一段:

docs/subsystems/filesystem.md185:185
185`fs/write-intent` and `fs/edit-intent` are **single-slot decision waterfalls**: the tool dispatches each with a default thunk returning `undefined` (the bare provider), and a listener fully decides without calling `next()`. The slot is first-wins by registration order — the policy plugin owning it is a deployment convention, not an enforced invariant. `fs/observed` is a fire-and-forget recording event carrying an `FsObservation`: present at a version or confirmed absent. It is dispatched with a plain `ctx.emit`; its listener MUST be synchronous and side-effect-only, because the tool does NOT guard the emit — a throwing listener can replace a read error or surface as the tool's `isError` result after a mutation already succeeded. The generated [cordis surface](#cordis-surface) below shows the exact signatures.

注册新能力的位置也由规范文件钉死,docs/architecture.md 的扩展点表:

docs/architecture.md119:119
119| Add filesystem access or policy | register a `ctx.fs` provider or listen to `fs/*` events |

边界条件剖析

如果 read 之后、write 之前文件被外部改了呢? replaceIfVersion 带着观察到的版本进 provider,锁内 probe 出新版本,比对不等,抛 FS_STALE_VERSION(fs-local/src/index.ts:180-183)。错误码回给模型,附带重读提示,重读后重新授权。并发两个 write 也一样:per-target 锁(fs-local/src/index.ts:91-104)保证只有一个成功,另一个拿 FS_STALE_VERSION

如果 owner 从未读过就 edit? 闸门直接抛 FS_NOT_OBSERVED(fs-observation-policy/src/index.ts:81-83),根本到不了 provider。没有 agent session 的直接调用方(比如纯 API 调用)owner() 返回 undefined,read 自由,但 write 永远只能 createIfAbsent、edit 永远 FS_NOT_OBSERVED(fs-observation-policy/src/index.ts:36-41;包 README 的 Known Limitations 也写了这条)。

如果确认过「文件不存在」之后去 write? 观察状态是 absent,闸门给 createIfAbsent,允许创建。但两个调用方同时创建时,后到者发现文件已存在,抛 FS_NOT_OBSERVED(fs-local/src/index.ts:184-187),这条路径上「已存在」和「未读过」是同一个错误码。absent 状态下 edit 则抛 FS_NOT_FOUND(fs-observation-policy/src/index.ts:84-86)。

如果同一个文件通过两个路径访问? targetKey 是 realpath 身份(fsio.ts:151),符号链接的别名共享同一个观察条目:走 A 路径读过,走 B 路径(软链)编辑也认账;反过来,一个路径上观察到版本,另一个路径上 stale 写会被发现。模块头注释明说了这个设计:

packages/fs/fs-local/src/index.ts1:5
1/**2 * Host-filesystem implementation of `ctx.fs`. Realpath-derived target identity makes aliases3 * share stale guards, and writes through a symlink update its target without replacing the link.4 * @module @deepseek-ai/dsh-fs-local5 */

如果两个策略插件都监听 intent 槽? 槽是 first-wins,按注册顺序,先注册的完整占住,后来的直接被短路。这是部署约定,不是事件系统强制的不变式(fs/src/index.ts:58 的文档和包 README 第 46 行都写了)。第二个版本策略监听者属于配置错误,不是功能。

如果 fs/observed 的监听器抛异常? emit 不 await、不守卫,抛出的监听器会把已成功的读或写结果替换成错误(fs/src/index.ts:68-76 文档;事件门笔记 129 行)。所以监听器契约是同步、纯副作用、永不抛,WeakMap.set 正好满足。

沙箱栅栏和事件闸门是正交的两层。 前者管「路径在不在允许区」(fs-sandbox 的 checkedTarget,read-only 直接拒,workspace-write 查 writableRoots,fs-sandbox/src/index.ts:126-148),后者管「读没读过、版本新不新」。两道门都可以挡下一次 write,错误码不同:FS_SANDBOX_DENIEDFS_STALE_VERSION/FS_NOT_OBSERVED,模型能直接区分「权限问题」和「版本问题」。执行顺序固定:write.ts:107 先解析本次沙箱模式,108 行 resolve,111 行过闸门,栅栏在 provider 内、锁外先跑。workspace-write 的检查用重新归一化的新鲜 target(fs-sandbox/src/index.ts:136),工具 resolve 之后被换掉的符号链接祖先也在检查范围内。两层都能单独卸载:栅栏随 provider 走,换回 dsh-fs-local 就没了;闸门随策略插件走,摘掉插件就没了,互不依赖。

这张图回答:沙箱栅栏与事件闸门两道门各拦什么、各自给什么错误码,模型为什么能区分权限问题和版本问题。

flowchart TB W[模型发起 write] --> P[沙箱栅栏 路径在不在允许区] P -->|拒绝| R1[FS_SANDBOX_DENIED 权限问题] P -->|放行| G[事件闸门 读没读过 版本新不新] G -->|未读过或版本过期| R2[FS_STALE_VERSION 或 FS_NOT_OBSERVED 版本问题] G -->|核对通过| D[原子落盘] D --> O[fs/observed 记录观察] R1 --> M[模型可区分 权限问题与版本问题] R2 --> M

横向对比

对比点:策略的表达形式。同一个问题,「模型写文件前,系统凭什么放行」,两个仓库给出两种答案。

dsh 的答案:新鲜度授权。放行的依据是这个 owner 对目标文件的观察状态和版本。表达单位是事件和 branded 版本令牌,策略插件里 writeIntent 三行决定(fs-observation-policy/src/index.ts:65-71),provider 锁内原子核对(fs-local/src/index.ts:178-188)。

claude-code 的答案:路径规则。放行的依据是路径与规则的匹配,规则写成 Edit(src/**)Read(.env) 这种 allow/deny/ask 条目。上游公开仓库(commit 45bdfa96)没有主产品源码,只有 CHANGELOG 行为记录,检索关键词:permissionallowlistdenylistEdit(Read(permission rulesFileWriteallowedDirectories。规则语法和语义只能从变更记录重建:

claude-code/CHANGELOG.md660:660
660- Fixed single-segment `dir/**` allow rules like `Edit(src/**)` auto-approving writes to nested `dir/` directories anywhere in the tree instead of only `<cwd>/dir`
claude-code/CHANGELOG.md1574:1574
1574- Fixed Windows permission rules never matching when spelled with backslashes (`~\`, `\\server\share`) or case-variant paths, and Read deny rules not hiding files from Glob/Grep results

路径规则有一个结构性的代价:规则匹配器长在工具层,每多一个能碰文件的工具,就得多一份接线。Glob/Grep 结果不遵守 Read deny 规则(1574 行),Bash 通过 $HOME 引用路径绕开 deny 规则(1557 行):

claude-code/CHANGELOG.md1557:1557
1557- Fixed deny rules on home-directory paths (e.g. `Read(~/Desktop/**)`) not blocking Bash commands that reference the path via `$HOME`

这正是「策略写在工具里则别的路径调用绕过」的事故现场:规则集合与工具枚举耦合,新增工具或新增路径别名形态,都要回去补规则。dsh 的事件闸门没有路径规则,也就不存在「规则没接到某条路径」这个洞,但同时它也给不了「允许写 src/** 但拒绝 ~/.ssh/**」这种表达,那个需求在 dsh 里要由 fs-sandbox 的模式栅栏或 tools/execute 权限插件去补。

沙箱配置的覆盖范围是第二个对比点。claude-code 的 sandbox 设置明确只作用于 Bash,说明文档直说不作用于文件工具:

claude-code/examples/settings/README.md27:27
27- The `sandbox` property only applies to the `Bash` tool; it does not apply to other tools (like Read, Write, WebSearch, WebFetch, MCPs), hooks, or internal commands

这不等于 claude-code 的 Read/Write 没有护栏,它的护栏在 permission 规则层,路径 allow/deny 规则覆盖所有文件工具,沙箱层只管 Bash 子进程,两层各管一段。dsh 的文件变更工具反而自带模式栅栏(fs-sandbox 的 read-only 和 workspace-write),对应物是三份:事件闸门管新鲜度,模式栅栏管路径范围,tools/execute 权限插件管审批,同样各管一段。接缝位置的差别更值得看:claude-code 的规则匹配器按工具枚举接线(推断,由 1574 行 Glob/Grep 不遵守 Read deny、1557 行 Bash 借 $HOME 绕开 deny 的行为记录反推),dsh 的闸门按事件接线。新增一个写路径工具时,前者要记得补规则,后者自动过闸,策略零改动。

还有第二个对照:写前先读这条规则本身,claude-code 也有,但它长在工具里,按模型版本放开:

claude-code/CHANGELOG.md364:364
364- Changed the Write tool so newer models can overwrite an existing file they haven't read this session, matching the Edit tool's rules; older models still require the read first

dsh 的同一条规则长在可摘除的插件里,工具代码不含这条规则,摘掉插件即回到无条件裸写。工具内嵌的好处是规则简单、无事件间接层,代价是策略变更必须发版改工具代码。dsh 内部也有同一决策两种位置的活样本:tool-str-replace-editorcreate 在工具层 stat 检查(tool-str-replace-editor/src/index.ts:249-251),write/edit 走事件闸门,前者有 check-then-act 窗口,后者没有。这一侧是 dsh 自己的真实行号,两侧代价对称。

互动演示设计

形态:决策沙盘,题目「一次 write 的放行之路」。一句话结论:一次写操作要过三道决策门,每道门都可插拔、可观察,模型到磁盘之间没有单点裁判。

舞台布局(流程图):

flowchart LR subgraph 操作台 S1[策略插件 装载或摘除] S2[是否先 read] S3[外部改动 开或关] S4[沙箱模式 三选一] end subgraph 闸门矩阵 G1[schema 校验] G2[事件闸门 观察状态] G3[provider 版本 CAS] G4[原子写] end 操作台 --> 闸门矩阵 闸门矩阵 --> T[逻辑轨迹面板]

左侧操作台四个开关,右侧闸门矩阵四行,每行三态:放行、拒绝加错误码、未参与。下方逻辑轨迹面板随步进滚动。

分六步,每步配字幕:

  1. 开局。模型调用 write 写一个从没读过的已存在文件,四道门逐一亮起。字幕:「一次 write,先过四道门」。
  2. schema 校验放行,闸门矩阵第一行亮绿。字幕:「参数齐全,第一道门通过」。
  3. 事件闸门查观察状态,发现 owner 从未读过,给 createIfAbsent,provider 锁内发现文件已存在,拒绝,错误码 FS_NOT_OBSERVED。字幕:「没读过就写,闸门直接拦下」。
  4. 拨动开关,让模型先 read 一次。轨迹面板显示 fs/observed 记下 present 版本,闸门改授 replaceIfVersion。字幕:「读过一次,版本入案,写被放行」。
  5. 拨动「外部改动」开关,文件版本变了。provider CAS 不匹配,拒绝,FS_STALE_VERSION,面板提示重读。字幕:「版本过期,CAS 说不,请重读」。
  6. 拨动「摘除策略插件」开关。事件闸门整行熄灭,next() 默认值生效,write 直接落盘。字幕:「策略是插件,摘掉它,工具依然完整」。

读者可操作:四个开关任意组合,看每一层决定放行还是拒绝、给什么错误码;沙盘右下角提供对照卡,同一动作在「有策略」和「无策略」下各跑一遍。逻辑轨迹面板伪代码,右侧真实行号:

text
[模型] write file_path content                   # 工具入参
[工具] parseWriteArgs 校验非空                    # tool-fs/src/write.ts:25-27
[工具] sandbox.resolvePolicy 解析本次模式          # tool-fs/src/write.ts:107
[工具] ctx.fs.resolve 路径归一化成 target          # tool-fs/src/write.ts:108
[磁盘] realpath 与祖先回溯生成 targetKey           # fs-local/src/fsio.ts:146-194
[闸门] fs/write-intent 查观察状态                 # fs-observation-policy/src/index.ts:65-71
[闸门] 未读过 → createIfAbsent                    # fs-observation-policy/src/index.ts:68-70
[provider] 锁内 probe 核对版本                    # fs-local/src/index.ts:172-183
[provider] 原子写并铸新版本                        # fs-local/src/index.ts:200-217
[工具] fs/observed 记录 present 版本              # tool-fs/src/write.ts:122

可迁移结论

  1. 策略与执行者解耦,值得抄。 最小形态:执行者发事件,策略监听事件返回决策,执行者带一个「没有监听者」的默认值。不需要 cordis,任何事件总线或回调都行。收益是策略可摘除、可换、可加第二个,执行者一行不改。默认值本身要想清楚,它定义了「无人决策时发生什么」:dsh 对 write 取无条件写、对 create 取只许新建,各自贴合工具语义。一次「先读后写」约束最小就是一句话的事:一个监听器加一个内存表。
  2. check-then-act 一律换成 CAS,语言无关。 检查版本和写入之间永远有窗口,把版本核对放进锁内临界区,策略层就只需要查自己的内存状态。最小形态:数据库乐观锁的 UPDATE ... WHERE version = ? 就是同一个答案。
  3. 路径归一化只放一层。 所有路径字符串处理收敛进 provider 的 resolve,工具和策略只见稳定身份。最小形态:一个 normalize(path) 函数,全项目只从它拿绝对路径。displayPath 和 targetKey 分开这个小细节值得抄:给人看的路径和给系统用的身份是两回事,用 realpath 做身份,符号链接别名自然共享同一份状态,省掉一张路径映射表。
  4. 事件闸门需要配套纪律,抄的时候要连代价一起抄。 单槽 first-wins、监听器必须同步纯副作用、决策事件和记录事件分开,这些约束缺一个就会出隐蔽故障。这个体量下想省事,直接在工具里写规则也不丢人,claude-code 就这么干的,代价是每条新路径都要记得补规则。还有一层正交值得抄:把「路径在不在允许区」和「版本新不新」拆成两层,各自有独立的装载开关。dsh 里前者是 provider 层的栅栏,后者是事件层的闸门,换 provider 只动前者,摘策略只动后者;claude-code 里路径规则在权限匹配器、写前先读在 Write/Edit 工具里,两处代码各自维护,新增调用路径时要分别检查两边有没有漏接线。
  5. 版本令牌这套(WeakMap 观察状态、CAS guard、present/absent 判别)对这个体量算过度设计吗? 对单用户 CLI 工具是。它服务的场景是「多写路径、可摘除策略、并发安全、未来换远程 backend」,都没有的话,一个「写前必须读」的标志位就够。

思考题

  1. 动手题。 打开 examples/acp-agent/cordis.yml,把 173 到 174 行的 fs-observation-policy 条目注释掉,重启会话,让模型对一个没读过的已存在文件执行 write。对比有策略时(报 FS_NOT_OBSERVED 或只能创建)和无策略时(直接覆盖)的行为。顺带试 edit:摘掉策略后编辑也不再要求先读,对应正文「摘掉插件即回到无条件裸写」。改回来再观察一次。
  2. 动手题。 同一文件里把 168 到 171 行的 dsh-fs-sandbox 换成 @deepseek-ai/dsh-fs-local,观察 read-only 沙箱模式下原本被拒的写现在直接成功,以及 write 工具的 schema 里沙箱相关字段消失。这验证的是「provider 无 fork 换实现」。
  3. 思考题。 需求是「禁止写 ~/.ssh/config」。dsh 的事件闸门能表达吗?为什么不能?应该放在哪一层?提示:fs-sandboxcheckedTargettools/execute 权限插件,先读 docs/subsystems/filesystem.md 第 242 行的闸门职责再答。
  4. 思考题。 为什么 fs/observed 必须同步、纯副作用、永不抛?如果它抛了异常,工具侧会发生什么?提示:tool-fs 在 write.ts:122 用裸 ctx.emit,读 fs/src/index.ts:68-76 的事件文档。