Skip to content

fix: post-merge audit 第二輪 —— 讓 --retroactive 的 gate 真的執行,以及 Codex 那批未修的 CRITICAL - #320

Merged
kiki830621 merged 11 commits into
mainfrom
idd/audit-round2
Aug 15, 2026
Merged

fix: post-merge audit 第二輪 —— 讓 --retroactive 的 gate 真的執行,以及 Codex 那批未修的 CRITICAL#320
kiki830621 merged 11 commits into
mainfrom
idd/audit-round2

Conversation

@kiki830621

Copy link
Copy Markdown
Member

Post-merge audit 的第二輪修正。第一輪(#319)修掉四項;跨模型(Codex)那批 CRITICAL 還有一半活著,這是剩下的部分。

最重要的一項:--retroactive 的 gate 從此會真的執行

idd-close/SKILL.md 原本自己寫著:

本 gate 是散文,不是機械強制(誠實揭露)。這張表由 agent 讀了才生效 —— 沒有任何 runtime 會擋住一個忽略它的執行。機械判定只存在於 check-closed-without-summary.sh,而本 skill 並未呼叫它。…未做

揭露是誠實的,但揭露不是緩解。七輪 verify 的全部成果,要等 agent 剛好讀到那張表才生效,而它守的是一個不可逆動作(在可能已經有 summary 的 issue 上再貼一份)。

helper 新增 --issue N:輸出一個 JSON 物件,用退出碼下判決0 唯一放行 / 1 其他分類 / 2 無法判定)。skill 在 draft 前與 post 前各執行一次,非 0 一律 abort —— 包含所有「不確定」的情況,也包含「helper 不在」(找不到 gate 等於沒有 gate)。審計模式的「永遠 exit 0」契約原樣不動,有斷言釘住。

gate 模式另外用 REST --paginate 抓 comment,不走 --json comments 那條硬上限 100 則、且回最舊 100 則的路。closing summary 依定義是最新一則。

其餘

缺陷 方向
migrate-idd-config.sh 對 symlink / 目錄 / 懸空連結一律照做 會毀資料:config 被搬出 repo;mv 把檔案搬目錄還印 ✓ migrated;breadcrumb 的 > 透過懸空連結建立檔案
mv 沒有原子 no-clobber ln + rm;中途被打斷留下兩個 link 指向同一 inode,不會兩邊都沒有
find -name 撈到 doc sample / fixture 限定 */.claude/<name>;否則在別人的樹裡憑空造出 .idd
idd-repo-map.shprintf '%b' 資料裡的 \c 靜默刪掉其後所有列 + totals 行更正 audit 裡我寫錯的一句--json 並非豁免,同一個 %b 也餵給 jq
find 退出碼沒檢查 讀不到的目錄回答「這裡沒有 config」= #301 的錯誤向上解析
lead_has_content 把 HTML 註解算成內容 ## Closing Summary + <!-- TODO -->compliant(印在任何區段都沒有)
idd-find / idd-update 用寬鬆比對做正面斷言 引述被標成「可考古的結案紀錄」;引述把還開著的 issue 推成 closed
附件 URL 抽取只涵蓋 markdown link autolink / HTML 屬性 / 句尾標點三種包裹全部黏回去 → 404 → 附件被忽略
#288 的 24 處固定 /tmp 路徑 含交給 reviewer 的 OUTPUT 指示,與兩份寫 egress body 的迴圈
idd-clarify 的 worked example 第三欄還是 #294 已取代的分析式;範例才是會被抄的那半

兩個反覆出現的形狀

「先寫規則、再在自己的範例裡違反它」 —— #288#294 都是這樣:規則出貨了、示範出貨了,然後隔幾十行的實際命令 / 範例還是舊的。這種文件比什麼都沒寫更糟:去查規則的人查得到,照著抄的人抄到缺陷。兩者都補了機械掃描(verify-scratch-pathsclarify-question-column),因為散文不會跟 grep 漂移。

探針壞掉而看起來是綠的 —— 這一輪自己又踩了三次,都在提交前抓到並記在 commit 裡:\c payload 不是合法 JSON 跳脫(jq 直接拒收,值根本沒進到被測的程式碼);jq -r '.x // "null"' 把真正的 false 報成 "null"(jq 的 // 視 false 為空),那條斷言因此不可能失敗;一個斷言字串裡的反引號被 shell 當成命令替換執行掉。

acid(逐條還原、確認 suite 會紅)

群組 結果
migrate 7 個機制 6 個單獨可轉紅。ln 在預設組態被前兩個守衛遮住 → 把它們一起關掉、只變動 lnmv,紅的數量 4→6,是活的縱深防禦
repo-map 2 個 各紅 2 / 1
gate 5 個 紅 2 / 4 / 1 / 1;--issue 整數驗證第一輪沒有測試重量(拿掉後仍 exit 2,只是改由下游 jq 崩潰),補一條檢查錯誤訊息來源的斷言後才轉紅
可見性一致化 紅 3

誠實殘留

  • mkdir -p 之後的 .idd 複查沒有測試重量:它只窄化 TOCTOU 窗口,單一 process 的測試碰不到。
  • jq 錯誤路徑的 tr 只去 ASCII 控制字元。先量過這個縫通不通:三種 payload(U+202E / U+2028 / ESC)在 jq 1.7 上完全不回顯輸入值。改的是註解不是程式碼 —— 補一份 Unicode-aware scrub 等於多一份字元類副本,而副本各自漂移正是這個檔案最長的病史。
  • verify-scratch-paths 只掃 idd-verify,未涵蓋 idd-edit/tmp/idd-edit-backup/(那是文件叫使用者去 ls 的復原位置,搬它是行為變更)。這句寫在測試檔本身。

Suites 47 → 49。Classifier assertions 89 → 120。全綠。

跨模型(Codex)在 post-merge audit 指出四個 CRITICAL:`.idd` 可以是
symlink、breadcrumb 的 `>` 會跟隨 symlink、目的檔只用 `[ -f ]` 檢查、
`find` 只比對檔名。#319 修掉了路徑切割那個(`-print0`),這四個沒修。

共同根因是同一句沒被說出口的假設:**這些路徑都是一般檔案與一般目錄**。
腳本沒有檢查它,於是由 shell 自己「跟隨 symlink」的預設替使用者的資料
做決定。`--apply` 預設走遍整個 ~/Developer,所以「我 clone 下來的 repo」
就在威脅模型內,而上面每一種形狀都能被 commit 進 repo 裡。

- `.idd` 是 symlink → `mkdir -p` 靜默接受並回報成功,config 被搬出樹外
- 目的檔是目錄 → `mv` 把檔案搬**進去**,變成 `local.json/issue-…json`,
  然後印「✓ migrated」
- 目的檔是懸空 symlink → `-e` 為 false,沒有任何警告
- 目的檔是指向樹外一般檔的 symlink → `-f` 跟隨它,稽核印出「both present,
  current wins」——一句在「current 其實不在這個 repo 裡」時為假的話
- breadcrumb 路徑是懸空 symlink → `-e` 看不見,`>` 直接**建立**目標
- `-name` 會撈到 doc sample / fixture,在別人的樹裡憑空造出 `.idd`

`-f`、`-d`、`-e` 都會跟隨 symlink,所以順序不是風格問題:`-L` 必須先測,
否則一個連結是由它指向的東西受審。

`mv` 沒有原子的 no-clobber——檢查與 rename 是兩個 syscall,中間有窗口。
`ln` 在目的存在時原子失敗,所以 hardlink-then-unlink 給得出 `mv` 給不出的
保證,且中途被打斷時留下兩個 link 指向同一 inode(config 兩邊都讀得到,
不會兩邊都沒有)。`mv -n` 是另一個候選,但 BSD 上它的 exit status 分不出
「已搬移」與「因目的已存在而跳過」。

測試:hostile tree 自成一個 scan root、自己一次 `--apply`(拒絕算 failure,
混進 happy path 會讓「apply exits 0」變成空話)。7 種形狀 + 1 條回歸鎖
(`find -type f` 用 lstat,所以 legacy 路徑是 symlink 時本來就不會被跟隨;
釘住它是因為這個保護藏在一個 flag 裡、看不見,將來有人改成 `find -L`
會安靜地開始跟隨)。30 assertions,全 suite 47/47。

acid(逐條還原、確認 suite 會紅):7 個機制裡 6 個單獨可轉紅。`ln` 在預設
組態下被前面兩個守衛遮住,把它們一起關掉、只變動 ln↔mv,紅的數量從 4 變
6——它是活的縱深防禦、不是裝飾。**mkdir 之後的 .idd 複查(M2)沒有測試
重量**:它只窄化 TOCTOU 窗口,單一 process 的測試碰不到,如實記在這裡。
`rows` 用 `\t` / `\n` 兩字元跳脫存放、再用 `printf '%b'` 印出,而 `%b` 也會
展開**資料裡**的跳脫。所以路徑或 `github_repo` 裡的一個反斜線是被執行、
不是被印出。sanitizer 擋掉了控制字元與 bidi mark,從來沒碰過反斜線——當時
下游沒有東西會解讀它。

`\c` 最糟:`%b` 遇到它會停掉**全部**輸出。一個被構造的值就能安靜刪掉它後面
的每一列與 totals 行,而少了一半列的地圖,讀起來跟「這台機器本來就只有這些
repo」一模一樣。`\n` 造出獨立的偽列,`\t` 位移欄位。

**更正一項我在 audit 裡寫錯的話**:當時我說「`--json` 路徑是安全的(jq 會
正確跳脫)」。那句話只對 newline payload 成立。同一個 `%b` 也餵給 jq,所以
反斜線 payload 一樣會截斷 JSON 那一列(實測 `config_format` 變成 `null`)。
我測了我寫的那段(sanitizer),沒測它與 printf 相接的那個縫。

改法:列緩衝直接放真的 TAB / NEWLINE,三處 `%b` 改 `%s`。資料不可能再帶回
真的控制字元,因為 sanitize_field 會刪掉 tab、把 newline 折成空白。

`find` 的退出碼:process substitution 會丟掉它。這件事在別的掃描器只是少幾
列,在這裡是要害——這張地圖的全部工作就是回答「這一層在不在」,而一個讀不
到的目錄回答「這裡沒有 config」,跟真的沒有 config **無法區分**,那正是 #301
的錯誤向上解析。改成先導向暫存檔、檢查退出碼、不完整時明講。順帶把 `-print`
換成 `-print0`(同 #319 對 migrate 的處置,消掉含換行目錄名那一類)。

新增 `refute_grep_re`(+ 自身 3 條測試)。這個 helper 值得存在,是因為固定
字串的否定斷言是本 repo 最容易寫錯的一種:`o/FORGED-ROW` 合法地出現在它被
寫進去的那一列**之內**,`refute_grep` 會因為一個測試根本沒打算檢查的理由而
失敗。要錨定就需要 regex。

測試自身也修過一次:第一版用 printf 寫 payload,檔案裡是 `\c`——那不是合法
的 JSON 跳脫,jq 直接拒收,值根本沒進到列緩衝,於是**探針通過、卻什麼都沒
測到**。改用 quoted heredoc 讓磁碟上真的是 `\\`,並加一條 probe self-check
斷言 payload 確實活著到 jq 之後。

acid:`%s`→`%b`(含還原跳脫式列緩衝)紅 2 條;拿掉 find 退出碼檢查紅 1 條。
全 suite 47/47。
`idd-close/SKILL.md` 原本自己寫著:「本 gate 是散文,不是機械強制…機械判定
只存在於 check-closed-without-summary.sh,而本 skill **並未呼叫它**…未做」。
揭露是誠實的,但揭露不是緩解——**七輪 verify 的全部成果,要等 agent 剛好讀到
那張表才生效**,而它守的是一個不可逆動作(在可能已有 summary 的 issue 上再
貼一份)。

helper 新增 `--issue N` 單一 issue 模式,輸出一個 JSON 物件,並且**用退出碼
下判決**:

    0  class == missing 且 comment 集合完整       → 唯一放行
    1  其他分類(compliant / casing / present)    → 拒絕
    2  無法判定(未 CLOSED / 截斷 / 抓取或解析失敗)→ 拒絕

審計模式的「永遠 exit 0」契約原樣不動(有斷言釘住)。那條契約是對**人**報告
用的;gate 是不可逆動作的前置條件,一個永遠 exit 0 的 gate 不是 gate——呼叫端
只能去解讀散文,而那正是這七輪維持 advisory 的原因。

gate 模式另外**不走** `--json comments` 那條路:它用 REST `--paginate` 抓
comment。那條巢狀 connection 硬上限 100 則且回**最舊**的 100 則,而 closing
summary 依定義是**最新**一則。審計端是事後修補截斷(#319),gate 端根本不走。

fail-closed 的範圍寫死在 skill 裡,包含「helper 不在」——找不到 gate 等於沒有
gate,不是「那就跳過吧」。

prose-drift 測試補兩類斷言:(1) skill 必須真的**執行** helper(比對呼叫式與
退出碼分支),不是只「指向 normative source」——指向正是它散文化七輪期間一直
在做的事;(2) helper 必須真的有那個 flag,否則 skill 呼叫一個不存在的旗標會
以最壞的方式 fail open(未知旗標只警告不中止,等於把審計的 always-exit-0
搬到破壞性路徑上)。

順帶修掉 drift 測試的 canary:它往 repo 寫固定檔名、沒有 trap。中途被打斷就
留下一個檔案,之後每一次執行都紅、且會被 `git add -A` 帶進 commit;固定檔名
還讓兩個並行執行互刪對方的 canary,各自把對方的刪除讀成「掃描看不到植入的
字串」。改成唯一檔名 + trap。

測試自身修過兩次:
- `jq -r ".comments_complete // \"null\""` 把真正的 `false` 報成 "null"
  (jq 的 `//` 視 false 為空),那條截斷斷言因此不可能失敗。改 `tostring`。
- 一個斷言字串裡的反引號被 shell 當成命令替換執行掉了。

acid:G1 截斷仍放行→紅 2;G2 非 missing 仍放行→紅 4;G3 未 CLOSED 不再拒絕
→紅 1;G5 skill 不再呼叫 gate→紅 1。G4(--issue 整數驗證)第一輪**沒有測試
重量**——拿掉它 `--issue abc` 仍然 exit 2,只是改由下游 jq 崩潰造成,退出碼
分不出兩者。補一條斷言檢查錯誤訊息確實來自驗證本身後才轉紅(那條驗證真正
要守的是 live 路徑:數字會被插進 API URL,中間沒有 jq 擋著)。

全 suite 47/47,classifier suite 114 assertions。
`lead_line` 找「讀者實際看到的第一行」時會跳過整行 HTML marker(本 plugin 要求
機器可定位的 marker 放第一行,不能讓它把一份逐字正確的 summary 降級)。
`lead_has_content` 掃後續行找內容時**沒有套同一條規則**,於是 HTML 註解被算成
summary。

    ## Closing Summary
    <!-- TODO: write this up properly -->

判成 `compliant`——**印在任何區段都沒有**,而 `--retroactive` 也拒絕它。那正是
內容判定當初要關掉的靜音管道,在下一層又開了一扇門。非 canonical 大小寫的同一
形狀則落 `casing`,那一類是**不帶 ⚠ 印出、並宣稱「summary 在」**。

抽出 `invisible_line`,兩邊共用。對讀者來說空行與看不見的行是同一件事,在這裡
就該是同一件事。

**方向誠實話**:這整類在**便宜**的一側——錯的答案是把 issue 藏起來,不是授權
重複貼文。仍然修,是因為前一輪明講要關掉這個管道、卻只關了一扇。

只認**單行** HTML 註解,這是刻意的:跨行的需要第 1-4 輪移除掉的 fence/comment
狀態機,而殘留誤差同樣落在便宜那一側。

fixture #162 / #163 + 6 條斷言;acid:拿掉後續行的可見性過濾 → 紅 3。
全 suite 47/47,classifier 120 assertions。
normative source 把 predicate 拆成兩個,判準不是「哪個比較準」而是**過度偵測
會往哪個方向錯**:`present_re`(寬)回答「讀者看得到嗎」,多報一次只是不採取
破壞性動作;`lead_re`(嚴)回答「這則 comment 是不是以它開頭」,多報一次就是
講了一句假話。這條規則不只屬於 classifier,但有兩個散文 reader 站錯邊:

- **`idd-find`** 用寬鬆比對標 `📜 closing summary(可考古的結案紀錄)`。一則
  只是引用模板來提問的 comment 會拿到一模一樣的標籤。改成 `📜 summary marker`
  ——**標籤的字面就是它能保證的全部**。比對維持寬鬆(搜尋結果漏報的誤導成本
  更高),改的是它敢宣稱什麼。render 範例那行一起改:只改被點到的那行、留下
  隔壁那行,正是這個 marker 五輪漂移的原樣。
- **`idd-update`** 的 phase 推斷同樣用寬鬆比對,而 phase 是正面斷言(「這個
  階段發生過」)。一則引用 `> ## Closing Summary` 的討論就能把一張**還開著**的
  issue 推成 `closed`。改成要求該 heading 是那則 comment 的首行、不帶 blockquote
  前綴、縮排 ≤3 空格 —— 即 `lead_re`。

**附件 URL 抽取**:字元類只排除 `)` 與空白,也就是只涵蓋 markdown link。實測
另外三種真實 issue body 的包裹方式全部把包裹字元黏了回來:

    <https://…/a.pdf>            autolink       → 尾巴多 `>`
    <img src="https://…/b.png">  HTML 屬性      → 尾巴多 `">`
    see https://…/c.pdf.         句尾           → 尾巴多 `.`

三種都會 404,而抓不下來的附件就是被忽略的來源 —— 本 plugin 把那當成違規而非
不便。`<` `>` `"` `'` 不可能未編碼地出現在 URL 裡,排除掉是零成本;句尾標點改
成事後去掉一個(`.` 在 URL 中段合法,而這些網址都以副檔名結尾)。

**jq 錯誤路徑**:註解寫著「strip control characters and bidi overrides」,程式
只做前者 —— `tr` 在 LC_ALL=C 下處理**位元組**,看不到多位元組的 U+202E / U+2028。
先量了這個縫**通不通**:三種 payload(U+202E / U+2028 / ESC)放在會讓 `.state`
撞上裸字串的位置,jq 1.7 回 `Cannot index string with string ("state")`,**完全
不回顯輸入值**。所以改的是註解不是程式碼,並如實記下這個乾淨的否定結果 ——
補一份 Unicode-aware scrub 等於多一份字元類副本,而「安全定義的副本各自漂移」
正是這個檔案最長的病史。哪天遇到會回顯的 jq,要改的就是那一行。

新增:process-attachments 的 `wrapped` gh-stub mode + 5 條斷言(RED 3 條);
prose-drift 補 3 條斷言釘住兩個 reader 的方向。全 suite 47/47。
#288 的 closing summary 說「每個 scratch path 現在都掛在 `mktemp -d` 底下」。
實際出貨的是一段**這樣寫的註記**,後面接著二十四處繼續 MUST 舊的固定路徑 ——
包含交給 reviewer agent 的 OUTPUT 指示,也就是真正決定 findings 落在哪裡的那
幾行。規則和違反它的例子相隔四行,共存了兩個版本。

**一份先寫規則、隔一行又自打嘴巴的規格,比什麼都沒寫更糟**:去查規則的人查得
到,照著抄命令的人抄到缺陷。

固定名稱不帶 repo 身分:同一個 issue 號在不同 repo 的兩個 session 共用檔名,
前一輪的殘檔會被當成這一輪的 findings 讀進來 —— 靜默,且方向最壞(把別的 repo
的判決併進這份報告)。

全部改掛 `$VERIFY_DIR`(Step 0 新增 `resolve_scratch_dir` task,在任何寫檔或
spawn 之前解析一次)。過程中另外撞到兩個**同一段迴圈的兩份副本**——egress body
(`master.md` / `pointer.md` / `pointer_template.md`)也用固定名,而那是要被
**貼到別人 issue 上的文字**:共用路徑上的殘檔或半寫檔不會大聲失敗,它會發布錯的
留言。兩份都改了;第二份是機械掃描抓到的,不是我看到的。

新增 `scripts/tests/verify-scratch-paths/`(第 48 個 suite):掃固定 /tmp 路徑,
帶 positive control(唯一檔名 + trap)。

**scope 明講**:只掃 `idd-verify`。同一個 grep 也會打到 `idd-edit` 的
`/tmp/idd-edit-backup/` 與 `/tmp/idd-edit-repl-*` —— 那是另一回事、**未涵蓋**:
backup 目錄是文件叫使用者去 `ls` 的復原位置,搬它是行為變更,且碰撞後果是看得見
的衝突而非被靜默併入的判決。這條寫進測試檔本身,免得有人把它的綠讀成對 idd-edit
的背書。

註記本身也不再逐字寫出被禁的舊路徑 —— 同 prose-drift 測試禁止複述 classifier
regex 的理由:不存在的副本不會漂移,而寫進文件的違例字面正是機械檢查第一個踩到
的東西。

全 suite 48/48。
#294 依使用者原話把 `Question for you` 那一欄從描述式分析改成問句 ——

> 「iddclarify我覺得你可以直接放對使用者的問題」

理由在讀者是誰:那一欄給的是**要做裁決的人**。寫成分析,他讀完還得先把分析
還原成一個可回答的問題。

出貨的是**規則**、一張 ❌/✅ 對照表示範規則 —— 然後四十行後的 worked example
第三欄還是分析(`分群變數 / distinguishing variable (per K-means context…)`)。
照規則做的人得到新行為,照範例抄的人得到舊行為,而**範例才是會被抄的那半**。

跟 #288 的暫存路徑同一類:先寫規則、再在自己的範例裡違反它。

改掉兩列,並新增 `scripts/tests/clarify-question-column/`(第 49 個 suite):
掃所有 `surfaced` 範例列的第三欄,沒有問號就紅(`...` 佔位、`passed`、
`deferred` 三種豁免——前者不是措辭範例,後兩者沒有東西可問)。帶 positive
control(唯一檔名 + trap)。在修改前的樹上跑會紅,已驗。

全 suite 49/49。
#292 之後 **attended** 的 `/idd-all` 走 `/idd-plan`(`EnterPlanMode` 閘門住在
那裡、不在 `/idd-implement`)。`idd-all` 更新了,`idd-plan` 的「與 idd-all 的
整合」段沒有,而且它把舊規則寫成**全稱句**:

> **idd-all 不該走 Plan path**…應該 fallback 走 Simple path

那句話的 unattended 半段是對的。錯在把一個分支的結論寫成無條件的 —— 以 idd-plan
為準的讀者,會得到與實際執行相反的結論。

改成兩欄分述(attended / unattended),並明講 normative source 是 `idd-all` 的
dispatch table。這正是 repo 自己那條規則說的:**能列舉的就列舉,不要先寫一句
總括判準再補例外** —— 總括與例外是兩份不會一起改的規格。

新增 `scripts/tests/plan-routing-consistency/`(第 50 個 suite):先對 `idd-all`
斷言 routing 本身仍成立(否則下面那些斷言只是在釘一份虛構),再對 `idd-plan`
斷言分述存在、全稱句消失。在修改前的樹上跑紅 4 條,已驗。

全 suite 50/50。
@kiki830621

kiki830621 commented Aug 15, 2026

Copy link
Copy Markdown
Member Author

追加一個 commit:#317idd-planidd-all 的 Plan routing 描述是一句全稱句)。

同一類缺陷,所以併進來而不是另開 PR:#292 之後 attended/idd-all 走的正是 /idd-plan,但 idd-plan 自己寫著「idd-all 不該走 Plan path」。那句話的 unattended 半段對、錯在把一個分支的結論寫成無條件的 —— 以它為準的讀者會得到與實際執行相反的結論。

改成 attended / unattended 兩欄分述。新增 scripts/tests/plan-routing-consistency/先對 idd-all 斷言 routing 本身仍成立,再對 idd-plan 斷言分述存在 —— 只釘讀者端而不核對來源,釘的可能是一份虛構。修改前的樹上紅 4 條。

Refs #317。Suites 49 → 50。


(上一則留言是我用雙引號傳 body,裡面的反引號被 shell 當成命令替換執行掉了 —— 本 session 第四次踩到同一類「探針/輸出被 shell 吃掉」的坑。以本則為準。)
再追加 #315(ensemble 看不到 diff 之外的寫入)。

idd-verify 的 scope 是 diff,但 idd-implement 的 sister sweep / cross-reference note 會寫到別的 issue、別的 repo。macdoc#143 的實例:實作筆記裡一個事實錯誤被原樣擴散到另一張 issue 的參照 note,四個 lens 都只評了 diff 內的措辭,是 DA 越出自己的 scope 去讀 blast radius 紀錄才抓到 —— 靠某個 reviewer 臨場越界不是機制。

#315 的選項 1:把 implement 自己記下的外部寫入清單塞進 reviewer context,兩個 backend 都給(給不同 context 會讓一個 finding 取決於當時解析到哪個 backend)。沒有紀錄時報 UNKNOWN 而非「沒有外部寫入」—— 漏跑的 sweep 與跑了沒找到的 sweep 痕跡一樣。

不做選項 2(machine-readable manifest):那是兩個 skill 之間的新契約,而整輪 audit 的結論正是「沒經過自己那輪 review 的新契約就是缺陷住的地方」。理由與殘餘風險寫進 CHANGELOG。

過程中又踩了一次同型缺陷並記在 commit 裡:第一版插入點鎖在只有 requirements prompt 才有的 attachment 行,五個 prompt 只改到一個。測試現在用數的:annotated 數必須等於 prompt 數,且 prompt 數 ≥4(防 0 == 0 的空洞相等)。


PR 現況:10 commits、25 files、+1112/−108、suites 47 → 51、全綠。涵蓋 audit 的全部剩餘 findings 加上 #317#315

這個 PR 沒有經過任何獨立驗證 —— 每個機制我都做了 acid(逐條還原確認 suite 轉紅),也自己抓到並修掉四個壞掉的探針,但那全是同一個人同一個模型。本輪的核心發現就是「沒被獨立看過的東西會出事」,所以這句話寫在這裡而不是留白。

`idd-verify` 的 scope 是 diff,但 `idd-implement` 的 sister sweep 與 cross-reference
note **會寫到 diff 以外的表面**:別的 issue 的 comment、別的 repo 的新 issue。沒有
任何 lens 看得到它們。

實例(`macdoc#143`):實作筆記裡一個事實錯誤被原樣擴散到另一張 issue 的參照 note。
四個 lens 都只評了 diff 內的措辭,是 devil's advocate **越出自己的 scope** 去讀
Implementation Complete 的 blast radius 紀錄才抓到。**某個 reviewer 臨場越界不是
機制。**

把 implement 自己記下的外部寫入清單塞進 reviewer context,**兩個 backend 都給** ——
pai 與 manual fan-out 拿到不同的 context,會讓一個 finding 取決於當時解析到哪個
backend,而 skill 自己的契約說 Step 3 之後兩者可互換。

**沒有紀錄時報 UNKNOWN,不報「沒有外部寫入」**:漏跑的 sister sweep 與跑了但沒找到
的 sister sweep,留下的痕跡一模一樣。

過程中自己踩到一次同型缺陷:第一版的插入點鎖在只有 requirements 那個 prompt 才有
的 attachment 行,於是**五個 prompt 只改到一個**、另外四個靜靜留著舊 context ——
正是這一輪一直在抓的「改了被點到的那行、留下隔壁」。改成鎖每個 prompt 都有的
`Diff path:` 行,並在測試裡**數**:annotated 數必須等於 prompt 數,且 prompt 數
≥4(防 0 == 0 的空洞相等)。

這是 #315 的**選項 1**。選項 2(idd-implement 寫 machine-readable manifest、
idd-verify 逐筆抽查內容)是**兩個 skill 之間的新契約**,而整輪 audit 的結論正是
「沒經過自己那輪 review 的新契約就是缺陷住的地方」。不做,理由與殘餘風險寫進
CHANGELOG,不留在 PR 討論裡。

新增 `scripts/tests/verify-external-writes/`(第 51 個 suite,7 assertions)。
全 suite 51/51。
@kiki830621

Copy link
Copy Markdown
Member Author

Verify — PR #320 (post-merge) — FAIL

Engine

pai-ensemble 2.20.0 (canonical #207) — 4 IDD lenses + DA + Codex (gpt-5.6-sol, xhigh), model: opus
Frozen at e90bae0 · range c2dd479..e90bae0 · diff 1853 lines · run wf_7d7a8bbf-979
6/6 legs returned. Codex was rate-limited for four consecutive rounds on the #295 line and its DA leg died on a weekly limit last round — this is the first complete cross-model pass this work has ever had. All six lenses contributed findings; none errored.

Freshness gate (#228): HEAD unchanged at e90bae0 — aggregate allowed.

Aggregate

FAIL — 69 findings: 4 CRITICAL (one defect, found independently by four lenses), 15 HIGH, 25 MEDIUM, 14 LOW, 11 INFO.

Lens distribution: codex 15 · logic 14 · regression 12 · requirements 11 · security 11 · devil's-advocate 6.


CRITICAL — the gate this PR exists to build does not hold

Four lenses (codex, logic, security, regression) independently reproduced the same defect. scripts/check-closed-without-summary.sh:155:

if ! CMTS=$(gh api "repos/$GATE_REPO/issues/$GATE_ISSUE/comments" --paginate \
              --jq '[.[] | {body}]' 2>/dev/null | jq -s 'add // []' 2>/dev/null); then
  gate_out "" "" false "could not fetch the comments of #$GATE_ISSUE" 2
fi

The script sets set -u and nothing else — no pipefail, no PIPESTATUS. if ! therefore tests jq's exit status, and jq -s 'add // []' exits 0 on empty stdin (printing []) and exits 0 on a truncated stream (printing whatever pages arrived). gh api's failure is unobservable. The type == "array" check on the next line passes for [].

I reproduced it myself before accepting it, with a stub gh where issue view succeeds (CLOSED) and the comments fetch fails:

stub mode expected actual
success (control — proves the stub's JSON parses) rc=1 ✅ rc=1 class=present
genuinely-empty (really has no comments) rc=0 missing ✅ rc=0 missing
total-failure (403 rate limit) rc=2 rc=0 class=missing, comments_complete=true
partial-pagination (page 1 streams, page 2 fails) rc=2 rc=0 class=missing, comments_complete=true

partial-pagination is the worst of the two, and it is not exotic — --paginate streams oldest-first, so a mid-pagination failure keeps the old comments and loses the newest, which is by construction where the closing summary is. The gate then answers "missing, comment set complete, go ahead and post." 0 is the one code /idd-close --retroactive reads as authorisation.

This is the seven-round failure shape, restored at the acquisition layer. The header comment I wrote — "the audit path repairs that after the fact; the gate simply never takes the broken road" — is inverted: the gate takes a different broken road, with no repair at all. The advisory path 60 lines below has the length > 0 check and a shrink-refusal; the irreversible path has neither.

Two secondary consequences the lenses drew out:

  • comments_complete: true is an unearned assertion on the live path. GATE_TRUNC reads .idd_comments_truncated, a field only the audit branch ever sets; gate mode assembles its own payload and never sets it. The truncation→exit-2 branch is unreachable live — the suite's gate: a truncated comment set exits 2 assertion only ever exercised the --json-file fixture.
  • The CHANGELOG's 2 = fetch or parse failure and "comment set known to be complete" are both false on the failure path.

My own probe understated this. My first reproduction used an unquoted heredoc, so \\n collapsed to a real newline inside a JSON string; jq rejected the payload and partial-pagination returned the correct exit code for entirely the wrong reason. That is the fifth broken probe in this work, and the only reason I caught it is that I added a success control that must come back rc=1.


HIGH (15)

Findings that falsify claims I made in writing

# Finding Where
H1 #317 criterion (c) is UNMET. docs/workflows.md:407 is the third place, and it states the OPPOSITE of idd-all: it says an unattended /loop /idd-all on a Plan-tier issue has the Plan gate trigger and hang with nobody to approve. Per idd-all's dispatch table, unattended Plan routes to Phase 3a /idd-implement — no gate, no hang. Verified. My grep searched for Phase 3p, the implementation label; this file states the claim without ever using that token. I tested my search, not the question — and then wrote "沒有第三處分岔" into a closing summary. requirements
H2 #315 "both backends" is not met. On the canonical pai backend the devil's advocate — the one reviewer that caught the recorded macdoc#143 case — never receives the external-writes record. requirements
H3 #315 uses $N, which is undefined in that scope (should be $NUMBER), and the new suite only greps static text, so it has zero execution weight over the fetch. codex
H4 #315 fetches with gh issue view --json comments — the oldest-100 connection this very PR identifies as the seven-round root cause and deliberately routes the gate away from. The newest Implementation Complete comment may never be seen. codex, requirements, security, logic

Gate bypass

# Finding Where
H5 --issue "" (or a bare trailing --issue) silently collapses into audit mode and exits 0. Verified: it prints the full audit report including the --retroactive invitation and returns 0. GATE_ISSUE="" makes [ -n "$GATE_ISSUE" ] false, so the validator never runs — the '' arm of its case is dead code. A caller writing --issue "$NUMBER" with NUMBER unset gets "go ahead". logic, security, regression
H6 idd-close resolves the helper through a CWD-relative fallback (${CLAUDE_PLUGIN_ROOT:-plugins/issue-driven-dev}/scripts/...). A cloned repo can supply the gate binary — arbitrary code execution plus an unconditional pass. My "helper 不在就 abort" rule closed the absent case and opened a substituted one. security

Others

# Finding Where
H7 Human-visible closing summaries still reach missing + rc=0 — a third arrangement of the same tokens as fixtures #115/#121. logic
H8 #315 pipes attacker-controllable issue-comment text verbatim into all five manual-fan-out reviewer prompts, with no data guard on that backend. security
H9 Attachment filenames are URL-decoded after basename, so a percent-encoded traversal in an issue URL escapes the attachments directory. security
H10 idd-update's tightening to lead_re regresses #295's own recorded drift case (summary merged into the Implementation Complete comment — 1 of the 11 real false positives #295 measured), and contradicts the sentence left directly above it. My fix over-corrected: the quotation risk is real, but I applied the strict predicate to all five headings. regression
H11 The migrate breadcrumb's "will not overwrite" is still check-then-write — a TOCTOU that can truncate an arbitrary file. codex
H12 #288's mechanised scope claim is false: rules/tagging-collaborators.md — a protocol idd-verify mandates — uses fixed /tmp filenames throughout, and the mention gate's decision source is that file. devil's-advocate

MEDIUM (25) — the recurring themes

  • #315 is broken in more ways than one: the extractor names four sections, but the only one any skill actually writes is Sister Bugs Filed — so the cross-reference class that motivated the issue will always report UNKNOWN; the fetch sits inside the block labelled 「Tier 1 專用」 so manual fan-out can silently get (none recorded) every run; the Codex lens gets nothing; ^ anchors to the whole comment string; cluster verify loops over every ref'd issue but the fetch reads exactly one.
  • The new count-equality assertion locks the gap in: adding the external-writes context to the Codex prompt would make verify-external-writes FAIL. The test I wrote to prove parity now enforces the disparity.
  • verify-scratch-paths cannot see what it forbids: it exempts the whole ${TMPDIR:-/tmp} idiom, so a fixed scratch name written that way — including an egress body — passes. And references/external-agent-delegation.md still names the three fixed egress-body paths, outside the scan scope I declared (I named idd-edit as the exclusion; I did not know about this file).
  • The gate's live path has zero test coverage, and --dry-run is silently ignored in gate mode, so no offline seam exists. The second (pre-post) gate invocation exists only as prose, and the gate is absent from idd-close's TaskCreate bootstrap. Batch --retroactive #34 #36 #38 is documented; the mandated snippet is single-issue.
  • Two of the new refute assertions do not go red under mutation — one is currently green only because of two markdown asterisks.
  • #295's strict-vs-permissive split reached one of six family readers: idd-list / idd-find phase inference is still permissive.

LOW (14) and INFO (11) omitted here; full set in the run journal.


Verdict

FAIL. Nothing tagged. The CRITICAL is blocking on its own: the single mechanism this PR exists to deliver — a machine-enforced precondition on an irreversible action — fails open on an ordinary transient error.

The honest reading is not "the audit missed things". It is that I merged this without independent review, said so, and the one thing I built to be trustworthy was the thing that was broken. Four lenses found it in the first pass. The stop condition that let it merge was my own green suite, and the suite could not see it because the live path has no coverage and the fixture path exercises a different branch.

#315 and #317 are being reopened: both were closed against acceptance criteria this run shows are unmet, and a closed issue whose fix does not satisfy its own criteria is worse than an open one — it removes the thing from view while leaving the defect in place.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant