diff --git a/docs/TESTING.md b/docs/TESTING.md index 0869087..86fa7a3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -624,11 +624,18 @@ bash hooks/tests/parallel-lines-cap-guard.test.sh `PostToolUse:Agent` 在**送出後 5 秒**就觸發(不是收工); subagent **不是獨立行程**(`ps` 只有一個 claude-code 行程,所以數行程數不到線)。 -### A24 — 一條線要有自己的工作目錄:59 條 +### A24 — 一條線要有自己的工作目錄:69 條 ``` bash hooks/tests/line-needs-own-worktree.test.sh ``` -**該看到**:`59/59 通過`。離線,每個案例自己開**兩顆**真的 git repo +一份真的 linked worktree。 +**該看到**:`69/69 通過`。離線,每個案例自己開**兩顆**真的 git repo +一份真的 linked worktree。 + +**E 群在守什麼**(comment 6587,2026-09-07 補):**閘印出來的那條出路要真的走得通。** +閘訊息教的是 `WORKTREE_OK=1 git -C … checkout …`,舊版讀的卻是 hook 自己的環境變數——PreToolUse hook +跟指令不是同一個行程,指令前綴的賦值到不了它 ⇒ 總管三次照貼三次被擋。E 群的測資是**閘自己印出來的那一行** +((61) 先擋一次、(62) 抽出那行、(63) 原樣餵回去要放行);(64)–(67) 釘住判準是**位置不是字**: +`echo WORKTREE_OK=1; git checkout`、前綴掛在別的指令上、值不是 1、寫在 commit 訊息裡,四種都照擋。 +修之前拿新測試跑舊閘:(60)(63)(68) 三條紅,其餘全綠——紅的那三條就是真的改到的。 **D 群在守什麼**(comment 5398,2026-08-29 補):不只要擋對,還要**說得出是哪一個 repo**。 舊版一律拿 payload 的 `cwd` 當答案,於是 `cd <別的 repo> && git checkout` 擋是擋對了, @@ -646,6 +653,8 @@ D 群同時釘住反面:認不出來(`cd $VAR`、`cd -`、cd 到不是 repo 擋掉它們等於線動不了工 - ① 紅 ⇒ 連總管在自己的目錄裡切分支都被擋。那不是嚴格,那是把共用目錄的主人趕出去 - ㉞㉟ 紅 ⇒ `prune` 做錯了。㉞是沒清掉說謊的登記;**㉟是把還在的 worktree 也弄掉了,那是災難** +- (63) 紅 ⇒ 閘印的出路又走不通了——被擋的人不會停下來修閘,他會去找繞過去的方法(6587 當天就是) +- (64)–(67) 任一紅 ⇒ 出路退化成關鍵字:印個字就能過,閘等於沒有 ### A25 — `sdd-guard` 退役了,而且退乾淨了:8 條 ``` @@ -911,6 +920,26 @@ bash hooks/tests/isep-presence-beacon.test.sh # ㉗–㉝ 那七條 **真實跑(2026-09-07)**:信標不帶 fixture 打真 Gitea(匿名)→ 安靜(v0.24.0 tag+release 都在), 留下 `.isep-chain-ok-v0.24.0` 快取;`release-check` 在本分支 → `v0.24.0 ✅✅✅ ←`、三個裁定不補的版本標 ⏭、exit 0。 +### A39 — D20 閘判「push 到哪」看的是指令實際會推的那個 repo:26 條 +``` +bash scripts/test-github-contact-guard.sh +``` +**該看到**:`26/26 通過`。離線;後 12 條自己開兩顆 git repo(`shell` 的 origin=github.com、 +`body` 的 origin 是 Gitea 的形狀),payload 帶 `cwd`,跟 hook 真的會收到的一樣。 + +**它在守什麼**(`inkstone/ISEP#109` → comment 6629,2026-09-07 實撞):雲端薄殼的 cwd 是 GitHub 那份, +總管站在那裡 `git -C push origin x`(InkStoneCo 的 origin=Gitea),舊版拿 **hook 收到的 cwd** +解 `origin` ⇒ 判成「指向 GitHub」擋下,`-C` 看都沒看;arcrun-hand 同日在 `Arcrun#176` comment 6614 撞到同一支。 +現在 remote 名在**這條指令實際會推的那個 repo** 裡解(沿用 `hooks/lib/push_target_dir.py`:cd 鏈、`-C`、 +子殼不外洩,推 main 那道閘 08-23 就為同一個病接的它);判準仍是 remote 解出來的 URL 主機,**不是「有 -C 就放行」**。 +修之前拿新測試跑舊閘:**7 條紅**——4 條是誤攔(推 Gitea 被擋),**3 條是漏擋**(站在 Gitea 那份 `-C` 到薄殼推 GitHub,舊版放行)。 + +**失敗**: +- 「不該擋」6 條任一紅 ⇒ **誤攔**:雲端推 Gitea 分支又得改用完整網址繞路,久了整道閘沒人當真 +- 「該擋」5 條任一紅 ⇒ D20 有洞:`-C`/`cd` 指到 GitHub 那份的 push 沒被攔 +- `(cd body && true); git push` 那條紅 ⇒ 子殼的 cd 外洩了(08-11 穿透的形狀) +- 前 14 條紅 ⇒ 舊有判準(heredoc/引號裡的散文/讀取放行)被改壞 + ### A37 — 雲端第一則 prompt 的主線就是現在這條,不是兩週前的舊線:31 條 ``` bash hooks/tests/mainline-gitea-fallback.test.sh @@ -1214,6 +1243,8 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面 | **A12 留言身份欄(兩道門)** | 總管 | ✅ 11/11(2026-08-27) | | **A23 同時跑的線有上限** | isep-guard | ✅ 33/33(2026-08-29,inkstone/ISEP#109) | | **A24 一條線要有自己的工作目錄** | isep-guard | ✅ 35/35(2026-08-29,inkstone/ISEP#109→5391) | +| **A24 出路那一行原樣餵回去走得通** | isep-hand | ✅ 69/69(2026-09-07,inkstone/ISEP#109→6587)+真路徑實跑(薄殼 cwd、`-C ISEP`:擋→印的那行→放行)+舊閘實跑 3 條紅 | +| **A39 D20 閘看 push 實際會推的 repo** | isep-hand | ✅ 26/26(2026-09-07,inkstone/ISEP#109→6629)+真路徑實跑(薄殼 cwd:`-C InkStoneCo` 放行、裸 `git push origin` 擋)+舊閘實跑 7 條紅(4 誤攔+3 漏擋) | | **A26 雲端憑證清單這台拿得到** | isep-hand | ✅ 11/11(2026-09-01,inkstone/ISEP#115→5526/5541/5542/5577)+兩條「該紅」方向實跑 | | **A16 放行的門真的打得開** | 總管 | ✅ 17/17(2026-08-28,inkstone/ISEP#90) | | **A17 未推警察不誤攔雲端分支** | 總管 | ✅ 10/10(2026-08-28,inkstone/ISEP#90) | diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index f8e158f..4f645b2 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -38,6 +38,12 @@ > **61 支、85 條、60 支腳本是在這棵樹上當場數出來的**(`ls hooks/*.sh | wc -l`=61、 > `grep -c '"command":' hooks/hooks.json`=85、`ls -p scripts | grep -v / | wc -l`=60)。 +> 📌 **`inkstone/ISEP#109` → comment 6629(2026-09-07)+0 支、+0 條**:兩支閘改判準不加閘—— +> `line-needs-own-worktree.sh` 的出路改認指令字串裡的 `WORKTREE_OK=1` 前綴(舊版讀 hook 自己的環境, +> 出路永遠走不通);`github-contact-guard.sh` 判 push 目標改在「這條指令實際會推的那個 repo」裡解 remote +> (舊版在 hook 的 cwd 解,薄殼站 GitHub、推 Gitea 會誤擋,反向會漏)。 +> 數字是改完當場數的:`ls hooks/*.sh | wc -l`=61、`grep -c '"command":' hooks/hooks.json`=85。 + > 📌 **`inkstone/ISEP#130`(2026-09-07)+0 支、+1 條**:SessionStart 多掛一條 > `scripts/settings-allow-sync --quiet`(不是閘,是腳本:把 ISEP 的權限白名單寫進這台的 > `~/.claude/settings.json`,雲端才不會每個正門工具都被分類器擋)。 @@ -129,7 +135,7 @@ | 閘名 | 對你意味著什麼 | 動作 | |---|---|---| -| `github-contact-guard.sh` | AI 想碰 GitHub(`git push`、`gh api` 等)就先擋下——兩個帳號曾經因為機器人式高頻寫入被永久 flag 過。**只有讀取(clone/fetch)自由,寫入一律要你在終端機跑 `github-arm.sh` 解鎖**。 | 🛑 擋 | +| `github-contact-guard.sh` | AI 想碰 GitHub(`git push`、`gh api` 等)就先擋下——兩個帳號曾經因為機器人式高頻寫入被永久 flag 過。**只有讀取(clone/fetch)自由,寫入一律要你在終端機跑 `github-arm.sh` 解鎖**。判「這次 push 到哪」看的是**這條指令實際會推的那個 repo**(含 `cd …&&`、`git -C …`、cwd 在子目錄),不是 hook 站的 cwd——雲端薄殼站在 GitHub 那份、推的是 Gitea 那份時不會誤擋,反過來也不會漏(`inkstone/ISEP#109`→6629)。 | 🛑 擋 | | `kbdb-api-wall-guard.sh` | AI 想繞過 KBDB 的 API、直接對資料庫下 SQL 或建新表就擋下——KBDB 的鐵律是「只能用 API、永遠只有三張表」。 | 🛑 擋 | | `stage-before-prod-guard.sh` | AI 想動「出貨鏈」相關指令(含 `arcrun-rag-bundles`/`github-arm`/`publish-github` 字樣)卻沒先在 stage 環境驗過就擋下。 | 🛑 擋 | | `main-and-prod-push-guard.sh` | 兩層確認:① subagent 想推 Gitea `main` 分支 → 一定要交回總管,總管確認過才放行;② 任何 `wrangler deploy` 打正式環境 → 一定要你親手解鎖。 | 🛑 擋 | @@ -143,7 +149,7 @@ | `reply-identity-guard.sh` | 直接打 Gitea API 貼留言到票上時,內文第一行沒有 `【身份】`(`總管`/`subagent`/`leo`)就擋下——票上多條線並行時,看不出哪一則是誰寫的。純讀取、走 `scripts/ticket` 正門、指令裡加 `reply-identity-ok` 都放行。 | 🛑 擋 | | `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。**擋下來時印的是一整塊可以直接貼著跑的指令**(腳本用絕對路徑、母票已經填好、內文草稿附在同一塊裡),那塊字是跟 `scripts/ticket usage subtask` 現要的,不是閘自己抄的一份(`inkstone/ISEP#112`)。 | 🛑 擋(同一輪只擋一次) | | `search-is-not-proof-guard.sh` | AI 用知識庫的**搜尋**找到一筆東西,然後把那筆東西的原始編號直接貼進票裡或派工單裡,當成「某個功能壞了」的證據——而它從沒用正規的檢索路徑去查過那筆——擋一次。你關心的是:不會再有人拿錯的方法量出一個錯的結論,然後退回一張其實已經做完的票。 | 🛑 擋(同一輪只擋一次) | -| `line-needs-own-worktree.sh` | 一條派工線想在**大家共用的那份工作目錄**裡切分支(`git checkout`/`git switch`)就擋下,叫它先開一份自己的 worktree。你關心的是:不會再有人交回工作時把某個 repo 留在自己的分支上,而下一個人在同一個目錄跑出貨、讀到的是別人留下的 HEAD。**總管在自己的目錄裡切分支不管**(那是他的目錄);已經在自己 worktree 裡的切換全部放行;還原檔案(`git checkout -- 檔`)不管。擋下來的同時會順手把「指向空氣」的 worktree 登記清掉。 | 🛑 擋 | +| `line-needs-own-worktree.sh` | 一條派工線想在**大家共用的那份工作目錄**裡切分支(`git checkout`/`git switch`)就擋下,叫它先開一份自己的 worktree。你關心的是:不會再有人交回工作時把某個 repo 留在自己的分支上,而下一個人在同一個目錄跑出貨、讀到的是別人留下的 HEAD。**總管在自己的目錄裡切分支不管**(那是他的目錄);已經在自己 worktree 裡的切換全部放行;還原檔案(`git checkout -- 檔`)不管。擋下來的同時會順手把「指向空氣」的 worktree 登記清掉。出路是閘訊息印的那一行 `WORKTREE_OK=1 git -C … checkout …`——認的是**指令字串裡**的這個前綴,原樣貼回去就過(`inkstone/ISEP#109`→6587:舊版讀 hook 自己的環境變數,那行在 Claude Code 底下永遠走不通)。 | 🛑 擋 | | `diagnosis-evidence-guard.sh` | 你想把一段有份量的東西寫上票,而這個 session **沒派人去查過那張票**、那段話裡也**沒有任何走得過去的出處**(檔案:行號/commit/comment 號/票號/網址/貼出來的實測輸出)就擋一次——未經調查的診斷寫在票上會長得像事實,工人會照著它去驗證而不是去查。派過人查、或把出處帶上,都放行;一句短回覆不管。 | 🛑 擋 | ## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit) diff --git a/hooks/github-contact-guard.sh b/hooks/github-contact-guard.sh index a41b9db..f4de287 100755 --- a/hooks/github-contact-guard.sh +++ b/hooks/github-contact-guard.sh @@ -87,12 +87,29 @@ try: print(json.load(sys.stdin).get('cwd','') or '') except Exception: print('') " 2>/dev/null) [ -n "$CWD" ] || CWD=$(pwd) + # 🔴 2026-09-07(inkstone/ISEP#109 → comment 6629):remote 名要在**這條指令實際會推的 + # 那個 repo** 裡解,不是在 hook 收到的 cwd 裡解。雲端薄殼的 cwd 是 GitHub 那份 + # (origin=github.com),總管站在那裡 `git -C push origin x` + # (InkStoneCo 的 origin=Gitea)——舊版拿 cwd 解 origin ⇒ 判成「指向 GitHub」擋下, + # `-C` 看都沒看;反過來,站在 Gitea 那份 `git -C <薄殼> push origin x` 則**漏擋**。 + # ⇒ 沿用 lib/push_target_dir.py(推 main 那道閘 08-23 就是為同一個病接的它): + # 純 tokenize 解出 cd 鏈/-C,解得出來就 cd 進去(唯讀)問 git;解不出來 + # (`cd $VAR`、目錄不存在)才退回 cwd——退回的方向是「照舊」,不是放寬。 + # 判準沒變:仍是 remote 解出來的 URL 主機(唯一識別碼),不是「有沒有 -C」。 + PUSHDIR="$CWD" + _ptd="$(dirname "$0")/lib/push_target_dir.py" + if [ -f "$_ptd" ]; then + _expr=$(printf '%s' "$CMD" | python3 "$_ptd" 2>/dev/null || printf '') + if [ -n "$_expr" ]; then + PUSHDIR=$(cd "$CWD" 2>/dev/null && cd "$_expr" 2>/dev/null && pwd) || PUSHDIR="$CWD" + fi + fi # 取 push 後面第一個非 flag 的字當 remote 名;沒有就是 origin RNAME=$(printf '%s' "$CMD" | sed -nE 's/.*git[[:space:]]+([^|;&]*[[:space:]])?push[[:space:]]+(-[^[:space:]]+[[:space:]]+)*([A-Za-z0-9._//-]+).*/\3/p' | head -1) [ -n "$RNAME" ] || RNAME="origin" - RURL=$(git -C "$CWD" remote get-url "$RNAME" 2>/dev/null) + RURL=$(git -C "$PUSHDIR" remote get-url "$RNAME" 2>/dev/null) if printf '%s' "$RURL" | grep -qi 'github\.com'; then - HIT="git push → github(remote「$RNAME」指向 GitHub)" + HIT="git push → github($PUSHDIR 的 remote「$RNAME」指向 GitHub:$(printf '%s' "$RURL" | sed 's|//[^@]*@|//<憑證省略>@|g'))" fi fi fi diff --git a/hooks/lib/checkout_target_dir.py b/hooks/lib/checkout_target_dir.py index 55dfd3d..a17b7bd 100644 --- a/hooks/lib/checkout_target_dir.py +++ b/hooks/lib/checkout_target_dir.py @@ -30,13 +30,24 @@ 其他 目錄運算式,可能是相對的;呼叫端自己 `cd` 進去問 git(唯讀)。 "." =「就是呼叫端自己的 cwd」(指令裡沒有 cd 也沒有 -C)。 -用法:printf '%s' "$CMD" | python3 checkout_target_dir.py +出路的判準也住在這裡(inkstone/ISEP#109 → comment 6587,2026-09-07) + 閘訊息教的出路是 `WORKTREE_OK=1 git -C … checkout …`。舊版讀的是 **hook 自己的 + 環境變數**,而 PreToolUse hook 跑在指令之前、跟指令不是同一個行程——指令字串裡的 + `WORKTREE_OK=1` 是給 git 的前綴賦值,hook 的行程裡根本沒有它 ⇒ 總管三次照貼三次被擋。 + ⇒ 改成認**指令字串裡的字面前綴**,而且判準是位置不是字:那個賦值必須是 + 「會移動 HEAD 的那條 git 指令」自己的前綴(`echo WORKTREE_OK=1; git checkout` 不算、 + `WORKTREE_OK=1 echo hi && git checkout` 不算、寫在 commit 訊息裡不算)。 + 留痕就是那個前綴本身——它在指令歷史上。 + +用法:printf '%s' "$CMD" | python3 checkout_target_dir.py → 目錄運算式(見上) + printf '%s' "$CMD" | python3 checkout_target_dir.py --escape WORKTREE_OK=1 + → 那條 checkout 帶著這個前綴就印 1,否則空 """ import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from push_target_dir import _events, _join # noqa: E402 +from push_target_dir import _ENV_ASSIGN, _events, _join # noqa: E402 VERBS = ("checkout", "switch") UNKNOWN = "?" @@ -52,14 +63,19 @@ def _step(base, path): def _moves_head(tokens): - """tokens 是整串 `git …`。回傳 (會不會動 HEAD, -C 指的路徑 or None)。 + """tokens 是整串 `[NAME=value …] git …`。 + 回傳 (會不會動 HEAD, -C 指的路徑 or None, 前綴賦值清單)。 判準只有「這個動作動不動 HEAD」,不看任何措辭: git checkout -- <檔>/git checkout HEAD -- <檔> ⇒ 還原檔案,不動 git checkout -p ⇒ 挑 hunk,不動 git checkout(沒有任何目標) ⇒ git 自己會報錯 """ - tok = list(tokens[1:]) # 去掉 "git" + tok = list(tokens) + env = [] + while tok and _ENV_ASSIGN.match(tok[0]): + env.append(tok.pop(0)) # `WORKTREE_OK=1 git …` 的前綴——出路就認這個 + tok = tok[1:] # 去掉 "git" c_path = None while tok and tok[0].startswith("-"): if tok[0] == "-C" and len(tok) > 1: @@ -72,24 +88,25 @@ def _moves_head(tokens): tok.pop(0) continue if tok[0].startswith("--git-dir"): - return (False, None) # 自己指 git-dir 的花式用法,不猜 + return (False, None, env) # 自己指 git-dir 的花式用法,不猜 tok.pop(0) if not tok or tok[0] not in VERBS: - return (False, None) + return (False, None, env) rest = tok[1:] if "--" in rest or "-p" in rest or "--patch" in rest: - return (False, None) + return (False, None, env) if not [a for a in rest if not a.startswith("-")] and \ not any(a in ("-b", "-B", "-c", "-C") for a in rest): - return (False, None) - return (True, c_path) + return (False, None, env) + return (True, c_path, env) -def find_checkout_target(cmd): +def find_checkout(cmd): + """回 (目錄運算式, 那條 checkout 的前綴賦值清單)。目錄運算式的三種值見檔頭。""" events = _events(cmd, VERBS, strip_env=True) if not events: - return "" # 引號壞掉之類——沿用「不擋在不確定上」 + return ("", []) # 引號壞掉之類——沿用「不擋在不確定上」 stack = [None] # 每層括號各自的 cwd;None = 同呼叫端的 cwd for kind, val in events: @@ -101,7 +118,7 @@ def find_checkout_target(cmd): elif kind == "cd": stack[-1] = _step(stack[-1], val) elif kind == "verb": - moves, c_path = _moves_head(val) + moves, c_path, env = _moves_head(val) if not moves: continue where = stack[-1] @@ -110,13 +127,29 @@ def find_checkout_target(cmd): # (而且 -C 若是相對路徑,是接在 cd 之後算,不是接在 cwd) where = _step(where, c_path) if where == UNKNOWN: - return UNKNOWN - return where or "." - return "" + return (UNKNOWN, env) + return (where or ".", env) + return ("", []) + + +def find_checkout_target(cmd): + return find_checkout(cmd)[0] + + +def has_escape(cmd, assignment): + """會移動 HEAD 的那條 git 指令,自己的前綴裡有沒有這個字面賦值(例 `WORKTREE_OK=1`)。 + 只看**那條指令的前綴**:印在別處、掛在別的指令上、寫在引號裡都不算。""" + _expr, env = find_checkout(cmd) + return assignment in env def main(): - sys.stdout.write(find_checkout_target(sys.stdin.read())) + argv = sys.argv[1:] + cmd = sys.stdin.read() + if len(argv) == 2 and argv[0] == "--escape": + sys.stdout.write("1" if has_escape(cmd, argv[1]) else "") + return + sys.stdout.write(find_checkout_target(cmd)) if __name__ == "__main__": diff --git a/hooks/lib/push_target_dir.py b/hooks/lib/push_target_dir.py index 1d4fb28..ecb2a7f 100644 --- a/hooks/lib/push_target_dir.py +++ b/hooks/lib/push_target_dir.py @@ -91,21 +91,31 @@ def _classify(tokens, verbs=("push",), strip_env=False): stays deliberately loose here (the verb may sit anywhere after `git`) -- callers re-walk the returned token list to confirm command position, so a false positive at this layer costs nothing.""" + body = tokens if strip_env: # `FOO=1 git checkout …` -- an env prefix doesn't change which command # runs. Opt-in so find_push_target's behaviour stays byte-identical; # checkout_target_dir.py turns it on (its predecessor stripped these, # and dropping that would have been a silent regression -- caught by # the worktree gate's own case ㉒). + # + # 2026-09-07 (inkstone/ISEP#109 -> comment 6587): the prefix is skipped + # for *classifying* the statement, but the "verb" event hands back the + # full token list, prefix included. The worktree gate's escape hatch is + # exactly that prefix (`WORKTREE_OK=1 git checkout …`), and a PreToolUse + # hook can only see it in the command string -- it never reaches the + # hook's own environment. Dropping it here made the printed way out + # unwalkable. Callers that don't care simply skip leading NAME=value + # tokens themselves (checkout_target_dir._moves_head does). i = 0 while i < len(tokens) and _ENV_ASSIGN.match(tokens[i]): i += 1 - tokens = tokens[i:] - if not tokens: + body = tokens[i:] + if not body: return ("other", None) - if tokens[0] == "cd" and len(tokens) > 1: - return ("cd", tokens[1]) - if tokens[0] == "git" and any(v in tokens[1:] for v in verbs): + if body[0] == "cd" and len(body) > 1: + return ("cd", body[1]) + if body[0] == "git" and any(v in body[1:] for v in verbs): return ("verb", tokens) return ("other", None) diff --git a/hooks/line-needs-own-worktree.sh b/hooks/line-needs-own-worktree.sh index b9b8e0b..260f1c1 100755 --- a/hooks/line-needs-own-worktree.sh +++ b/hooks/line-needs-own-worktree.sh @@ -1,7 +1,12 @@ #!/usr/bin/env bash # 管什麼: 一條派工線要在「共用的工作目錄」裡切分支時擋下來,叫它去開一份自己的 worktree。 # 為什麼: 2026-08-29 inkstone/ISEP#109 comment 5391——每個 repo 只有一份工作目錄,所有線共用。#163 切到自己的分支、交回時沒切回來,總管後來在同一個目錄跑 ship.mjs,讀到的是別人留下的 HEAD。票號說得出「這是哪個任務」,沒有任何東西說得出「這個目錄現在是誰留下的狀態」。 -# 誤觸時怎麼關: `WORKTREE_OK=1 git checkout …`(留痕,跟 NOT_MY_BRANCH_OK 同形狀)。 +# 誤觸時怎麼關: `WORKTREE_OK=1 git checkout …`——認的是**指令字串裡**這個字面前綴(留痕就在指令歷史上)。 +# 🔴 2026-09-07(inkstone/ISEP#109 → comment 6587):舊版讀的是 hook 自己的環境變數 `$WORKTREE_OK`, +# 而 PreToolUse hook 跟指令不是同一個行程——指令前綴的賦值到不了它 ⇒ 閘印出來的出路在 Claude Code +# 底下永遠走不通(總管三次照貼三次被擋)。現在兩種都認:環境變數(本機 shell export 過)與字面前綴 +# (閘訊息教的那一行)。判準是位置不是字:前綴必須掛在會移動 HEAD 的那條 git 指令上,見 +# lib/checkout_target_dir.py 檔頭;測試 E 群把閘印出來的那一行原樣餵回去驗它走得通。 # # line-needs-own-worktree.sh — 共用工作目錄裡不准切分支(PreToolUse: Bash) # @@ -96,6 +101,11 @@ EXPR=$(printf '%s' "$CMD" | python3 "$RESOLVER" 2>/dev/null) || exit 0 [ -n "${EXPR:-}" ] || exit 0 # 沒有會移動 HEAD 的 checkout/switch [ "$EXPR" = "?" ] && exit 0 # 目錄解不出來(cd $VAR / cd -)⇒ 不猜,見該檔檔頭 +# 出路:那條 checkout 自己帶著 `WORKTREE_OK=1` 前綴 ⇒ 放行(comment 6587)。 +# 看的是指令字串裡的位置,不是 hook 的環境——hook 的環境裡永遠不會有它。 +ESC=$(printf '%s' "$CMD" | python3 "$RESOLVER" --escape WORKTREE_OK=1 2>/dev/null) || ESC="" +[ "${ESC:-}" = "1" ] && exit 0 + TARGET=$(cd "$CWD" 2>/dev/null && cd "$EXPR" 2>/dev/null && pwd) || exit 0 [ -n "${TARGET:-}" ] || exit 0 diff --git a/hooks/tests/line-needs-own-worktree.test.sh b/hooks/tests/line-needs-own-worktree.test.sh index 860a2af..492b3d2 100755 --- a/hooks/tests/line-needs-own-worktree.test.sh +++ b/hooks/tests/line-needs-own-worktree.test.sh @@ -213,6 +213,38 @@ else printf ' ❌ (59) lib/checkout_target_dir.py 不見了\n'; FAIL=$((FAIL+1)) fi +echo +echo "── E 群:出路要真的走得通(inkstone/ISEP#109 → comment 6587,2026-09-07 實撞)──" +# 為什麼這群非有不可:閘訊息教的出路是 `WORKTREE_OK=1 git -C … checkout …`, +# 而舊版讀的是 **hook 自己的環境變數**。PreToolUse hook 在指令執行之前跑, +# 指令字串裡的 `WORKTREE_OK=1` 是給 git 的前綴賦值,hook 的行程裡根本沒有它 +# ⇒ 那條出路在 Claude Code 這個 harness 下永遠走不通(總管當天三次照貼三次被擋)。 +# 🔴 這群的測資是「閘自己印出來的那一行」,原樣餵回去——驗的是出路跑不跑得動, +# 不是閘擋不擋(mistakes.md「閘擋對了,但它印出來的那條出路跑不動」)。 +# 判準是**位置**不是字:那個賦值必須是「會移動 HEAD 的那條 git 指令」自己的前綴。 + +t 0 "(60) 指令字串裡帶 WORKTREE_OK=1 前綴、hook 的環境裡**沒有**它 ⇒ 放行" \ + "WORKTREE_OK=1 git checkout -b feat/esc" "$REPO" +t 2 "(61) 先擋一次,拿它印出來的出路當下一條的測資" "git checkout main" "$REPO" +ESC_LINE=$(printf '%s' "$LAST_ERR" | grep -F 'WORKTREE_OK=1 git -C' | head -1 | sed 's/^[[:space:]]*//; s/<分支>/main/') +N=$((N+1)) +if [ -n "$ESC_LINE" ]; then printf ' ✅ (62) 抽得出那一行:%s\n' "$ESC_LINE"; PASS=$((PASS+1)) +else printf ' ❌ (62) 訊息裡抽不出 WORKTREE_OK=1 git -C … 那一行\n'; FAIL=$((FAIL+1)); ESC_LINE="WORKTREE_OK=1 git -C $REPO checkout main"; fi +t 0 "(63) 閘印出來的那一行**原樣餵回去**(只填分支、人站在別處)⇒ 放行——6587 那天走不通的就是這條" \ + "$ESC_LINE" "$PLAIN" +t 2 "(64) 只是印了那個字:echo WORKTREE_OK=1; git checkout main ⇒ 照擋(看位置不看字)" \ + "echo WORKTREE_OK=1; git checkout main" +t 2 "(65) 前綴掛在別的指令上:WORKTREE_OK=1 echo hi && git checkout main ⇒ 照擋" \ + "WORKTREE_OK=1 echo hi && git checkout main" +t 2 "(66) 值不是 1:WORKTREE_OK=0 git checkout main ⇒ 照擋" \ + "WORKTREE_OK=0 git checkout main" +t 2 "(67) 寫在 commit 訊息裡再切 ⇒ 照擋(引號裡的字不是前綴)" \ + "git commit -qm 'WORKTREE_OK=1 git checkout main' ; git checkout main" +t 0 "(68) cd 進去再掛前綴:cd && WORKTREE_OK=1 git checkout main ⇒ 放行" \ + "cd $REPO && WORKTREE_OK=1 git checkout main" "$PLAIN" +t 0 "(69) 在自己的 worktree 裡也掛了前綴 ⇒ 本來就放行,前綴不礙事" \ + "WORKTREE_OK=1 git checkout -b feat/x2" "$OWN" + clean echo echo "══ $PASS/$N 通過${FAIL:+,$FAIL 個失敗} ══" diff --git a/scripts/test-github-contact-guard.sh b/scripts/test-github-contact-guard.sh index 2bb8878..79154e2 100755 --- a/scripts/test-github-contact-guard.sh +++ b/scripts/test-github-contact-guard.sh @@ -34,6 +34,43 @@ run 0 'python3 -c "requests.post(url, json={\"body\": \"quoting: git push origin run 0 'grep -n "gh api" hooks/github-contact-guard.sh' run 0 'grep -rn "git push" installer/scripts/line-source-repo.mjs' +echo "── 判「push 到哪」要看這條指令實際會推的那個 repo,不是 hook 站的 cwd(inkstone/ISEP#109 → comment 6629,2026-09-07 實撞)──" +# 為什麼這群非有不可:雲端薄殼的 cwd 是 GitHub 那份(origin=github.com),而總管在 +# `InkStoneCo/`(origin=Gitea)推分支。舊版拿 **hook 收到的 cwd** 去解 `origin`, +# 於是 `git -C push origin x` 被判成「remote origin 指向 GitHub」擋下 +# ——連 `-C` 都沒看。arcrun-hand 同日在 Arcrun#176 comment 6614 撞到同一支。 +# 判準仍是唯一識別碼(remote 解出來的 URL 主機),不是名字;**不是「有 -C 就放行」**—— +# 下面「該擋」那組就是 -C/cd 指到 GitHub 那份時照擋的證據。 +W=$(mktemp -d "${TMPDIR:-/tmp}/isep-ghc.XXXXXX"); trap 'rm -rf "$W"' EXIT +W=$(cd "$W" && pwd -P) +mk(){ git init -q -b main "$1" && git -C "$1" remote add origin "$2"; } +mk "$W/shell" https://github.com/example/shell.git # 薄殼:origin=GitHub +mk "$W/body" https://git.example.invalid/inkstone/body.git # 真身:origin=Gitea 的形狀(主機不是 github.com) +mkdir -p "$W/body/sub" +runc(){ # $1=want $2=cwd $3=cmd —— 跟 run 一樣,只是 payload 帶 cwd(hook 真的會收到它) + LAST_ERR=$(printf '%s' "{\"tool_name\":\"Bash\",\"cwd\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$2"),\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$3")}}" \ + | bash "$H" 2>&1 >/dev/null) + got=$? + if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi + printf 'want=%s got=%s cwd=%s %.60s\n' "$1" "$got" "${2#$W/}" "$3" +} +has(){ if printf '%s' "$LAST_ERR" | grep -qF -- "$1"; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi; printf '%s\n' "$2"; } + +echo " · 該擋:這條指令實際推的那個 repo 的 origin 是 github.com" +runc 2 "$W/shell" 'git push origin x' +runc 2 "$W/body" "git -C $W/shell push origin x" +has "$W/shell" " 訊息說得出是哪個目錄的 remote 指向 GitHub(人才知道自己站錯地方還是推錯地方)" +runc 2 "$W/body" "cd $W/shell && git push origin x" +runc 2 "$W/shell" "(cd $W/body && true); git push origin x" +runc 2 "$W/shell" 'cd $SOMEWHERE && git push origin x' +echo " · 不該擋:這條指令實際推的那個 repo 的 origin 不是 github.com(★=6629 那天被擋的形狀)" +runc 0 "$W/shell" "git -C $W/body push origin x" +runc 0 "$W/shell" "git -C $W/body push -u origin feat/x" +runc 0 "$W/shell" "cd $W/body && git push origin x" +runc 0 "$W/body/sub" 'git push origin x' +runc 0 "$W/shell" "cd $W && git -C body push origin x" +runc 0 "$W/body" "(cd $W/shell && true); git push origin x" + echo echo "$PASS/$((PASS+FAIL)) 通過" [ "$FAIL" -eq 0 ] diff --git a/system-dev/wiki/mistakes.md b/system-dev/wiki/mistakes.md index 5e4e128..2d87cb1 100644 --- a/system-dev/wiki/mistakes.md +++ b/system-dev/wiki/mistakes.md @@ -373,3 +373,41 @@ leo 當場:「**這些為什麼不寫到票裡?**」 正確做法: 匿名優先、匿名被拒(401/403/404)且環境拿得到 token 才帶 token 再問一次(`fetch_gitea()`); 假伺服器要有 private 模式(匿名 404、帶 token 200)照真 Gitea 回;真跑兩條都跑(帶 token 讀到、拔 token 退回)。 收到「X 讀得到/X 不存在」這種宣稱先打一次再動手——那是一個 curl 的成本。 + +## ⚠️ MISTAKE: 閘看的是 hook 自己的環境/cwd,不是指令要動的那個 repo + +票: `inkstone/ISEP#109`(comment 6587/6629) +日期: 2026-09-07 + +症狀: 同一天兩支閘同一個病。① `line-needs-own-worktree.sh` 印的出路 + `WORKTREE_OK=1 git -C … checkout …` 照貼三次三次被擋。 + ② `github-contact-guard.sh` 把 `git -C push origin x` 判成「remote origin 指向 GitHub」擋下 + (總管站在薄殼 cwd;InkStoneCo 的 origin 明明是 Gitea),連 `-C` 都沒看; + 同日 arcrun-hand 在 `Arcrun#176` comment 6614 撞到同一支。當天只能改用完整 Gitea 網址推, + 而那種形狀分類器又時過時不過。 + +實查: ① hook 讀的是 `$WORKTREE_OK` 這個**環境變數**。PreToolUse hook 跟指令不是同一個行程, + 指令字串裡的 `WORKTREE_OK=1` 是給 git 的前綴賦值,hook 的行程裡永遠沒有它 + ⇒ 那條出路在 Claude Code 底下**結構上**走不通,不是偶爾。(`NOT_MY_BRANCH_OK` 同形狀,見下) + ② hook 拿 **payload 的 cwd** 去 `git remote get-url origin`,而 `-C`/`cd … &&` 改的是 + 指令自己的目錄。`main-and-prod-push-guard.sh` 08-23 就為同一個病接了 + `lib/push_target_dir.py`(`inkstone/ISEP#30` comment 3949)——**同一族的另一支沒跟上**, + 跟 `leo21c-write-guard.sh` 那條坑一模一樣(修一份不會帶動另外兩份)。 + 修之前拿新測試跑舊閘:① 3 條紅;② **7 條紅——4 條誤攔之外還有 3 條漏擋** + (站在 Gitea 那份 `git -C <薄殼> push origin x` 舊版放行)。cwd 不只讓它誤擋,也讓 D20 有洞。 + +原因: 兩支都在回答「這條指令會動到哪個 repo」,卻拿「hook 自己站在哪」當答案。 + 本機一份目錄時兩者恰好相等,所以測試全綠;雲端薄殼+真身兩份目錄一分開就露餡。 + 而「出路走不走得通」那一格從來沒被測過——測的只有「擋不擋」。 + +正確做法: + - 出路要是 hook **讀得到**的形狀:認指令字串裡的字面前綴(判準是位置——前綴必須掛在會移動 HEAD + 的那條 git 指令上;印個字、掛在別的指令上、寫在引號裡都不算),或 `gate-ok` 同款的戳記檔。 + - 「這條指令在哪個 repo 執行」一律走 `hooks/lib/push_target_dir.py`/`checkout_target_dir.py` + 解 cd 鏈與 `-C`,解不出來才退回 cwd(退回的方向是照舊,不是放寬)。改判準時 `grep -l` 找同一族 + (`main-and-prod-push-guard`/`github-contact-guard`/`line-needs-own-worktree`/`not-my-branch-guard`)一次看齊。 + - 測試要有一條「把閘印出來的那一行原樣餵回去」(A24 (61)–(63)),還要有 payload 帶 `cwd`、 + cwd 與目標 repo **不同**的案例(A39 後 12 條)——本機一份目錄的測試驗不出這個病。 + +📌 沒在本票修的同形狀:`not-my-branch-guard.sh` 的 `NOT_MY_BRANCH_OK=1 git commit …` 同樣只讀環境變數 + (第 28 行),沒實測、沒動——它認的動作是 commit,不是 push_target_dir 那條線的事,另報。