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:
@@ -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=1(harness 內建 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" # 產品組裝 repo(2026-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 的 worktree(git worktree list 第一筆=$allowed)" >&2
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user