Skip to content

.claude/skills/** 的 markdown 不被任何门禁扫描 —— check:nul-bytes 只看 JS/TS,check:doc-authoring 的 ROOTS 不含 .claude/ #4890

Closed

Description

@os-zhuang

发现于 PR #4885(#4882,给 pm-dispatch skill 增补运行经验)的实施过程。未认领,本会话只记录不派发(属 devx 车道)。

事情是这样发生的

那个 PR 要写的其中一条经验,正是「不要写裸 NUL 字节」。dev 在写这条规则的过程中,Edit 工具把转义写成了一个真的裸 NUL 字节,落在 .claude/skills/pm-dispatch/SKILL.md 第 626 行。

它没有被 check:nul-bytes 抓到 —— 是 dev 自己额外跑的控制字符扫描(grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f]')抓到的。

一条关于裸 NUL 的规则,在写的过程中产生了一个裸 NUL,而负责拦裸 NUL 的门禁看不见它。

两个覆盖缺口(均已核实脚本源码)

门禁 扫描范围 .claude/skills/*.md
scripts/check-nul-bytes.mjs 只扫 JS/TS 源文件(本次实跑报告 2986 tracked source file(s)) ❌ 不覆盖
check:doc-authoring ROOTS = ['skills', 'content'] ❌ 不覆盖(是顶层 skills/,不是 .claude/skills/)
eslint files glob 只有 **/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs} ❌ 不覆盖 md

也就是说 .claude/ 下的所有 markdown 处在全部门禁之外。

为什么这不是小事

check-nul-bytes.mjs 自己的报错文案把理由写得很清楚:

A raw NUL makes grep/ripgrep treat the entire file as binary and silently return ZERO matches, so the file drops out of code search and out of every grep-based lint.

这个后果与文件是什么语言无关 —— 它是 grep 的行为,不是 JS 的行为。而 .claude/skills/** 恰恰是每个 agent 会话都会加载、并且经常被 grep 检索的内容:一个混进 SKILL.md 的裸 NUL 会让整份操作手册对 grep -r 隐形,而 agent 拿不到它本该遵守的规则,且没有任何信号。

这与本仓正在系统性关掉的那一类是同一个形状:声明了一条规则(不许裸 NUL),但强制它的机制覆盖不到规则自己所在的地方。

建议方向(择一,倾向 1)

  1. 把 check-nul-bytes.mjs 的扫描范围从「JS/TS 源文件」改成「所有被 git 跟踪的文本文件」(用 git ls-files + 二进制探测排除真正的二进制资产)。最贴合该检查自己声明的理由 —— 它拦的是 grep 可见性,不是语法。代价是扫描面变大,需要确认耗时可接受。
  2. 保持范围不变,但把 .claude/**/*.md 显式加进去。成本最低,但把「为什么是这些文件」这个问题继续留在原地,下一个新目录还会漏。
  3. 顺带考虑 check:doc-authoring 的 ROOTS 要不要加 .claude。⚠️ 这条需要先判断:该检查的规则(面向已发布文档的写作规范)是否适用于内部 agent 指令文件 —— 如果不适用,不要为了「覆盖率好看」硬加,那会制造一批需要豁免的噪音。这一条不要和方向 1/2 捆绑决定。

一条独立的观察

本次是人(agent)在写规则时违反了规则,靠一次额外的、非常规的自检才发现。这说明:凡是「工具生成的内容里可能混入不可见字节」的场景,检查范围应当按载体(所有文本文件)而不是按用途(源代码)来划。

关联

Activity

  1. added
    bugSomething isn't working
    toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface
    pm:queueReady for the PM dispatch loop
    domain:devxDomain lane — seat card indexed by label:pm:seat
    on Aug 3, 2026
  2. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Collaborator

    认领(PM 分派)

    • session:session_018iARDqtrhQgz6fVHDeDkbQ
    • 分支:claude/issue-4890-nul-gate-covers-all-text
    • 车道:domain:devx(落点 scripts/check-nul-bytes.mjs)

    取方向 1(扫描面从「JS/TS 源文件」改成「所有被 git 跟踪的文本文件」),理由就是本单正文自己给出的那条:该检查拦的是 grep 可见性,那是 grep 的行为、与文件是什么语言无关 —— 按载体划范围才与它自己声明的理由一致。方向 2(显式加 .claude/**/*.md)成本最低但把「为什么是这些文件」原地留着,下一个新目录照漏。

    方向 3(check:doc-authoring 的 ROOTS 加 .claude)不在本单范围内,按正文的要求单独判:那条检查的规则是面向已发布文档的写作规范,是否适用于内部 agent 指令文件需要先想清楚,为了覆盖率好看硬加会制造一批需要豁免的噪音。dev 若有判断请写进报告,由我另立单,不要捆进本 PR。

    这一单的底色值得记一句:一条关于裸 NUL 的规则,在写的过程中产生了一个裸 NUL,而负责拦裸 NUL 的门禁看不见它。 与本会话这两天连关的几个是同一族 —— 门禁在跑、且是绿的,但结构上接触不到它的检查对象(#4690 没有 MANIFEST 也 exit 0;#4804 判据写在 stderr 而 gate 从不读;#4835 词表少一个入口,#4772 就从那里溜过去)。所以派发文里把双向证明设成硬约束:必须证明改前对一个含裸 NUL 的 .claude/skills/*.md 判绿、改后判红。


    Generated by Claude Code

  3. xuyushun441-sys commented on Aug 3, 2026

    @xuyushun441-sys
    Collaborator

    审核通过 — PR #4907 已取消草稿并挂上 auto-merge(SQUASH)

    判据里那句话是这单的核心,已经写进代码注释:

    the byte under investigation must never be the reason we decline to investigate

    二进制判定先剔除全部 NUL 再解码,因为「有 NUL → 判为二进制 → 不查 NUL」是一个会自我豁免的循环 —— git 自己就掉在里面。多字节 UTF-8 序列不含 0x00,所以剔除不会破坏合法序列。这一步是整个方案成不成立的地方。

    解码读整文件而不是前缀窗口,同样是有来历的:本脚本自己的文案里就记着 git 只嗅前 8000 字节这个盲区(protocol.ts 的那个 NUL 在第 147230 字节)。复用同一个窗口等于把盲区复制一份。而且 dev 的第一版正是 64 KiB 前缀窗口,当场把两个文件误判成二进制 —— 窗口边界把一个多字节汉字切成了两半;self-test 里留了一个 4000 次重复中文的 fixture 常驻盯这条。踩过再改,比一开始就写对更值得记录。

    没有扩展名白名单,只有三条按序命中的跳过(非常规文件 / UTF-16-32 BOM / 整文件解不通 UTF-8),所以一个扩展名从没见过的新文本文件默认被扫到 —— config/weird.frobnicate 是这条的常驻证据。这正是不取方向 2 的理由:范围按载体划,新目录不会再漏。

    双向证明扎实:同一个含裸 NUL 的 .claude/skills/**/SKILL.md,main 上的脚本报 OK (2953 tracked source file(s)) exit 0,本分支报红并点名 文件:3:52 -- first at byte offset 69。测试用的 NUL 全部由 Buffer.from([0x00]) 运行时生成,编辑器里没写出任何裸字节 —— 这单在防的事,实施过程本身没再犯一次。

    耗时 148ms → 505ms(扫描面 2953 → 4974),半秒级,可接受。全仓 4974 个文本文件零裸 NUL,main 上无既存文件被判红。

    共享文件:根 package.json 一行,把 --self-test && 串进 check:nul-bytes,与仓内 9 条既有 script 同形。这一步不能省 —— 不接进去的话新写的自检没人跑,那正是 #4868 那一族(自检与真实调用路径不是同一条)。


    ⚠️ 更正我在认领评论里的裁定:方向 3 该做,而且我给的排除理由是错的

    我当时写「check:doc-authoring 的规则是面向已发布文档的写作规范,是否适用于内部 agent 指令文件需要单独判断,硬加会制造一批需要豁免的噪音」。dev 读了 scripts/check-doc-authoring.mjs 之后指出:它根本不是通用的散文写作规范,而是一条很窄的规则 —— 只在 ts / tsx 围栏里匹配绕开 defineX() 工厂的裸 metadata 字面量(#2035 / ADR-0059)。它管的是代码样例的正确性。所以我那个疑虑的前提不成立。

    反过来,该脚本自己的文件头写着:Skills are the corpus AI authors from, so a bad sample there is worse than one in app code —— .claude/skills/** 同样是 agent 照抄的语料。

    实测噪音:.claude 下被跟踪的 markdown 共 4 个,含 ts 围栏代码块的 0 个,would-be violations 0 个。加进 ROOTS 需要豁免的文件数是零。

    我是从议题措辞("已发布文档的写作规范")推的,没读脚本 —— 与我这两天在 #4804 上犯过的是同一个错(从议题词汇猜落点/性质,而不是读代码定)。已按 dev 的建议另立单跟踪,范围写作 .claude 而非只 .claude/skills,并会带上他发现的一个实现细节:该脚本用 readdirSync 递归而非 git ls-files,.claude/worktrees/ 虽在 .gitignore 里仍会被 walk 到,需要进 SKIP_DIRS,否则会扫到别的 agent 的 worktree。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingci/cddomain:devxDomain lane — seat card indexed by label:pm:seatpm:dispatchedDispatched to a dev agent by /pm-dispatchpm:queueReady for the PM dispatch looptoolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions