Skip to content

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

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. 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

  2. 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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions