fix: 白名單認的是「哪個 repo」不是路徑前綴(inkstone/ISEP#117)

照 `line-needs-own-worktree.sh` 印的下一步開 worktree(`<repo>-wt-<票號>/`),
接著在裡面寫檔會被 `guard-cross-project.sh` 擋——它的白名單是路徑前綴,
`matrix/arcrun-wt-195/…` 對不上 `matrix/arcrun/*`。
⇒ 照規矩做的人被擋,留在共用目錄裡的人反而過得了(inkstone/Arcrun#195 實撞,
工人只好把 worktree 開到 InkStoneCo 樹之外躲開它)。

判準改成 git 自己算得出來的事實,跟 line-needs-own-worktree 同一個形狀:
`git worktree list --porcelain` 第一筆永遠是主工作目錄 ⇒ 主目錄與它所有的
worktree 回同一個答案。`-wt-` 這個命名慣例一個字都沒有進判準。
名單上沒有的 repo、它的 worktree、根本不是 repo 的目錄,全部照舊擋。

順帶修掉一個獨立的缺陷(實測證據見檔內註解與 wiki):
這支閘在 plugin 裡**從來沒生效過**。它用 `<hook 檔>/../..` 當頂層 repo 根,
搬進 plugin 之後那個路徑指到 plugin cache 的上一層 ⇒ 每一次都靜默放行。
同一份 payload:plugin 那份 exit 0、InkStoneCo 那份 exit 2,而兩個檔案 diff 一字不差。
改用 `$CLAUDE_PROJECT_DIR`(README 路徑規約本來就這樣寫)。
🔴 這也代表:本輪的修法要生效,`InkStoneCo/.claude/hooks/guard-cross-project.sh`
那份現役副本必須退場(刪檔+從 settings.json 取消註冊)——那是頂層的樹,
不在本 PR 裡,交回總管裁。

測試:scripts/test-guard-cross-project.sh(docs/TESTING.md A25)21 條,
真的 git repo + 真的 worktree 當道具,放行 12 條/該擋 9 條兩個方向都驗。
不新增第三支閘。

版本號待總管定版。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 14:46:35 +08:00
parent 4b91cd5d48
commit 7af30e97b0
6 changed files with 251 additions and 12 deletions
+86 -9
View File
@@ -12,8 +12,18 @@
# 放行:頂層自己的檔(CLAUDE.md / .claude/ / docs/ / MEMORY.md…)、
# 以及子 repo 內的 .md(交棒 HANDOFF / 筆記 / 文件——leo 2026-06-15 拍板放行所有 .md)。
#
# 誠實限制(arcrun mindset §7):本 hook 擋的是「路徑語法層」——能擋「寫程式碼進子 repo」,
# 擋不了「把實作偽裝成 .md」或「該交棒卻判斷成可直改」這種語意層越界。它是底線,不是萬能
# 白名單放行的判準是「**這個路徑屬於哪個 repo**」(git 回答),不是路徑前綴——
# 所以 `matrix/arcrun` 的 worktree`matrix/arcrun-wt-<票號>/`)跟主工作目錄一樣放行
# 理由與實撞見下面 `owning_repo_rel()` 上方那段(inkstone/ISEP#117)。
#
# 誠實限制(arcrun mindset §7):
# 1. 本 hook 擋的是「路徑語法層」——能擋「寫程式碼進子 repo」,擋不了「把實作偽裝成 .md」
# 或「該交棒卻判斷成可直改」這種語意層越界。它是底線,不是萬能。
# 2. 🔴 `CLAUDE_CODE_CHILD_SESSION` **不是可靠的「我是 subagent」標記**——
# `InkStoneCo/system-dev/wiki/ops-facts.md` 記著實測:Claude Desktop 的總管主 session
# 也是 `1`。⇒ 在那個 surface 上,下面整段白名單對總管自己也會放行。
# 這一點本輪**沒有動**(那是另一件事,改它要重新評估誰該被擋),
# 但誰讀這支閘都該知道它不是它字面上看起來的那道夾子。
input="$(cat)"
@@ -30,13 +40,31 @@ except Exception:
# 拿不到路徑就放行(不誤擋;hook 不該因解析失敗卡死正常操作)
[ -z "$file_path" ] && exit 0
# 頂層 repo 根hook 檔位於 <root>/.claude/hooks/
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# 頂層 repo 根 =「現在在操作哪個專案」,那正是 $CLAUDE_PROJECT_DIR 的定義。
#
# 🔴 舊版拿 `<hook 檔>/../..` 當答案。那在 `InkStoneCo/.claude/hooks/` 底下剛好對,
# 但這支閘搬進 plugin 之後路徑變成 `<plugin>/hooks/``../..` 指到 plugin cache 的
# 上一層 ⇒ 絕對路徑的前綴永遠剝不掉 ⇒ rel 永遠留著 `/Users/…` ⇒ 永遠不符合
# `matrix/*` ⇒ **這支閘在 plugin 裡從來沒生效過**。
# 2026-09-01 實測(inkstone/ISEP#117),同一份 payload(寫 matrix/arcrun/foo.ts):
# plugin 那份 → exit 0(放行)
# InkStoneCo 那份 → exit 2(擋)
# ⇒ 真正在擋的一直是 InkStoneCo 的副本。這也是 README「路徑規約」寫的那條:
# 指專案內的檔案要用 `$CLAUDE_PROJECT_DIR`,不要拿 hook 自己的位置去推。
# 後面那個 `../..` 只留作 fallback(沒有這個環境變數時,例如手動 pipe 進來測)。
repo_root="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
# 同一個目錄的**實體路徑**symlink 解開)。macOS 的 `/tmp`→`/private/tmp`、
# `/var`→`/private/var` 就是這個形狀,而 `git worktree list` 回的一律是實體路徑
# ⇒ 兩個版本都要留著比,不然「明明是同一個目錄」會比不出來。
repo_root_phys="$(cd "$repo_root" 2>/dev/null && pwd -P)"
[ -n "${repo_root_phys:-}" ] || repo_root_phys="$repo_root"
# 轉成相對 repo_root 的路徑(file_path 可能是絕對或相對)
case "$file_path" in
/*) rel="${file_path#"$repo_root"/}" ;; # 絕對路徑 → 去掉 repo_root 前綴
*) rel="$file_path" ;; # 相對路徑 → 假定相對 repo_root
/*) rel="${file_path#"$repo_root"/}" # 絕對路徑 → 去掉 repo_root 前綴
rel="${rel#"$repo_root_phys"/}" ;; # 實體路徑版也試一次
*) rel="$file_path" ;; # 相對路徑 → 假定相對 repo_root
esac
# 不在子 repo 目錄內 → 放行(頂層自己的檔)
@@ -48,8 +76,10 @@ esac
# 自動派工放行(2026-06-28 leo 拍板上線,實測通過):
# 子 repo 的 subagent 可直接寫該 repo code(繞 D9)——因 subagent 心智已是該 repo
# A/B 實驗證 ≈ claude -p),讓「心智是該 repo 的小弟寫該 repo」符合 context 隔離。
# 雙重夾:① CHILD_SESSION=1harness 內建 subagent 標籤,總管主 session 無此標籤仍被擋)
# ② 路徑在下面白名單 array 內。
# 雙重夾:① CHILD_SESSION=1 ② 路徑屬於下面白名單 array 上的 repo。
# ⚠️ ①**沒有它字面上看起來那麼緊**:原註解寫「總管主 session 無此標籤仍被擋」,
# 但 `InkStoneCo/system-dev/wiki/ops-facts.md` 記著實測——Claude Desktop 的
# 總管主 session 也是 `1`。⇒ 在那個 surface 上這一夾是空的。本輪沒有動它。
# 隔離保證=prompt reset(軟,靠監測)非結構級;要絕對純走 claude -p。心法見 wiki。
#
# 🔧 leo 維護:要放開一個 repo 給 subagent 自動寫 code,就在這 array 加一行路徑前綴。
@@ -59,11 +89,58 @@ AUTODISPATCH_ALLOW=(
"matrix/arcrun" # 共用框架(影響全部人,走 PR 把關)
"products/arcrun-rag" # 產品組裝 repo2026-07-13 leo 放行 G8:回覆「放行」)
)
if [ "$CLAUDE_CODE_CHILD_SESSION" = "1" ]; then
# ── 白名單比對的是「哪個 repo」,不是「哪個路徑前綴」(inkstone/ISEP#117)─────────
#
# 為什麼改:`line-needs-own-worktree.sh` 印出來的下一步是
# git -C <TOP> worktree add <PARENT>/<NAME>-wt-<票號> -b <分支>
# 照做之後,`matrix/arcrun` 的線會在 `matrix/arcrun-wt-195/` 裡動工——
# 而舊版這裡是純前綴比對,`matrix/arcrun-wt-195/…` 不符合 `matrix/arcrun/*` ⇒ **擋**。
# ⇒ 兩道閘互相打架:**照規矩開 worktree 的人被擋,留在共用目錄裡的人反而過得了**。
# 2026-08-31 `inkstone/Arcrun#195` 實撞(見票上原文),工人只好把 worktree 開到
# InkStoneCo 樹之外躲開這一支——閘把人逼去繞路,那條規矩就等於沒有。
#
# 判準用的是 git 自己算出來的事實,跟 `line-needs-own-worktree.sh` 同一個形狀:
# `git worktree list --porcelain` **第一筆永遠是主工作目錄**(不管你從哪一份問)。
# ⇒ 那一筆就是這個路徑「屬於哪個 repo」的唯一識別碼。
# ⇒ 主工作目錄與它所有的 worktree 都會回同一個答案,`-wt-` 這個命名慣例
# 一個字都不用出現在判準裡(不靠名字=改名字也騙不過去)。
#
# 🔴 這是「拿唯一識別碼往**放行**的方向比對」,不是關鍵字比對:
# 名單上沒有的 repo、它的 worktree、以及根本不是 repo 的目錄,全部照舊被擋。
owning_repo_rel() {
local d="$1" main
# 檔案還不存在也要答得出來(Write 新檔)⇒ 往上走到第一個真的存在的目錄
while [ -n "$d" ] && [ "$d" != "/" ] && [ ! -d "$d" ]; do d="$(dirname "$d")"; done
[ -d "$d" ] || return 1
main="$(git -C "$d" worktree list --porcelain 2>/dev/null | sed -n '1s/^worktree //p')"
[ -n "$main" ] || return 1
# 🔴 `git worktree list` 回的一律是**實體路徑**(symlink 都解開了)。
# macOS 的 `/tmp`→`/private/tmp`、`/var`→`/private/var` 就是這個形狀,
# 所以兩邊都要用實體路徑比,不然「明明是同一個目錄」會比不出來。
case "$main" in
# 只認「頂層樹底下的子 repo」。走到這裡若答案是頂層樹自己(=那個目錄根本
# 不是獨立的 repo,git 一路往上找到了頂層),就回 1 ⇒ 照舊擋,不放寬。
"$repo_root"/*) printf '%s' "${main#"$repo_root"/}" ;;
"$repo_root_phys"/*) printf '%s' "${main#"$repo_root_phys"/}" ;;
*) return 1 ;;
esac
}
if [ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ]; then
case "$file_path" in
/*) abs="$file_path" ;;
*) abs="$repo_root/$file_path" ;;
esac
owner="$(owning_repo_rel "$(dirname "$abs")" 2>/dev/null || true)"
for allowed in "${AUTODISPATCH_ALLOW[@]}"; do
case "$rel" in
"$allowed"/*) echo "🤝 [guard] subagent 放行:$allowed(自動派工)" >&2; exit 0 ;;
esac
if [ -n "${owner:-}" ] && [ "$owner" = "$allowed" ]; then
echo "🤝 [guard] subagent 放行:$rel 屬於 $allowed 的 worktreegit worktree list 第一筆=$allowed" >&2
exit 0
fi
done
fi