能力閘門依 CLI 分流並隔離狀態檔,判不出能力一律擋下 #72

Merged
admin merged 1 commits from fix/sdlc-gate-cli-isolation into develop 2026-09-02 03:11:43 +00:00
Member

摘要

  • 需求描述:使用者回報兩件事——在 claude 底下執行時,SDLC 能力閘門卻拿 codex 的模型來判定;以及這道閘門應該只鎖能力標籤、不該鎖死特定模型。追查後兩件事的根因相同:閘門完全不分辨自己正跑在哪一支 CLI 上。四個缺陷疊加的後果是,技能以 Bash 呼叫 lock 算出的狀態檔名,與 hook 自己算出的檔名永遠不同,因此這道閘門在 claude 上從未真正生效。本次修正讓偵測鏈依 CLI 分流、狀態檔依 CLI 隔離,並把判定政策改為 fail-closed。
  • 計畫名稱:無
  • 計畫頁:無
  • 分析頁:無

變更內容

檔案 為什麼改
hooks/lib.sh 模型偵測鏈依 CLI 分流,一支只讀自己的紀錄;session_id() 與 cli_name() 補上實測看得到的判準變數,避免一路退回預設值;階段鎖狀態檔改為 sessions/{CLI 代號}-{sid}.stage,並保留舊路徑唯讀相容;修正 codex 工作階段記錄取檔的排序缺陷。
hooks/sdlc-gate.sh check 改為 fail-closed:判不出 CLI、判不出模型、模型不在能力標籤表上,三種一律擋下;每一則擋下訊息附兩條逃生門;unlock 可接受狀態檔路徑參數,並限制只收工作階段目錄底下的 .stage 檔。
hooks/write-guard.sh 階段模式改用共用函式庫的路徑函式,與閘門讀寫同一份狀態檔;擋下訊息把實際讀到的狀態檔路徑寫進解除指令。
tools/wire-cli.sh 冒煙夾具補齊本次行為:模型來源四條各自指定 CLI 代號,另加三種擋下情形、fail-closed 判定、階段鎖的 CLI 區隔與舊格式相容,判定條數由四條增為十二條。
README.md 同步 hooks 表、狀態檔分界表與環境變數表。

設計重點

決策 內容與理由
政策改為 fail-closed 判不出 CLI、判不出模型、模型不在能力標籤表上,三種都視為「不知道能力」,一律擋下。原本第二種只提醒不擋,等同「換一支讀不到紀錄的 CLI 就能繞過」,閘門形同虛設。
每則擋下訊息附兩條逃生門 fail-closed 的代價是可能把人鎖在送不出提示的狀態,因此每一則擋下訊息都印出兩條出路:設定 JSC_MODEL,或執行 unlock 並附上實際狀態檔路徑,照抄即可脫困。
逃生門路徑照抄進指令 擋人的是 hook,解鎖的是人在殼層手動執行,兩側算出的檔名未必相同;不點名路徑就解不到真正擋人的那一支。
unlock 參數限縮 只接受工作階段目錄底下的 .stage 檔,逃生門不該順帶變成任意刪檔工具;判準刻意不綁本行程算出的家目錄,否則會把照抄訊息的人擋在門外。
狀態檔用檔名前綴而非子目錄 外部工具以單層 sessions/*.stage 盤點階段鎖,改用子目錄會讓每一支鎖從那些盤點裡整批消失。
舊格式狀態檔唯讀相容 舊路徑只讀不搬也不刪,unlock 不帶參數時新舊兩份一起清;只清新的那一份,舊格式的鎖將永遠解不開。
判準變數只補 claude 一支 其他 CLI 的環境變數名與環境特徵未經實測,猜測填入只會多一個錯誤來源;那幾支本來就由接線設定明確帶入 CLI 代號。
修正工作階段記錄取檔排序 原本以分批管線排序再取第一筆,實際只在各自那一批內排序,取到的不是全域最新的一支;改為一併輸出修改時間後全域排序,環境不支援時退回路徑排序(檔名以 ISO 時間開頭,字典序等同時間序)。

升級後的連帶影響如下表,皆為既有缺陷修正後的必然結果,方向正確但確實是行為改變。

影響項目 說明
一次性重啟閘門清除 升級後每台機器第一次工作階段啟動會清一次重啟閘門,因為工作階段代號換名,看起來像新的工作階段。實測只發生一次,連跑三次不再重複。升級當下若正好有閘門升著,那一次會被放下。
版本快取換子目錄 殼層執行時 CLI 代號由未知變為 claude,版本快取目錄跟著換到對應子目錄。
阻擋形態改變 由保守預設形態改為 claude 形態。
使用紀錄欄位改變 技能使用紀錄與錯誤回報腳本所記的 CLI 欄位跟著改變。
三份工作階段標記換檔名 起訖標記與最後技能標記三份檔案換上新檔名;舊檔不刪除也不報錯,只是不再被讀取。
版本號刻意未動 三份 manifest 的版本提升由另一支同時進行的變更帶走,本次之後再補下一個修訂號。審查時看到版本號未動屬預期,並非遺漏。

測試結果

驗證項目 結果
殼層與 hook 路徑一致 兩側算出同一個狀態檔名;修改前一側為 unknown-default、另一側為 claude-{UUID}。
端對端串接 殼層 lock plan 寫出的狀態檔,hook 的 check 確實讀到並擋下(exit 2),寫入守門的階段模式亦擋下寫檔。這是這道閘門第一次真的串起來。
claude 情境偵測來源 不再出現 codex 來源。
codex 情境偵測來源 指定 CLI 代號為 codex 時才查 codex 來源,且取到全域最新的一支,即排序缺陷已修正的證據。
三種擋下情形 判不出 CLI、判不出模型、模型不在能力標籤表上,各驗一次皆擋下。
逃生門一:設定環境變數 設定 JSC_MODEL 後 exit 0。
逃生門二:解除階段鎖 照抄訊息中的 unlock 指令後 exit 0,狀態檔消失。
逃生門參數安全性 惡意路徑 unlock /etc/passwd 被拒(exit 2)。
兩支 CLI 同工作階段代號 各自上鎖後狀態檔分開,互不干擾。
舊格式相容 舊格式狀態檔仍讀得到,全程唯讀未改動。
重啟閘門 只清自己那一支、且只清一次。
靜態檢查 四項 lint 全過。
冒煙測試 status=ok、lines 139;階段標記檔跑前跑後皆為 1 支,未多寫。

已知風險

風險 說明與現況
逾時成因未完全查明 使用者先前觀察到閘門慢到逾時,但在本機重現不出來,全樹掃描只花百毫秒級。該段全樹掃描已整段移除,冷快取的風險一併消失,但「120 秒逾時」的成因仍未完全釐清。
hook 觸發未實跑驗證 未實際驗證 hook 是否確實會被觸發;冒煙測試驗的是接線內容與腳本邏輯。
其他 CLI 判準未涵蓋 其他 CLI 的環境變數名未經實測,本次只補上 claude 一支的判準,其餘仍仰賴接線設定明確帶入 CLI 代號。
fail-closed 的阻擋面擴大 政策收緊後,任何無法判定能力的情形都會擋下,較先前更容易擋到人;緩解方式是每則訊息都附上兩條可照抄的逃生門,且兩條皆已實走驗證。

前置 Push Request

  • 無
## 摘要 - 需求描述:使用者回報兩件事——在 claude 底下執行時,SDLC 能力閘門卻拿 codex 的模型來判定;以及這道閘門應該只鎖能力標籤、不該鎖死特定模型。追查後兩件事的根因相同:閘門完全不分辨自己正跑在哪一支 CLI 上。四個缺陷疊加的後果是,技能以 Bash 呼叫 `lock` 算出的狀態檔名,與 hook 自己算出的檔名永遠不同,因此這道閘門在 claude 上從未真正生效。本次修正讓偵測鏈依 CLI 分流、狀態檔依 CLI 隔離,並把判定政策改為 fail-closed。 - 計畫名稱:無 - 計畫頁:無 - 分析頁:無 ## 變更內容 | 檔案 | 為什麼改 | | --- | --- | | `hooks/lib.sh` | 模型偵測鏈依 CLI 分流,一支只讀自己的紀錄;`session_id()` 與 `cli_name()` 補上實測看得到的判準變數,避免一路退回預設值;階段鎖狀態檔改為 `sessions/{CLI 代號}-{sid}.stage`,並保留舊路徑唯讀相容;修正 codex 工作階段記錄取檔的排序缺陷。 | | `hooks/sdlc-gate.sh` | `check` 改為 fail-closed:判不出 CLI、判不出模型、模型不在能力標籤表上,三種一律擋下;每一則擋下訊息附兩條逃生門;`unlock` 可接受狀態檔路徑參數,並限制只收工作階段目錄底下的 `.stage` 檔。 | | `hooks/write-guard.sh` | 階段模式改用共用函式庫的路徑函式,與閘門讀寫同一份狀態檔;擋下訊息把實際讀到的狀態檔路徑寫進解除指令。 | | `tools/wire-cli.sh` | 冒煙夾具補齊本次行為:模型來源四條各自指定 CLI 代號,另加三種擋下情形、fail-closed 判定、階段鎖的 CLI 區隔與舊格式相容,判定條數由四條增為十二條。 | | `README.md` | 同步 hooks 表、狀態檔分界表與環境變數表。 | ## 設計重點 | 決策 | 內容與理由 | | --- | --- | | 政策改為 fail-closed | 判不出 CLI、判不出模型、模型不在能力標籤表上,三種都視為「不知道能力」,一律擋下。原本第二種只提醒不擋,等同「換一支讀不到紀錄的 CLI 就能繞過」,閘門形同虛設。 | | 每則擋下訊息附兩條逃生門 | fail-closed 的代價是可能把人鎖在送不出提示的狀態,因此每一則擋下訊息都印出兩條出路:設定 `JSC_MODEL`,或執行 `unlock` 並附上實際狀態檔路徑,照抄即可脫困。 | | 逃生門路徑照抄進指令 | 擋人的是 hook,解鎖的是人在殼層手動執行,兩側算出的檔名未必相同;不點名路徑就解不到真正擋人的那一支。 | | `unlock` 參數限縮 | 只接受工作階段目錄底下的 `.stage` 檔,逃生門不該順帶變成任意刪檔工具;判準刻意不綁本行程算出的家目錄,否則會把照抄訊息的人擋在門外。 | | 狀態檔用檔名前綴而非子目錄 | 外部工具以單層 `sessions/*.stage` 盤點階段鎖,改用子目錄會讓每一支鎖從那些盤點裡整批消失。 | | 舊格式狀態檔唯讀相容 | 舊路徑只讀不搬也不刪,`unlock` 不帶參數時新舊兩份一起清;只清新的那一份,舊格式的鎖將永遠解不開。 | | 判準變數只補 claude 一支 | 其他 CLI 的環境變數名與環境特徵未經實測,猜測填入只會多一個錯誤來源;那幾支本來就由接線設定明確帶入 CLI 代號。 | | 修正工作階段記錄取檔排序 | 原本以分批管線排序再取第一筆,實際只在各自那一批內排序,取到的不是全域最新的一支;改為一併輸出修改時間後全域排序,環境不支援時退回路徑排序(檔名以 ISO 時間開頭,字典序等同時間序)。 | 升級後的連帶影響如下表,皆為既有缺陷修正後的必然結果,方向正確但確實是行為改變。 | 影響項目 | 說明 | | --- | --- | | 一次性重啟閘門清除 | 升級後每台機器第一次工作階段啟動會清一次重啟閘門,因為工作階段代號換名,看起來像新的工作階段。實測只發生一次,連跑三次不再重複。升級當下若正好有閘門升著,那一次會被放下。 | | 版本快取換子目錄 | 殼層執行時 CLI 代號由未知變為 claude,版本快取目錄跟著換到對應子目錄。 | | 阻擋形態改變 | 由保守預設形態改為 claude 形態。 | | 使用紀錄欄位改變 | 技能使用紀錄與錯誤回報腳本所記的 CLI 欄位跟著改變。 | | 三份工作階段標記換檔名 | 起訖標記與最後技能標記三份檔案換上新檔名;舊檔不刪除也不報錯,只是不再被讀取。 | | 版本號刻意未動 | 三份 manifest 的版本提升由另一支同時進行的變更帶走,本次之後再補下一個修訂號。審查時看到版本號未動屬預期,並非遺漏。 | ## 測試結果 | 驗證項目 | 結果 | | --- | --- | | 殼層與 hook 路徑一致 | 兩側算出同一個狀態檔名;修改前一側為 `unknown-default`、另一側為 `claude-{UUID}`。 | | 端對端串接 | 殼層 `lock plan` 寫出的狀態檔,hook 的 `check` 確實讀到並擋下(exit 2),寫入守門的階段模式亦擋下寫檔。這是這道閘門第一次真的串起來。 | | claude 情境偵測來源 | 不再出現 codex 來源。 | | codex 情境偵測來源 | 指定 CLI 代號為 codex 時才查 codex 來源,且取到全域最新的一支,即排序缺陷已修正的證據。 | | 三種擋下情形 | 判不出 CLI、判不出模型、模型不在能力標籤表上,各驗一次皆擋下。 | | 逃生門一:設定環境變數 | 設定 `JSC_MODEL` 後 exit 0。 | | 逃生門二:解除階段鎖 | 照抄訊息中的 `unlock` 指令後 exit 0,狀態檔消失。 | | 逃生門參數安全性 | 惡意路徑 `unlock /etc/passwd` 被拒(exit 2)。 | | 兩支 CLI 同工作階段代號 | 各自上鎖後狀態檔分開,互不干擾。 | | 舊格式相容 | 舊格式狀態檔仍讀得到,全程唯讀未改動。 | | 重啟閘門 | 只清自己那一支、且只清一次。 | | 靜態檢查 | 四項 lint 全過。 | | 冒煙測試 | `status=ok`、`lines 139`;階段標記檔跑前跑後皆為 1 支,未多寫。 | ## 已知風險 | 風險 | 說明與現況 | | --- | --- | | 逾時成因未完全查明 | 使用者先前觀察到閘門慢到逾時,但在本機重現不出來,全樹掃描只花百毫秒級。該段全樹掃描已整段移除,冷快取的風險一併消失,但「120 秒逾時」的成因仍未完全釐清。 | | hook 觸發未實跑驗證 | 未實際驗證 hook 是否確實會被觸發;冒煙測試驗的是接線內容與腳本邏輯。 | | 其他 CLI 判準未涵蓋 | 其他 CLI 的環境變數名未經實測,本次只補上 claude 一支的判準,其餘仍仰賴接線設定明確帶入 CLI 代號。 | | fail-closed 的阻擋面擴大 | 政策收緊後,任何無法判定能力的情形都會擋下,較先前更容易擋到人;緩解方式是每則訊息都附上兩條可照抄的逃生門,且兩條皆已實走驗證。 | ## 前置 Push Request - 無
jiantw83 added 1 commit 2026-09-02 03:06:05 +00:00
What:
- current_model_report() 的偵測鏈依 CLI 分流,一支只讀自己的紀錄。claude 讀 transcript 與 hook stdin,codex 讀 hook stdin 與自己的 session 記錄,copilot、antigravity、kiro 本機沒有可讀的模型紀錄,判不出是哪一支 CLI 時不採用任何自動來源。JSC_MODEL 人工覆寫五支都保留,而且一律排在最後。
- session_id() 的退路鏈補上 CLAUDE_CODE_SESSION_ID,cli_name() 補上 CLAUDECODE 與 CLAUDE_CODE_SESSION_ID。
- 階段鎖狀態檔改成 sessions/{CLI 代號}-{sid}.stage。舊路徑仍讀得到,unlock 不帶參數時新舊兩份一起清,也收一個狀態檔路徑當參數。
- check 改成 fail-closed:判不出 CLI、判不出模型、模型不在能力標籤表上,三種一律以 exit 2 擋下該輪提示。每一則擋下的訊息都印出兩條逃生門。
- write-guard.sh 的 stage 模式改讀 lib.sh 的路徑函式,擋人訊息把實際讀到的狀態檔路徑寫進解除指令。
- codex_session_file() 取「全樹最新一支」的做法改掉。
- wire-cli.sh 的冒煙夾具跟著補:模型來源四條各自指定 CLI 代號,另加三種擋下情形、check 的 fail-closed、階段鎖的 CLI 區隔與舊格式相容,這一組的判定條數由四條增為十二條。
- README 的 hooks 表、狀態檔分界表與環境變數表跟著改。

Why:
- 使用者回報兩件事:在 claude 底下被 codex 的模型判定,而且這道閘門應該只鎖能力標籤、不鎖模型。查下來根因是同一個——閘門完全不分辨自己跑在哪一支 CLI 上。
- 偵測鏈不分流,claude 拿不到 transcript 就一路掉進 codex 的 session 記錄,拿別支的模型判這一支。順帶每一輪都對一個上千個檔、兩百多 MB 的目錄樹跑一次 find,宿主 CLI 跟著卡。
- session_id() 讀的變數名在目前的 claude 上並不存在,sid 一路退回 default。cli_name() 犯同一類錯:只認 plugin 接線才有的變數,技能以 Bash 工具呼叫腳本時取不到,一律判成 unknown。
- 前三項合起來的後果是:技能呼叫 lock 算出的檔名,跟 hook 算出的檔名永遠不同。階段鎖上了也對不上,所以這道閘門在 claude 上一直沒有真正生效。
- 狀態檔不分 CLI,各支就共用同一支 default.stage,一支上的鎖擋到另一支。那不只擋提示,write-guard.sh 的 stage 模式連 Write、Edit、MultiEdit 一起擋。

How:
- 政策改成 fail-closed:不知道能力就擋下,知道才比對標籤。原本判不出模型只提醒不擋,那等於「換一支讀不到紀錄的 CLI 就能繞過去」,閘門形同虛設。代價是把人鎖在送不出提示的狀態,所以每一則擋下的訊息都要帶兩條逃生門,照抄就脫困。
- 逃生門的路徑照抄進指令裡。擋人的是 hook,解鎖的是人在殼層手動執行,兩邊算出來的檔名未必相同,不點名就解不到真正擋人的那一支。
- unlock 的參數只收 sessions 目錄底下的 .stage 檔,逃生門不該順便變成任意刪檔的工具。判準刻意不綁這支行程算出的 JSC_HOME:擋人的一側跟解鎖的一側未必相同,綁上去就會把照抄訊息的人擋掉,那正是這個逃生門要避免的事。
- 狀態檔用檔名前綴不用子目錄。外部工具以單層的 sessions/*.stage 盤點階段鎖,改成子目錄會讓每一支鎖從那些盤點裡整批消失。
- 舊路徑只讀不搬也不刪,unlock 一併清掉。只清新的那一份,舊格式的鎖就永遠解不開,被它擋住的人沒有逃生門。
- 變數名只補實測看得到的那幾個,而且只補 claude 這一支。其他 CLI 的變數名與環境特徵沒有實測過,猜一個填進來只會多一個錯誤來源,那幾支本來就由接線設定明確帶 JSC_CLI。
- codex_session_file() 原本寫成 xargs ls -t 再 head -n 1,那是錯的:xargs 依參數長度分批,ls -t 只在自己那一批裡排序,取到的是第一批裡最新的,不是全域最新。改成讓 find 一併印出修改時間再全域排序;沒有 -printf 就退回路徑排序,那些檔名以 ISO 時間開頭,字典序等同時間序。
- 冒煙的擋人案例連訊息一起驗,不只比結束碼。訊息漏掉逃生門一樣是綠燈,而那才是這道 fail-closed 真正的風險。
- 四支腳本併成一筆。write-guard.sh 讀 lib.sh 的路徑函式,wire-cli.sh 是驗這些行為的冒煙夾具,拆開會留下跑不過的中間版本:路徑函式與呼叫端分兩筆,中間那一筆狀態檔的讀寫兩側就對不上。
- 一次性代價:升級後每台機器第一次 SessionStart 會清一次重啟閘門。sid 換了名字,看起來像新的工作階段。只發生這一次。
- 三份 manifest 這一筆不動,版本號另外提升。

Who:
SDLC 階段能力閘門。修的是它一直沒有生效的那條路徑,順帶把 codex session 記錄的取檔方式一起修掉。
admin approved these changes 2026-09-02 03:11:32 +00:00
admin merged commit ccbf0b8ef7 into develop 2026-09-02 03:11:43 +00:00
admin deleted branch fix/sdlc-gate-cli-isolation 2026-09-02 03:11:43 +00:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: plugins/hooks#72