一个 !!js 表达式把文件系统工具全禁掉了:DeepSeek Harness 0002 号复盘
先说清楚背景:DeepSeek Harness 仓库的 README 第 9 行起有一节叫「Developer preview」,第 11 行原文写着它正在快速迭代,并用加粗大写强调「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」。所以下面提到的每个配置键、每个默认值、每个脚本名,都可能在下一个版本里换掉。本文对应的是 0.1.0-rc.5 这个快照。
现象:工具卡片全失败,但测试是绿的
docs/postmortem/ 下有四份复盘(0001 到 0004),0002 号的标题是「Filesystem snapshot tools were permanently disabled」,状态标 resolved。它描述的现象很具体:七个文件系统场景,加上一个混合工作区编辑场景,调用了注册表里根本不存在的工具。结构化会话日志里带的是 ToolNotFoundError,code 为 UNKNOWN_TOOL,stdout 那边渲染出来的是通用的失败工具卡片。
麻烦的地方在最后一句:快照套件是通过的。复盘原文的说法是,两侧输出都跟刷新后的 fixture 对上了,所以它证明的是「回归的确定性回放」,而不是文件系统行为的正确性。这句话值得单独抄下来贴在工位上——一套全绿的快照,证明的只是这次跑出来的东西跟上次一样。
一行 YAML 的求值位置
根因在 docs/postmortem/0002-js-expression-disabled-filesystem-tools.md 的 Root cause 一节。当时的写法是把 read、write、edit 这几个插件放在默认的 cordis.yml 里,给它们挂一个 disabled: !!js ... 表达式,意图是只在全权限启动和快照模式下把它们打开。
Cordis 的 Include 插件会把每个 !!js 标量解析成一个表达式对象。问题是当时 Loader 递归插值的只有插件的 config 字段,disabled 这类配置项元数据是直接读取的。复盘里点到了具体的两个方法名:Entry._resolveConfig() 对 config 做插值,而 Entry.disabled 直接测 entry.options.disabled,不经过插值。于是每个文件系统配置项拿到的都是一个对象——对象在 JavaScript 里恒为 truthy,disabled 判定成立,插件在所有模式下都不挂载。
再补一刀:YAML 标签本身语法合法,加载过程不产生任何诊断信息。你既不会看到报错,也不会看到警告,只会看到模型调 read 的时候说找不到这个工具。
复盘的 Impact 一节还写明了另一件事,值得单独说:实际运行的受限默认模式并没有因此获得意外的文件系统访问权。而且原文明写,草率地「直接把插值补上」反而会带来那个风险——因为权限预设在运行时更新的是 bash 沙箱和审批状态,它无法挂载、卸载或约束文件系统栈。也就是说,运行时的权限开关和组合期的插件挂载,管的根本不是同一件事。
现在仓库里的口径,和复盘写的不一样
这是我翻这份复盘时最想提醒的一点:复盘文档记录的是事故当时的语义,不是当前的语义。
复盘的 Guardrails 一节写的是,AGENTS.md 和 Cordis 入门文档「明确说明 !!js 仅在插件 config 内有效」。但当前快照里的 AGENTS.md 第 96 行原文是:cordis.yml 允许 !!js(never !js)用在插件 config 与配置项 disabled 上,其它元数据保持字面量。docs/cordis-primer.md 的「Loader Configuration」一节说得更细:Loader 对配置项的 config 插值(在声明的注入激活之后、针对该插件上下文求值),也对它的 disabled 字段插值(在每一次挂载决策时、针对 loader 上下文求值);Include 会保留嵌套的行表达式直到目标激活。
代码这边也是同一口径。scripts/verify-cordis-config.ts 里有个数组 metadataFields,一共六个字段:id、name、group、inject、intercept、isolate。这六个字段里出现任何表达式节点,都会被报成 !!js is not interpolated here。而 disabled 被单独拎出来处理,metadataExpressionErrors 上方的注释原话是:disabled 是唯一被插值的元数据字段。
两处白纸黑字放在一起就是:复盘说「只有 config」,当前的 AGENTS.md、primer 和守卫脚本说「config 加 disabled」。我不去推断中间发生了什么,只提醒你——照着复盘的结论去写配置,可能会写出一个当前版本里合法、但你以为不合法的东西;反过来照着 primer 写,也别指望复盘文档会同步。以你实读的仓库源码为准。
同样的差异还有第二处。复盘说文件系统场景启动的 fs.cordis.yml 是「一个显式的固定全权限 overlay,配有对应的回放配置和独立的 request-header 类」。而当前 examples/acp-agent/fs.cordis.yml 开头的注释写的是:沙箱化的文件系统栈已经在基础 cordis.yml 里了,所以这个 overlay 只加了那些场景要用的本地 tool-result spill 存储。examples/acp-agent/tests/acp.snapshot.ts 里对应的注释也写着这些场景「共享默认的 header 类」。在这个快照里,FS_CONFIG 这个常量一共出现三次:一次定义,两次被场景引用(parallel-tool-calls 和 bash-spill)。说完,不延伸。
加上去的两道守卫,长什么样
第一道是静态配置守卫,就是上面那个 scripts/verify-cordis-config.ts。除了拦六个元数据字段里的表达式,它对 disabled 还做了一件事:如果 disabled 本身是表达式,就用 new Function 只做一次编译(注释里明写构造器不会执行函数体),语法错就报 disabled expression does not parse。理由写在函数注释里:Loader 会在每一次挂载决策时求这个表达式,语法错会让启动失败,所以把这个失败提前到最早能解析的时刻。另外,如果 disabled 不是表达式而是个嵌套结构,那么藏在它下面的表达式一样不会被求值,守卫照样拒。
第二道是快照结果守卫。packages/test-support/acp-snapshot/src/suite.ts 里导出了一个 unknownToolCallIds(),它扫会话 JSONL,只挑 type 为 tool/result 的记录,看 data.error.code 是不是 UNKNOWN_TOOL,是就把 callId 收集出来。这个函数在两个地方被断言成空数组:一处在新鲜运行的会话日志上,失败信息是「snapshot scenarios must not accept UNKNOWN_TOOL」;另一处扫的是已提交的 fixture 文件,失败信息是「contains UNKNOWN_TOOL」。函数注释里那句话说得很直白:快照刷新不能把一次缺失的注册变成被接受的行为。
如果你也撞上「工具找不到」,怎么分诊
按源码语义能给出的判定动作有几条,都不需要装东西:
第一,在你自己的 Cordis 配置里把 !!js 全部找出来,逐个看它落在哪个键下面。按当前 AGENTS.md 与 primer 的口径,只有插件 config 和配置项 disabled 这两处会被求值,落在别处就是一坨 truthy 的数据。
第二,跑仓库自带的 verify-cordis-config,看它报不报 !!js is not interpolated here。
第三,去会话日志里筛 tool/result 记录,看 error.code 是不是 UNKNOWN_TOOL——这正是上面那个守卫函数的判据,你可以照着同一个字段路径自己筛。
处置之后怎么确认修好了:工具注册与否是可以从组合后的工具 schema 上看出来的,复盘里也点了这件事的坑——header pin 验证的是组合后的工具 schema,但当时文件系统场景共享的是默认组合的 pin,所以并没有独立证明它们所需的工具已经注册。所以验证的关键不是「日志变干净了」,而是「有一条独立于预期输出的断言在管这件事」。
最后一步别省:什么情况说明不是这个原因。
如果工具其实注册上了、只是被拒了,报出来的不是 UNKNOWN_TOOL。packages/fs/fs-sandbox/src/index.ts 的模块注释写明,每次调用的策略是:read-only 拒绝一切写操作;workspace-write 只在目标规范化后落在策略的 workspace 根或平台临时区里才放行;danger-full-access 不加围栏直接下放。被拒时抛的是结构化的 FS_SANDBOX_DENIED。这跟找不到工具是两条完全不同的分诊路径。
还有一种也会报 UNKNOWN_TOOL、但跟 disabled 无关:packages/fs/tool-fs/src/index.ts 里,read_image 是组合条件式注册的——它包在 ctx.inject(['attachments'], ...) 里,注释写明没有挂载 attachment 存储的部署压根不会注册这个工具,执行体里还留了一次防御性复查。read、write、edit 则是无条件注册的。
顺带把 tool-fs 的四个默认值也留在这儿,都在源码里能查到:readLimit 默认 2000(单次 read 返回的行数上限),readMaxLineLength 默认 2000 字符,readMaxBytes 默认 50 × 1024,readStreamMinSize 默认 10 × 1024 × 1024——到这个大小及以上的文件走流式,小的整个读进内存。这四个值 apply() 里都会过一遍 assertPositiveInteger,不是正整数就直接抛。这些是配置默认值,不是对你实际跑起来会怎样的承诺。
一句不该被跳过的提醒
fs-sandbox 的模块注释自己就把话讲死了:那道围栏是受信任代码里针对模型可控路径的策略检查,不是内核边界;对不受信任代码的内核级隔离仍然是 ctx.shell 的活(@deepseek-ai/dsh-bash-sandbox)。注释里还明说残留的 TOCTOU(祖先符号链接在容纳复查与系统调用之间被换掉)只是被「立即在下放前重新规范化」收窄,并在这个威胁模型下被接受。所以别把「配了 fs-sandbox」当成安全结论。
复盘最后三条教训我原样转述:语法上被接受的配置值,不一定在那个位置被求值,应当记录并验证到底对哪些字段插值;快照刷新是 fixture 的生产过程,不是正确性审查,像「已注册工具缺失」这种语义上不可能的结果需要独立于预期输出的断言;权限控制只应描述其实际管辖的能力。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。