#!/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 的定義。 # # 🔴 舊版拿 `/../..` 當答案。那在 `InkStoneCo/.claude/hooks/` 底下剛好對, # 但這支閘搬進 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 worktree add /-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 <