feat(pilot): FU-1 —— git-guard merge-pr 的 gh flag 改白名单(从 #45 拆出) - #47
Conversation
黑名单只能拒绝今天已知的危险 flag。gh pr merge 将来新增一个能绕过 分支保护的 flag,黑名单会静默放行、这里不会自动知道。改成白名单: 不认识的 flag 一律拒绝,要放行得有人明确加进去并写明理由 —— 这是 守卫应该失败的方向。 - 放行:--squash/--merge/--rebase/--auto,以及 --body/-b、--body-file/-F、 --subject/-t、--match-head-commit 的分离与粘连两种形式 - --admin/--repo/-R/--delete-branch 保留具名拒绝,只为让报错自己解释清楚 (白名单本来也会拒) - 多余位置参数一并拒绝:PR 号是唯一位置参数 - 顺带修一处注释:gh api 的错误 BODY 走 stdout,不需要 2>&1 —— 原注释 说反了,照着改会把 stderr 混进 JSON 让解析全线降级 从 PR#45 拆出。#45 那边只留 squash 清理一件事。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
fresh-build 检查会随日期漂移:statistics.json 的 averageTaskAge 按天算, 今天重建就与 main 里的不同。与本 PR 代码无关,但不提交 CI 会红。 这条漂移本身是个 followup,见 FU-7。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
clestons
left a comment
There was a problem hiding this comment.
❌ REQUEST_CHANGES — [4-round]
黑名单→白名单这个方向是对的,而且严格比它替换掉的东西更严:被删掉的每个黑名单条目仍然各自具名 case 着,后面还有 -* 兜底;多余位置参数从"直接透传"变成了拒绝;所有拒绝都在解析阶段触发,早于 command -v gh,不需要网络和认证。实测拒绝(rc=3):--admin、--author-email、-s、-m、--disable-auto、-bfoo、尾随位置参数、--body -R other/repo(-R 被当成 body 值吃掉,other/repo 再作为位置参数被拒)、merge-pr --squash 47。
但这道白名单管住了 flag,没管住它自己的第一个位置参数,而那个参数能做到 --repo 被拒绝时想做的事。
🔴 Blocking
[High] git-guard.sh:156 — n="${1:-}" 从不校验,而 gh pr merge 接受 [<number> | <url> | <branch>];URL 选择器会覆盖被钉住的 --repo
实测链条(全部只读验证,gh 2.92.0):
$ gh pr view https://github.com/jhfnetboy/NextStop/pull/26 --repo AAStarCommunity/Brood --json number,baseRefName,url
{"baseRefName":"main","number":26,"url":"https://github.com/jhfnetboy/NextStop/pull/26"}
--repo 被 URL 完全无视。再在 Brood 检出目录里直接跑本 PR head 的守卫:
$ bash git-guard.sh merge-pr "https://github.com/jhfnetboy/NextStop/pull/26" --integration zzz-nonexistent-branch
git-guard: BLOCKED: PR #https://github.com/jhfnetboy/NextStop/pull/26 base is 'main', not integration 'zzz-nonexistent-branch'
守卫在 Brood 里读到了 NextStop 的 PR。把 integration 换成真实值走一遍单主干路径:
| 步骤 | 用的是哪个仓库 | 结果 |
|---|---|---|
repo="$(gh repo view …)" |
本地 Brood(完全不看 $n) |
AAStarCommunity/Brood |
trunk 判定 / gh api repos/$repo/branches/$integration/protection |
本地 Brood | Brood main 的保护规则 → 通过 |
gh pr view "$n" --repo "$repo" 读 reviewDecision |
URL 指向的外部仓库 | 外部 PR 的 APPROVED → 通过 |
| base / head 检查 | 外部仓库 | 通过 |
exec gh pr merge "$n" --repo "$repo" |
URL 指向的外部仓库 | 合的是外部 PR |
一道闸门都拦不住。Brood 的分支保护被当成证据,去合另一个仓库的 PR —— 这正是第 186 行拒绝 --repo/-R("it would bypass the safety rail")要防的事。而且本 PR 新加的那条位置参数报错信息自己写着"the PR number is the first argument",却从来没检查过它是不是个数字。
来源上确实是既有问题(n="${1:-}" 不是这个 PR 引入的),但本 PR 的全部立意就是"控制什么东西能到达 gh pr merge",这道白名单声称关掉的正是这一类;放在这里修最合适。
修复:在第 197 行那组检查旁边加一行——
case "$n" in ''|*[!0-9]*) die "refusing PR selector '$n' on merge-pr — pass a bare PR NUMBER; a URL or branch name overrides the pinned --repo and would target a different repository" ;; esac这一行顺带把下面 MISSED 里两条误导性报错也一并修了。
已确认
[Medium] git-guard.sh:178,234 — --auto 进了白名单,但 --allow-trunk 的证明只是调用瞬间的快照,且从不检查 dismiss_stale_reviews
--allow-trunk 分支只验 required_approving_review_count >= 1 与 reviewDecision == APPROVED,两者都是调用当刻的状态;--auto 把真正的合并推迟给 GitHub。在没开 dismiss_stale_reviews 的仓库上,agent 可以在两项检查通过之后继续 push 未经评审的 commit,auto-merge 随后把它们带进主干——正是这条 rail 存在的理由。Brood 自己 main 开了 dismiss_stale_reviews=true,所以在本仓库是潜伏的;但 git-guard.sh 是作为 pilot 插件分发到任意仓库的。第 234 行的注释专门解释了为什么不检查 enforce_admins,却对这个字段只字未提——而它恰恰在 --auto 被放行的那一刻变成承重的。
注意:删掉 --auto 并不能关掉这个窗口 —— gh 自己的 help 写明 merge queue 作为 base 时,不带 flag 也会启用 auto-merge。所以修法在保护检查那一侧:从已经抓到的 $prot JSON 里多解一个 dismiss_stale_reviews 并要求为 true(或在 --allow-trunk 下拒绝 --auto),同时把 234 行注释补上"这个字段为什么是承重的"。
| 严重度 | 位置 | 问题 |
|---|---|---|
| Low | git-guard.sh:197,298 |
文件头 usage 已改成 [allowlisted gh flags],但两处运行时报错里的 usage 仍写 [gh args]——用户真正看到的那句话还在承诺已经不存在的透传语义 |
| Low | git-guard.sh:178-182 |
gh 官方短形式 -s/-m/-r 被拒,而 -b/-F/-t 短形式放行,不一致。今天没有调用方会断(reference/review-contract.md:54 用的是 --squash),但脚本自己的注释就写着"不可用的守卫会被绕过去" |
| Low | docs/agent/followups.md FU-7 |
用"pilot 冻结期内不做"做延后理由,但仓库里 grep 不到任何地方声明了这个冻结期,而本 PR 自己就在改 plugins/pilot/ |
驳回(DeepSeek 的 5 条全部不成立)
- A1/B1/B2
--match-head-commit—— gh 2.92.0 help 原文:Commit SHA that the pull request head must match to allow merge。它是一个更严的前置条件(head 动过就拒绝合并),不是选择器;值写错 gh 直接拒绝,fail closed;head 分支检查是按名字做的,这个 flag 影响不到。 - A2
--auto缺少安全性证明 —— flag 本身不是缺陷,延迟合并窗口经 merge queue 无论如何都存在;已按真实根因重新归档到上面的 Medium。 - A3
--body-file可读任意文件 —— 守卫以调用者身份运行,那些文件它本来就能读,没有跨越任何权限边界。 - Codex F3/F5 ——
-b/--body取text、-F/--body-file取file,吃掉下一个 token 是正确行为(pflag 无视前导横杠),后面再来个非法 flag 照样被拒;短粘连形式(-bfoo)被拒是有意的,守卫无法安全拆分,拒绝的方向是对的。
补充(R4 全量复扫)
[Low] git-guard.sh:156 —— 因为 n 在解析循环之前被无条件取走,首位放一个 flag 会被静默当成 PR 选择器,报错还会指错地方。两条都实测过:merge-pr --integration zzz --squash(用户忘了写 PR 号)报的是 refusing extra positional argument 'zzz' … the PR number is the first argument —— 指着 --integration 的值说事;merge-pr --admin --integration zzz 用 bash -x 追踪到 n=--admin,最后死在 cannot read PR #--admin base branch (gh auth / wrong number?) —— 把一个本该被白名单具名拒绝的 flag 说成了认证问题。两条都 fail closed 所以只是 Low,而且加上上面那条 blocking 的一行校验就一起没了。
建议
- 那条
gh api走 stdout 的注释修得对,值得留着——把 stderr 折进来确实会破坏 JSON 解析,并让下面每个分支静默退化成"读不到保护规则"。 - 这属于本仓库一直在追的同一个家族(#39 死代码、#40 随机红灯、FU-2 清不掉东西的清理、FU-7 自己变红的检查):读起来很严、但有一个输入它从来不检查的 rail。建议在
reference/里写一条:每个守卫必须校验自己的选择器,而不只是 flag。
PK Review v4 · 4 轮:R1a/R1b DeepSeek-v4-flash(并行,5 条全部被驳回)→ R2 Opus 独立战略评审(亲自跑守卫,产出唯一存活的 Medium)→ R3 Codex 对抗挑战(决定性回合:F4 挑出跨仓库选择器漏洞,前面每一轮都漏了)→ R4 Opus 全量裁决。R2 曾建议降到 2-round,执行器按"安全守卫"保持 4-round,Codex 这一轮证明了这个坚持是对的。机械证据:gh 2.92.0 help 原文、9 组 flag 拒绝实测、gh pr view <URL> --repo <other> 跨仓库覆盖实测、守卫读到外部仓库 PR 的端到端复现。
This reverts commit bfa5c9f.
本地 dist 会随日期漂 ≠ CI 会红。verify.yml 96-122 行在 diff 前把 averageTaskAge pin 到 committed 值(PR#40 做的),所以那条守卫不会 因时钟变红。立 FU-7 时只看了 git status、没读 CI 脚本。 连带 revert 上一个多余的 dist commit。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
评审 R3 挖出的:merge-pr 的第一个位置参数从来没被校验过,而 `gh pr merge` 接受 `[<number>|<url>|<branch>]`。URL 选择器完全无视 `--repo`(gh 2.92.0 实测),于是每道闸门读的都是本仓库(protection、 trunk 判定全用本地解析的 $repo),而 exec 出去的合并落在另一个仓库 —— 拿 Brood 的分支保护当证据,去合别人仓库的 PR。这正是上面拒绝 `--repo`/`-R` 要防的事,拒了 flag 却不看选择器,等于闸门开着。 四条一起修: 1. [Blocking] 选择器必须是纯数字。校验放在解析循环【之前】——放在 之后的话,`merge-pr --integration x`(忘了写 PR 号)会先死在循环里, 指着 --integration 的值说『多余的位置参数』,为两个 token 之前的 错误报错。选择器是最先被消费的,就得最先被判。 实测:URL 拒 / 分支名拒 / 首位 flag 拒(并指明是漏了 PR 号)/ 纯数字放行。 2. [Medium] --allow-trunk 增加 dismiss_stale_reviews 必须为 true。 原来的两个证明(保护要求审批、PR 已 APPROVED)都是【调用当刻】的 快照,而 --auto ——以及 gh 自己 help 写明的 merge queue,不带 flag 也会——把真正的合并推迟给 GitHub,那一刻本脚本没有任何检查会再跑。 不开 stale-dismissal 的话,检查通过之后再推的 commit 会顶着旧的 APPROVED 进主干。从已抓到的 $prot 里多解一个字段,不加 API 调用。 实测五种形状:true→放行,false/缺字段/无保护/坏 JSON→全部拒。 并补上 234 行注释:为什么这个字段承重,而 enforce_admins 不承重。 3. [Low] -s/-m/-r 加进白名单。gh 文档里它们是 --squash/--merge/--rebase 的正式别名,放长拒短显得武断,而武断的守卫会被绕过去。 4. [Low] 两处【运行时】报错的 usage 还写着 `[gh args]`,承诺着已经 不存在的透传语义,改成 `[allowlisted gh flags]`。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
|
四条全部成立,已修并推 Blocking:选择器这条我认得很彻底 —— 白名单的全部立意就是「控制什么能到达 修法按你给的方向,但校验位置往前挪了:放在解析循环之后不够。实测
你 R4 那两条 Low 确实一起没了。 Medium:
|
PR#47 review 的建议。那一处实例本 PR 修了,纪律本身没落地。 和 #45 一起收口,不往这个 PR 里加。 Claude-Session: https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk
clestons
left a comment
There was a problem hiding this comment.
✅ APPROVE — 上一轮 4 条全部修掉并实测通过 [4-round]
逐条复测(全部在 PR head 上跑真脚本,不是读代码推断):
| 上一轮的问题 | 实测结果 |
|---|---|
[High] 选择器可以是 URL,覆盖被钉住的 --repo |
merge-pr https://github.com/jhfnetboy/NextStop/pull/26 --integration main → rc=3 BLOCKED;refs/heads/somebranch、26/../../other、main 同样全拒 ✅ |
[Medium] --allow-trunk 不查 dismiss_stale_reviews |
已从同一份已捕获的 $prot 解析并强制要求;两处 parse 不可能互相矛盾 ✅ |
[Low] -s/-m/-r 被拒 |
三个都放行并进到 base 检查 ✅ |
[Low] 运行时 usage 仍写 [gh args] |
全文 grep [gh args] → 零命中 ✅ |
| [Low] 首位放 flag 时报错指错地方 | merge-pr --integration main --squash → refusing '--integration' as the PR selector,直接点名真正的错误;--admin 同理,且它从未到达白名单 ✅ |
之前拒绝的一律仍拒(--admin / --author-email / -bfoo / 尾随位置参数)。而且把 selector 检查放在解析循环之前这个决定是对的 —— 注释里解释的正是上一轮那条 Low 的成因。
新要求的 dismiss_stale_reviews 不会砸死本仓库自己的流程:gh api repos/AAStarCommunity/Brood/branches/main/protection → {"dismiss_stale_reviews":true,"required_approving_review_count":1}。
FU-7 的撤回是对的,我独立核实过:.github/workflows/verify.yml 96-122 行确实在比对前用 re.subn 把 averageTaskAge pin 回 git show HEAD: 里的值,并打印 pinned averageTaskAge to committed value。「本地 dist 会漂 ≠ CI 会红」这个自我更正成立,而且那条教训(报「守卫不可靠」之前先读守卫的实现)比 bug 本身有价值。
🔵 建议顺手做掉这一行(零风险,已三 locale 实证)
[Low] git-guard.sh:169 —— *[!0-9]* 里的 0-9 是区间,受 locale collation 影响
这是 Codex 这一轮挑出来的,我复现并定了界:
case "٥" (U+0665) in *[!0-9]*)
LC_ALL=C → reject
LC_ALL=en_US.UTF-8 → reject
LC_ALL=fa_IR → ACCEPT ← 当成数字
LC_ALL=fa_IR.UTF-8 → ACCEPT
LC_ALL=ar_SA.UTF-8 → ACCEPT
走真脚本:LC_ALL=fa_IR bash git-guard.sh merge-pr "٥" --integration main 不再死在选择器闸门,而是往下走到了 trunk 检查 —— 说明 ٥ 被当成"纯数字 PR 号"接受了。
定界(所以只是 Low):同一 locale 下上一轮那条 High 仍然全部被拦 —— https://…/pull/26、refs/heads/x、26/../../other、main 都还是 rc=3,因为拉丁字母、/、:、. 并不在被撑大的区间里。真正漏过的只有 ٥(U+0665) 和 ۵(U+06F5),而它们下一关就死在 gh pr view 上(cannot read PR #٥ base branch → rc=3)。今天没有可达利用,但这个校验器不是 locale-robust,而它是一条安全 rail 的第一道闸门。
修复一行:*[!0123456789]* —— 枚举不是区间。已验证在 C / fa_IR.UTF-8 / ar_SA.UTF-8 三种 locale 下都拒绝 ٥。
记账(走 #45,本 PR 不要再加东西)
FU-8 白纸黑字写了「不要再往 PR#47 里加东西(它被拆出来就是因为 #45 装了四件事)」—— 同意,下面几条都走 #45。
[Medium] git-guard.sh:153 —— 同一个 bug 家族的另一个实例就活在同一文件、修复点上方 170 行
pr-create) 分支以 exec gh pr create "$@" 收尾,零校验:无白名单、不拒 --repo/-R、不查 --base。而它索要的 preflight 戳记绑定的是本仓库 HEAD —— 于是 git-guard.sh pr-create --repo other/repo … 拿本仓库的证明去别的仓库开 PR,与 merge-pr 刚修掉的那处结构完全相同。
FU-8 现在把这条纪律写成「未来风险」("下一个守卫照样可能只查 flag"),但它已经有一个存活实例。建议 FU-8 里把这一处具名成 open 实例,收口时先修它 —— 否则这条 follow-up 读起来像是预防性的,实际是欠账。
| 严重度 | 位置 | 问题 |
|---|---|---|
| Low | git-guard.sh:325 |
闸门只认 dismiss_stale_reviews:true,但 require_last_push_approval:true 对「批准后再推提交」的防护至少同等强,且就在同一份 $prot 里(Brood 的 payload 里确实有这个字段)。用后者保护的仓库会被无理由拒绝。同一个 parse 加一个 or 即可 |
| Low | git-guard.sh:275 |
保护证明只走 classic protection 端点。该端点对只用 Repository Rulesets 保护的分支返回 404 "Branch not protected",于是 --allow-trunk 在这类仓库上永久不可用 —— 正是脚本注释自己点名的「不可用的守卫会被绕过去」。Brood 自己 rulesets 为空、classic 保护可读,本仓库不受影响;但 plugins/pilot/ 是要装进别的仓库的插件。404 时回落查 repos/$repo/rules/branches/$b 取 pull_request 规则 |
| Low | git-guard.sh:14 vs :166 |
文件头写「2 = usage error, 3 = BLOCKED by a rail」,但纯用法错误全部走 die 返回 3:实测 merge-pr 裸跑 rc=3、merge-pr 26(缺 --integration)rc=3、push origin rc=3,只有未知子命令才 rc=2。调用方按这份文档区分不了「我打错了」和「护栏拒绝了」。本次把 usage 判定从循环后搬进新的 selector case,顺带把这条不一致固化了 |
| Low | docs/agent/followups.md:24-25 |
append-only + 撤回,导致 FU-7 同时以 - [ ](24 行,开)和 - [x](25 行,撤回)存在于同一 id 下,任何「列出未完成 follow-up」的工具仍会把 FU-7 报成 open。原行改成 - [x] FU-7 (superseded, 见下行) 即可 |
驳回(DeepSeek 4 条全部不成立)
- A1
--未处理 ——--落进-*)分支 rc=3 fail-closed,不是洞。 - A2
|| true掩盖 python 错误 —— python 内已 try/except,且[ "$dismiss" = "1" ]对空值 fail-closed;pipefail 下去掉|| true反而会静默 abort、连 die 消息都不打印,更差。 - B1 dismiss 检查该扩到非 trunk 路径 —— 非 trunk 路径本来就不要求任何 approval,不存在「过期的 approval」;照改会把无保护的 preview 分支直接砸死。
- B2
-s/-m/-r绕过了对--squash/--merge/--rebase的拒绝 —— 前提就是错的:那三个长形式一直在白名单里,从未被拒;短形式是 gh 2.92.0--help里写明的官方 alias。
建议
--match-head-commit已经在白名单里,它正是 gh 提供的「把 head SHA 钉死」的机制。与其硬性要求对方仓库开dismiss_stale_reviews(很多仓库没开,直接砸死--allow-trunk),守卫可以在--auto场景下自己读当前 head SHA 注入--match-head-commit—— 自洽、不依赖对方仓库设置,与现有 dismiss 检查可并存互为兜底。- FU-7 的撤回建议限定到
averageTaskAge:projectHealth.staleTasks来自未 vendored 的外部 CLI,同样可能按天派生,而 verify.yml 并没有 pin 它。
PK Review v4 · 4 轮:R1a/R1b DeepSeek-v4-flash(并行,4 条全部驳回,其中 B2 的前提是事实错误)→ R2 Opus 独立战略评审(自跑 bypass battery,挖出 pr-create 同类洞存活 + 2 条 Low)→ R3 Codex 对抗挑战(CHALLENGE 掉「选择器修复完备」这个断言 —— locale 区间绕过,执行器复现并定界)→ R4 Opus 全量裁决(另补 rulesets 404 与退出码文档不符两条 Low)。机械证据:13 组 selector/flag 实测(含跨仓库 URL、路径穿越、flag 占位、空参);grep '[gh args]' 零命中;verify.yml 96-122 行 pin 逻辑原文核实;gh api .../branches/main/protection 实取;٥ 在 C / en_US / fa_IR / fa_IR.UTF-8 / ar_SA.UTF-8 五种 locale 下的区间 vs 枚举对比。
为什么拆
PR #45 一次装了四件事(白名单 + squash 清理 + worktree + 成本闸门),已经走到第四轮评审,
每轮都能挖出真问题,但交互面也一轮比一轮大。这里把三轮评审从没点过名的那部分单独切出来先合,
#45 之后只剩 squash 清理一件事。
这个 PR 只做一件事
git-guard.sh merge-pr拒绝 gh flag 的方式,黑名单 → 白名单。黑名单只能拒绝今天已知的危险 flag。
gh pr merge将来新增一个能绕过分支保护的 flag,黑名单会静默放行,这里不会自动知道。白名单往相反方向失败:不认识就拒,要放行得有人明确加进去。
--squash--merge--rebase--auto,以及--body/-b--body-file/-F--subject/-t--match-head-commit的分离与粘连两种形式--admin/--repo/-R/--delete-branch保留具名拒绝,只为让报错自己解释清楚(白名单本来也会拒)gh api的错误 BODY 走 stdout,不需要2>&1—— 原注释说反了,照着改会把 stderr 混进 JSON 让解析全线降级实测
本 PR 自己的 push 就走的
git-guard.sh push,PR 也是git-guard.sh pr-create开的,preflight.sh run4/4 通过(grade A)。第一次 pr-create 还被自己的 stale-stamp 守卫拦了一次,守卫工作正常。账本里一条自己撤回的记录
开这个 PR 时我先立了 FU-7(『dist fresh-build 检查随日期变红』),同日核实后撤回 —— 那是误报。
verify.yml96–122 行早就在 diff 前把averageTaskAgepin 到 committed 值(PR#40 做的),CI 不会因它变红。当时只看到本地
git status有 diff 就下了结论,没读 CI 脚本,把「本地 dist 会漂」和「CI 会红」混成了一件事。对应那个多余的 dist commit 也一并 revert 了。撤回过程按 append-only 纪律留在
followups.md里,没删行。Closes FU-1
https://claude.ai/code/session_01CmAW1q62bBtjT99inZeyLk