Files
ISEP/hooks/guard-cross-project.sh
T
Leo 7af30e97b0 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>
2026-09-01 14:46:35 +08:00

171 lines
9.5 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# guard-cross-project.sh — 頂層總管 guardrail
#
# 職責邊界(InkStoneCo CLAUDE.md D9):頂層只做「跨專案的整理與安排」(總管),
# 不進單一子 repo 做實作。子 repo 的程式碼/設定實作 = 在那個子 repo 的專案裡執行,
# 頂層只負責「交棒」(寫 docs/HANDOFF-*.md)並記錄安排。
#
# 本 hook 攔 PreToolUse 的 Write/Edit/MultiEdit
# - 目標路徑落在子 repo 目錄(matrix/ polaris/ products/ _archive/)內
# - 且不是 .md 文件
# → 擋下(exit 2),提示改用交棒(寫 HANDOFF)。
# 放行:頂層自己的檔(CLAUDE.md / .claude/ / docs/ / MEMORY.md…)、
# 以及子 repo 內的 .md(交棒 HANDOFF / 筆記 / 文件——leo 2026-06-15 拍板放行所有 .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)"
# 取 file_pathWrite/Edit/MultiEdit 都用 tool_input.file_path
file_path="$(printf '%s' "$input" | python3 -c '
import sys, json
try:
d = json.load(sys.stdin)
print(d.get("tool_input", {}).get("file_path", ""))
except Exception:
print("")
' 2>/dev/null || true)"
# 拿不到路徑就放行(不誤擋;hook 不該因解析失敗卡死正常操作)
[ -z "$file_path" ] && exit 0
# 頂層 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="${rel#"$repo_root_phys"/}" ;; # 實體路徑版也試一次
*) rel="$file_path" ;; # 相對路徑 → 假定相對 repo_root
esac
# 不在子 repo 目錄內 → 放行(頂層自己的檔)
case "$rel" in
matrix/*|polaris/*|products/*|_archive/*) : ;; # 落在子 repo,繼續判斷
*) exit 0 ;;
esac
# 自動派工放行(2026-06-28 leo 拍板上線,實測通過):
# 子 repo 的 subagent 可直接寫該 repo code(繞 D9)——因 subagent 心智已是該 repo
# A/B 實驗證 ≈ claude -p),讓「心智是該 repo 的小弟寫該 repo」符合 context 隔離。
# 雙重夾:① 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 加一行路徑前綴。
# ⚠️ 加 = 放權,想清楚私人/共用(改它影響誰)再加。array 空 = 誰都不放(安全預設)。
AUTODISPATCH_ALLOW=(
"polaris/mira" # 私人環境(只影響 leo
"matrix/arcrun" # 共用框架(影響全部人,走 PR 把關)
"products/arcrun-rag" # 產品組裝 repo2026-07-13 leo 放行 G8:回覆「放行」)
)
# ── 白名單比對的是「哪個 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
# 在子 repo 內:.md 放行(交棒 / 文件),其餘擋
case "$rel" in
*.md|*.MD|*.markdown) exit 0 ;;
esac
# 走到這 = 寫非 .md 檔進子 repo 目錄 = 越界實作 → 擋
sub="${rel%%/*}"
cat >&2 <<EOF
❌ BLOCKED by InkStoneCo 總管 guardrail(職責邊界 D9
違反:頂層總管直接寫實作檔進子 repo($sub/)。
路徑:$rel
頂層只做「跨專案的整理與安排」,不進單一 repo 做實作。
子 repo 的程式碼 / 設定 = 在該子 repo($sub)的專案裡改,不在頂層。
正確做法:
• 要子 repo 改什麼 → 寫一份交棒文件:$sub/docs/HANDOFF-*.md.md 放行),
給判準與論證,成品由該 repo 的 CC 按它自己的 rules 產出。
• 真要由你(總管)動這個子 repo → 切換到那個子 repo 的工作脈絡再做,不在頂層越界。
參考:CLAUDE.md「職責邊界(核心原則)」+ decisions-summary D9。
EOF
exit 2