7af30e97b0
照 `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>
171 lines
9.5 KiB
Bash
Executable File
171 lines
9.5 KiB
Bash
Executable File
#!/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_path(Write/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" # 產品組裝 repo(2026-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 的 worktree(git 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
|