DSH plugin 的 pack 脚本传空 --out 会删库?空值守卫与祖先链接绕行排查
如果 release 脚本的 --out 拿到一个空字符串,它不是「什么都没输出」,而是会把整个仓库 checkout 递归删掉——包括你所有未提交的改动。 根因是 scripts/release/pack.ts 解析 --out 之后没有空值/根路径守卫,而 path.resolve(root, '') 的结果与 path.resolve(root, '.') 相同,都是 root 本身;紧接着的 rmSync(destination, { recursive: true, force: true }) 于是把仓库根当成了待清理的输出目录(#457)。
DSH plugin 事故现象:一条命令递归删除整个仓库
这个 bug 的严重性不在概率,而在后果——它不可逆,而且第二条触发路径完全不需要人为失误。 具体表现:
- 危险代码只有三行:
scripts/release/pack.ts:47-51(基于 master47f943859):
const destination = resolve(root, values.out ?? DEFAULT_OUTPUT)
// ...
rmSync(destination, { recursive: true, force: true })
mkdirSync(destination, { recursive: true })
(#457)
2. 触发方式一是手误:直接传空字符串即可复现——tsx scripts/release/pack.ts --family dsh --out '',效果是 rmSync(仓库根),递归删除整个 checkout,包括未提交改动(#457)。
3. 触发方式二更危险:CI 里的空环境变量。--out \"$RELEASE_DIR\" 在 RELEASE_DIR 为空时同样触发。这一点值得特别标出——审查者会本能在「有人写了 --out ''」上找原因,而现实中更常见的是引号里那个变量恰好为空,命令文本看起来完全正常(#457)。
4. 影响是不可逆的数据丢失:本地未提交改动、CI 工作区都会消失。触发前提是操作员失误或空环境变量,但release 脚本恰恰是最容易出现这类输入的地方——这也是为什么它被判定为高严重度(#457)。
5. 它是被系统性审查找出来的:该问题由多轮 AI 辅助代码审查发现,并经两个独立外部模型(均高推理档)交叉读码复核,三方一致确认。这一点有参考价值:这类「守卫漏了一处」的缺陷,靠人肉读全量脚本很难发现,而对照同类脚本却很显眼(#457)。
DSH plugin 机制:path.resolve(root, '') 归一到根,而 rmSync 之前没有守卫
两件各自「看起来合理」的事叠在一起,就成了删库:默认值兜底只认 undefined,而空字符串会被路径归一化到根。 逐层拆解:
- 空值穿透默认值:
values.out ?? DEFAULT_OUTPUT里的??只对undefined(与null)生效;parseArgs接受--out '',所以空字符串会原样穿过,destination拿到的是空串而不是默认输出目录(#457)。 path.resolve的归一化把空串变成根:path.resolve(root, '')与path.resolve(root, '.')返回值相同,即root本身。于是「空输入」在路径语义上等价于「仓库根」,而不是「当前目录下的默认输出」(#457)。- 删除操作没有前置断言:
rmSync(destination, { recursive: true, force: true })直接执行,force: true还让「不存在」之类的情况不再报错。没有一行代码问过「这个目标是不是仓库根或它的祖先」,所以危险输入畅通无阻(#457)。 - 同类脚本其实都有守卫,只有这里漏了:
scripts/clean.ts在删除前断言目标是后代路径,并解析祖先符号链接以防逃逸;scripts/release/verify-built-package-invariants.mjs则只删除自己mkdtemp出来的目录。也就是说,仓库里已经有两种正确的守卫范式,pack.ts只是没有采用(#457)。 - 最小修法能拦住最常见的情形:把默认值兜底改成
values.out?.trim() || DEFAULT_OUTPUT,或是在rmSync之前校验解析后的路径位于已知的输出根之内。前者足以拦住空串与纯空白,但拦不住--out .、--out ..和下面说的符号链接绕行(#457)。
DSH plugin 修复:祖先守卫、canonical deletion target 与回归测试形态
社区已经给出了可直接 cherry-pick 的补丁,而且它比最小修法更完整——它处理了「路径看起来在仓库内、物理上却指回仓库根」这一类绕行。 具体做法:
- 第一版:拒绝任何解析到仓库根或其祖先的输出路径。分支
yha9806/deepseek-harness @ codex/fix-release-pack-out-guard,commite18f94a4。守卫用containsPath(destination, root)这样的包含检查,因此覆盖空字符串、.、..以及文件系统根目录,而正常的子目录与同级输出仍然可用(#457)。 - 回归测试的形态值得照抄:不要 mock
rmSync。它在mkdtemp创建的一次性 fixture 里执行真实的 TypeScript release 脚本——--out ''与--out ..在修复前会真的删掉 fixture 里的 sentinel 文件,修复后则 fail fast 并保留 sentinel。focused test 2/2 通过、pre-push typecheck 通过。用真实删除行为做断言,才能防止「守卫写在错误的分支上」这类假绿(#457)。 - 评审发现的缺口:符号链接。第一版守卫不解析符号链接,所以一个指向仓库之外的
--out符号链接仍会通过。后续讨论把这从「可选加固」升级为真实漏洞(#457)。 - 两处文件系统语义差异必须分清:① 在实测的 POSIX 运行时上,最终
--out项本身是符号链接时,递归rmSync会 unlink 该项并保留其目标(Windows junction 行为不同);② 但中间祖先是符号链接或 junction 时,rmSync会穿过它。所以危险不等于「最终项是链接」,而是「链接在中间的祖先上」(#457)。 - 已经复现出最坏情况绕过:在一个一次性的
repository里创建parent-link -> container,然后执行--out parent-link/repository——该路径词法上位于 checkout 之下,物理上却解析回 checkout 根。在后续修复之前,真实 release 脚本确实删掉了 repository 的 sentinel(#457)。 - 后续提交的守卫形态:commit
b8f2957f。守卫现在同时检查词法包含关系与由「最近的已存在目标父目录」推导出的规范删除目标(canonical deletion target),并且刻意不跟随最终路径项。这样既保留了「输出目录尚不存在」与「最终项为链接时只 unlink」的既有语义,又能拒绝把删除路由到仓库里的祖先链接/junction 路径(#457)。 - 验证清单:TDD 先红后绿——新增的真实脚本回归在后续修复前确实删掉了 sentinel;3 个 focused release-pack 安全测试全部通过;向一个此前不存在的
dist/npm输出执行真实打包成功;文档同步 28/28 通过;完整 lint 与 pre-push build/typecheck 通过。此外该模式还被收进docs/defensive-patterns.md,成为可复用范式(#457)。 - 诚实的边界声明(值得一并记住):这提供的是针对静态误配置的 fail-fast 保护。同用户的恶意进程仍可在校验之后替换某个祖先,所以它不是「无竞态的」文件系统安全边界。把边界说清楚,比声称「已彻底修复」更有价值(#457)。
- 给插件作者的启示:这是每个会执行删除/覆盖的脚本都该抄的一课——任何进入
rmSync/rm -rf的路径,都必须先证明它是「允许的输出后代」,而不是反过来相信输入。你在 DSH Plugin Hub 上发布带构建/打包步骤的 DSH插件、或者给 DeepSeek插件 写清理脚本时,如果脚本会清理输出目录,请照这里的模式做:词法包含 + 规范目标双重校验、不跟随最终项、并用真实删除行为的测试锁住它(#457)。
DSH plugin 排查注意事项
先记住这不是「概率很低的误操作」——它不可逆,而且第二条触发路径(CI 里的空环境变量)完全不需要人为失误,所以守卫必须写在脚本里,而不是靠操作纪律。 八条要点:
- 空串会穿过
??:默认值兜底只认undefined,不认''。 - 空环境变量同样致命:
--out \"$RELEASE_DIR\"在变量为空时等价于传空串。 path.resolve(root, '')等于根:空输入在路径语义上就是仓库根。- 最小修法不够:
?.trim() || DEFAULT拦不住.、..与符号链接绕行。 - 危险点在中间祖先:最终项是链接通常只 unlink,中间祖先若是链接才会被穿过。
- 别 mock 删除:用真实脚本 + mkdtemp fixture 断言 sentinel 存活,才能避免假绿。
- 同类脚本已有范式:clean.ts 的「后代断言 + 祖先链接解析」可直接对齐。
- 它不是安全边界:只防静态误配置,不防校验后替换祖先的同用户进程。

常见问题
DeepSeek Harness 的 pack 脚本确实会删库,而且不可逆。触发条件有两个:① 操作员手误传入 --out '';② **CI 里 --out "$RELEASE_DIR" 而 RELEASE_DIR 是空环境变量**。第二种尤其危险,因为没人会去审一条看起来正常的 --out 传参。一旦触发,rmSync(destination, { recursive: true, force: true }) 会**递归删除整个 checkout,包括未提交的改动**(来源:Discussion #457)。
DSH plugin 里空字符串删到仓库根,是因为 path.resolve(root, '') 的返回值与 path.resolve(root, '.') **相同**,也就是 root 本身。而 --out 的默认值只在 undefined 时生效——parseArgs 接受 --out '',空字符串会**原样穿过** values.out ?? DEFAULT_OUTPUT。于是 destination 就是仓库根,紧接着的 rmSync 把它递归删掉(来源:Discussion #457)。
在 DeepSeek Harness 仓库里,两个同类脚本都有守卫,唯独 pack.ts 缺失:scripts/clean.ts 在删除前**断言目标为后代路径**,并解析祖先符号链接以防逃逸;scripts/release/verify-built-package-invariants.mjs 则**只删除自己 mkdtemp 出来的目录**。所以这不是「团队不知道怎么做」,而是这一处的守卫漏了(来源:Discussion #457)。
DSH plugin 里同样适用这类修复,但只加这一行不够:那只能拦住空串与纯空白,--out .、--out .. 以及**祖先符号链接**仍然可以绕过。社区补丁走的是更强的「祖先守卫」:先做词法包含检查,再把「最近的已存在父目录」realpath 成规范路径后推导真实删除目标,从而同时覆盖空串、点号、双点号、文件系统根,以及「路径看起来在仓库内、物理上却指回仓库根」的符号链接/junction 绕行(来源:Discussion #457)。
相关术语
- empty-value pass-through(空值穿透)
- empty-value pass-through 是参数校验只用 ?? 兜默认值时,只有 undefined 会命中默认值;空字符串 '' 会原样传下去。配合 path.resolve(root, '') === root 的归一化语义,就形成了「合法输入直达危险目标」的组合。— https://github.com/deepseek-ai/deepseek-harness/discussions/457
- ancestor symlink detour(祖先符号链接绕行)
- ancestor symlink detour 是最终路径项本身是符号链接时,递归删除通常只 unlink 该项并保留目标;但中间祖先若是符号链接或 junction,删除就会穿过它。于是词法上位于仓库内的路径,物理上可能指向仓库根。— https://github.com/deepseek-ai/deepseek-harness/discussions/457
- canonical deletion target
- canonical deletion target 是对「最近的已存在父目录」做 realpath(跟随已存在组件中的符号链接),再拼接尚不存在的后缀与最终路径项、且刻意不跟随最终项,从而得到真实删除目标;既保留「缺失输出目录」与「最终项为链接时只 unlink」的语义,又拒绝祖先链接绕行。— https://github.com/deepseek-ai/deepseek-harness/discussions/457
来源
- deepseek-harness Discussion #457:scripts/release/pack.ts 的 --out 传空字符串会递归删除整个仓库目录· deepseek-ai(GitHub Discussions)
- 补丁分支 yha9806/deepseek-harness @ codex/fix-release-pack-out-guard(commit e18f94a4 + b8f2957f)· GitHub(yha9806)