fix(pilot): 选择器的「是不是数字」改成枚举,不受 locale 影响 - #50
Conversation
PR#47 approve 时评审顺手挑出来的一条 Low(Codex 发现)。
`[!0-9]` 是【区间】,哪些字符算"在 0 到 9 之间"由 LC_COLLATE 决定。
在 fa_IR / ar_SA 下,东阿拉伯数字 ٥(U+0665) 和 ۵(U+06F5) 落在区间内,
于是被当成"纯数字 PR 号"接受。
实测(合入 main 的那版):
LC_ALL=fa_IR … merge-pr "٥" --integration main
→ 穿过选择器闸门,死在下一关的 trunk 检查
改成枚举 `[!0123456789]` 之后,C / en_US.UTF-8 / fa_IR / fa_IR.UTF-8 /
ar_SA.UTF-8 五种 locale 全部拒绝。
定界(所以只是 Low):今天没有可达利用 —— 那两个字符下一关就死在
`gh pr view` 上,而字母、`/`、`:`、`.` 从来不在被撑大的区间里,所以
URL / 分支名 / refs/heads/x 那几条拒绝在任何 locale 下都成立。但这是
一条安全 rail 的第一道闸门,它对"什么是数字"的判断不该随 locale 变。
approvals 那处的同样写法一并改掉。它由 python print 一个 JSON 整数
喂进来、实际只会是 ASCII,改它是为了让这个文件只有【一种】判断
"是不是数字"的写法,而不是一处防得住 locale、一处防不住。
回归:数字 47 正常放行、URL / 分支名照拒。
Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
两处冲突,都是 #47 合入 main 造成的: - git-guard.sh:整个文件取 main 的版本。白名单已经归 #47(以及 #50 的 locale 修复)管,这个分支不该再碰它 —— 本 PR 只做 squash 清理。 - followups.md:FU-1 取 main 的 done=PR#47(白名单实际是 #47 合的, 本分支上标 done=PR#45 是拆分前的旧状态);FU-2/3/4 取本分支的完成 标记;FU-7 及其撤回、FU-8 保留;FU-9 追加在末尾。十个条目一个没少。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
clestons
left a comment
There was a problem hiding this comment.
✅ APPROVE — [3-round, Codex 按 post-R2 闸门跳过(全 Low)]
这条改对了,而且拆成独立 PR 的判断也对(#45 现在只动 safe-cleanup.sh,把它塞进去就是再一次「一个 PR 装多件事」)。
实测(/bin/bash 3.2.57 arm64-apple-darwin25,merge-pr <sel> --integration zzz-nonexistent):
| 选择器 | locale | 结果 |
|---|---|---|
47 |
C / fa_IR.UTF-8 | 当成数字接受,进到 base 检查 ✅ |
٥ (U+0665) / ۵ (U+06F5) |
fa_IR.UTF-8 | 在选择器闸门被拒 ✅(此前会穿过去) |
https://…/pull/26、main |
fa_IR.UTF-8 | 仍然被拒 ✅ |
007、0 |
C | 仍当数字接受,交给 gh 判 ✅ |
+5、5.0、a/b、空串、含换行 |
— | 仍然被拒 ✅ |
对 ASCII 输入是严格的 no-op,只在非 ASCII 数字上收紧 —— 这正是想要的形状。第二处(approvals 解析)改动确实是惰性的(python print() 出来的 JSON 整数必是 ASCII),而注释自己老实写了这一点,没有把它包装成一个修复,这点值得表扬。
关于 DeepSeek 提的 [[:digit:]]:那是个更糟的写法,不要采纳
R1 建议改用 POSIX 字符类。实测 6 个 locale × 5 个字符(٥ ۵ ٠ १ 5)共 30 格:
*[!0123456789]*(本 PR) |
*[![:digit:]]*(R1 的建议) |
|
|---|---|---|
| C | 全拒 ✅ | 全拒 |
| en_US.UTF-8 | 全拒 ✅ | 全部接受 |
| fa_IR / fa_IR.UTF-8 | 全拒 ✅ | 全部接受 |
| ar_SA.UTF-8 / hi_IN.UTF-8 / ja_JP.UTF-8 | 全拒 ✅ | 全部接受 |
[[:digit:]] 会把「数字」扩成全部 Unicode Nd,连默认的 en_US.UTF-8 都中招 —— 而旧的区间写法至少还要求 locale 匹配才出问题。所以采纳 R1 会让这个洞在一台默认配置的开发机上就可达。枚举是正确选择。
同一类构造在别处还活着,而且那边是 fail-OPEN
[Low] check-docs.sh:42 —— 同样的 *[!0-9]*,但那里失败方向是反的
在一个七份规划文档各只有一个字符的仓库上实测:
LC_ALL=C PILOT_DOC_MIN_BYTES=٥ → rc=2 正确拒绝(must be a non-negative integer)
LC_ALL=fa_IR.UTF-8 PILOT_DOC_MIN_BYTES=٥ → rc=0 ok=7/7 "ready — safe to run unattended"
(伴随 [: ٥: integer expression expected × 两处)
LC_ALL=C (默认 MIN_BYTES) → rc=1 NOT ready, ok=0/7
set -uo pipefail(没有 -e)让出错的 [ ] 条件读成 false,于是每份文档都掉进 ok 分支 —— 那道 fail-CLOSED 的无人值守起跑门禁 fail-OPEN 了,在一个根本没有规划的仓库上放行。这比本 PR 关掉的那个洞后果更重。
而且它不限于波斯语/阿拉伯语环境:ja_JP.UTF-8 + PILOT_DOC_MIN_BYTES=5(U+FF15) 同样 rc=0 ok=7/7 —— 一个主流开发 locale。
同文件的 :96(case "$bytes" in ''|*[!0-9]*))是同一构造的第二处,今天不可达($bytes 来自 wc -c),但按本 PR 自己的一致性论证(第二处 approvals 那个惰性站点都改了),它也该一起改。
[Low] pr-monitor.sh:61,75,78 —— 同样的构造。PILOT_VERDICT_MAX_MIN=٥ 在受影响 locale 下能穿过校验,随后 deadline=$(( $(date +%s) + max_min * 60 )) 是算术语法错误、deadline 未绑定,set -u 直接中止那个通宵等裁决的循环 —— 正是 :51-57 注释里说这条校验要防的那类死等。
建议把
check-docs.sh那条放进 #49(那个 PR 本来就在改这个文件),而不是把本 PR 撑大。fail-OPEN 的门禁是更高价值的修复,值得在那边单独写一段复现。
[Low] git-guard.sh:174-180 的新注释 —— 它列的字符和 locale 是当时采样到的,不是受影响集合。我自己的矩阵里,旧的 [!0-9] 还会在 fa_IR/ar_SA 下接受 ٠(U+0660)、在 hi_IN.UTF-8 下接受 १(U+0967)、在 ja_JP.UTF-8 下接受 5(U+FF15)。论证本身不依赖这份清单穷尽,但现在读起来像是「就这两个字符的小毛病」。建议加个「例如」或干脆不列。
顺带确认:注释里「nothing was exploitable」这个结论是成立的,我跨 9 个 locale 复核过 —— 字母、/、:、.、+、-、换行从未进入过那个区间,所以 URL 和分支名的拒绝在每个 locale 下都守住了;被放进去的全都是孤立的非 ASCII 数字,下一关 gh pr view 就死。
建议
- 三个文件现在需要同一个判据了,考虑抽一个
is_uint()放进共享文件。四份手抄的 glob 就是下一次漂移的来源 —— 这个仓库前面几轮反复踩的正是这个形状。 - 独立复核:
bash -n干净;接受/拒绝集合与改动前在 ASCII 上完全一致。
PK Review v4 · 3 轮(R3 Codex 按 post-R2 严重度闸门跳过 —— R2 全部为 Low):R1a DeepSeek-v4-flash 1 条 Low(建议 [[:digit:]],前提为假且修法更糟,30 格 locale 矩阵实测驳回)/ R1b 无发现 → R2 Opus 独立战略评审(自跑 9 locale 矩阵,驳回 R1a 并挖出 check-docs.sh 同构造 fail-OPEN、pr-monitor.sh 三处)→ R4 Opus 全量裁决(把 check-docs 的复现扩到 ja_JP.UTF-8 主流 locale,另补同文件 :96 第二处站点)。机械证据:6 locale × 5 字符共 30 格枚举 vs 字符类对照;merge-pr 在 C/fa_IR 下对数字/东阿拉伯数字/URL/分支名的拒绝矩阵;check-docs.sh 在 C(rc=2) / fa_IR(rc=0 ok=7/7) / ja_JP(rc=0 ok=7/7) 三档的端到端复现;followups.sh 的 POSIX ERE 经实测不受影响(说明本 PR 范围既不窄也不宽)。
PR#47 approve 时评审顺手挑出来的一条 Low(Codex 发现的)。它自己说「走 #45,本 PR 不要再加东西」——
但 #45 现在只改
safe-cleanup.sh,把git-guard.sh的改动塞进去就是再一次「一个 PR 装多件事」,所以单独开这个。
问题
[!0-9]是区间,哪些字符算「在 0 到 9 之间」由LC_COLLATE决定。在fa_IR/ar_SA下,东阿拉伯数字
٥(U+0665) 和۵(U+06F5) 落在区间里,于是被当成「纯数字 PR 号」接受。实测(已合入 main 的那一版):
修法
枚举,不用区间:
*[!0123456789]*。五种 locale 全部拒绝:approvals那处的同样写法一并改掉。它由 pythonprint()一个 JSON 整数喂进来、实际只会是 ASCII,改它是为了让这个文件只有一种判断「是不是数字」的写法,而不是一处防得住 locale、一处防不住。
定界
今天没有可达利用,所以是 Low:那两个字符下一关就死在
gh pr view(cannot read PR #٥ base branch),而字母 /
//:/.从来不在被撑大的区间里,所以 URL、分支名、refs/heads/x那几条拒绝在任何locale 下都成立 —— 上一轮那条 High 没有回归。但这是一条安全 rail 的第一道闸门,它对「什么是数字」
的判断不该随 locale 变。
回归
数字
47正常放行(走到 base 检查)、URL 和分支名照拒。preflight.sh run4/4(grade A)。https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk