Files
ISEP/hooks/lib/path-resolve.sh
Leo 87186585d6 fix(hooks): sdd-guard.sh 修「解析失敗仍照擋、且訊息洩漏 /nonexistent」(InkStoneCo#22)
症狀(總管 2026-08-12 實撞):寫暫存腳本進 scratchpad
(/private/tmp/.../scratchpad/foo.py)被 sdd-guard.sh 攔下,訊息印出字面的
「/nonexistent/3-specs/ 下找不到任何 SDD」。

兩個洞:
- 洞 A:scratchpad 不在任何 git repo 裡,卻被當成「repo 裡的 code 變動」誤判
  需要 SDD。改成先問 path_in_git_worktree()(見 hooks/lib/path-resolve.sh):
  不在任何 git repo 裡 → SDD 天生管不到,直接放行,不必先猜專案根。
  這個檢查放在 $_root 的 case 分岔之前、對兩邊都適用——第一版只放進「專案外」
  分支,被本次新增的 hooks/tests/sdd-guard.test.sh 抓到一個不對稱漏洞(cwd 剛好
  等於 scratchpad 祖先目錄時會漏判),改成統一檢查後修掉。
- 洞 B:舊版用內部 sentinel `/nonexistent/3-specs` 重用既有的擋下路徑,但這個
  假路徑被直接印進使用者看到的訊息。改用 RESOLVED 旗標記解析成不成功,訊息
  改用人話描述原因,不洩漏假路徑。

fail-closed / fail-open 的判準(票上明確要求回答,不能各憑運氣):
真的落在某個 git repo 裡、但那個 repo 沒有 3-specs(或沒有 active SDD)→
仍然 fail-closed(擋)。理由:這道閘存在的目的就是防止「沒有 SDD 卻能動
code」,把「判斷不出來」直接放行,等於把環境跑歪(cwd 被切走、
$CLAUDE_PROJECT_DIR 沒設)悄悄變成「這道閘關掉了、且沒人知道」——silent
bypass 的代價遠高於多打一次確認。#22 紅線亦明寫「不要把閘改成解析失敗就
放行」。

順手修的殘留 cwd 依賴:SPECS_DIR 的預設值原本是相對路徑
「system-dev/docs/3-specs」,專案內迴圈找不到時會被拿去跟 hook 執行當下的
cwd 兜;改成絕對路徑 $_root/system-dev/docs/3-specs。

同時修 ADR-0001(ISEP 自建 wiki):標題與內文原本會讓人誤解成「ISEP plugin
裝到哪個 repo,就會在那裡自建一份 wiki」,但實際查證(marketplace.json 只宣告
hooks/commands/skills、README 明文排除 wiki/docs、hooks 一律用
${CLAUDE_PLUGIN_ROOT} 讀自己不是寫別處)並非如此——那份 wiki 只是 ISEP 這個
repo自己的開發歷史,跟裝 plugin 無關。唯一真的會在某 repo 建 wiki 的
scripts/install.sh 是 system-dev-template 的獨立安裝器殘留,要手動執行,
作用對象是 cwd 不是「plugin 裝到的地方」——這多半是誤解的真正來源,已在
ADR 的「常見誤解」段說明。

驗證:
- 造出 08-12 原始事故情境(cwd=InkStoneCo、CLAUDE_PROJECT_DIR 未設、寫
  scratchpad),修前擋(印 /nonexistent)、修後放行——實測輸出見票留言。
- 造出「真的在 git repo 裡但沒有 3-specs」情境,修後仍擋、訊息不含
  /nonexistent。
- 新增 hooks/tests/sdd-guard.test.sh:8 案例全過(洞 A/洞 B/fail-open
  陷阱/單一活性違反/恰好一份 active/改文件放行)。
- 既有六套 scripts/test-*.sh 全過,無退步。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 21:03:14 +08:00

48 lines
3.0 KiB
Bash
Raw Permalink 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.
# hooks/lib/path-resolve.sh — 共用:判斷一個檔案路徑「歸不歸某個 git repo 管」。
# 不是獨立掛的閘(沒進 hooks.json),給其他 PreToolUse 閘 `source` 用的函式庫。
#
# 背景(inkstone/InkStoneCo#22):sdd-guard.sh 曾經把 scratchpad 暫存檔
# `/private/tmp/.../scratchpad/foo.py`)誤判成「repo 裡的 code 變動」而擋下——
# 因為它只會「猜專案根($CLAUDE_PROJECT_DIR 或 cwd)+往上找 3-specs」,
# 猜錯或猜不到時,找不到 3-specs 就一律當「找不到 SDD」擋下,連「這條路徑根本不在
# 任何 repo 裡、SDD 這件事天生管不到它」都沒判斷過。
#
# path_in_git_worktree 提供一個不必先猜對專案根的判法:直接問 git
# 「這個路徑在不在某個 repo 的工作樹裡」。不必窮舉暫存區的路徑關鍵字(/tmp、scratchpad…),
# 任何真的不在 git repo 裡的路徑,一律視同「這是暫存/非受管檔案」。
#
# 同一個 `${CLAUDE_PROJECT_DIR:-$(pwd)}` 猜根目錄寫法,實測(2026-08-20)還出現在:
# component-guard.sh、factory-idle-guard.sh、github-contact-guard.sh、
# history-first-guard.sh、main-and-prod-push-guard.sh、no-ticket-no-dispatch.sh、
# not-my-branch-guard.sh、release-tag-guard.sh、skill-deploy-drift-guard.sh、
# stage-before-prod-guard.sh、unpushed-police.sh、wiki-first-police.sh。
# 另有 claim-verify-police.sh、subagent-claim-worksheet.sh、empty-handed-stop-guard.sh、
# issue-status-autoflip.sh 直接寫 `$CLAUDE_PROJECT_DIR`(無 `:-` fallback)——
# 這批在該變數未設時行為又不一樣,同一個病的另一種長相。
# 這些全部沒有本檔「先確認到底在不在 repo 裡」的判斷;本檔先在 sdd-guard.sh 落地,
# 其餘要不要跟進、要不要改用這支共用函式,另案處理,不在本票(#22)範圍內一次改完。
#
# 用法:
# source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/path-resolve.sh"
# if ! path_in_git_worktree "$FILE_PATH"; then
# # 不在任何 git repo 裡 ⇒ 這支閘通常管不到,多半該放行
# fi
# path_in_git_worktree <path>
# 回傳 0=這個路徑落在某個 git 工作樹裡;1=不在任何 git repo 裡(含路徑本身不存在的情況)。
# 做法:從路徑的目錄部分開始,往上找到「第一個真的存在的祖先目錄」,
# 對那個目錄問 `git rev-parse --is-inside-work-tree`。
# 為什麼要往上找存在的祖先,不能直接對 dirname 問:
# 要在 repo 裡建一個還沒建立的子目錄下的新檔案時,dirname 也不存在,
# 若不往上找,`git -C <不存在的目錄>` 會直接失敗 ⇒ 誤判成「不在 repo 裡」
# ⇒ 放行了本來該擋的東西(fail-open 的洞,不是這支函式該製造的)。
path_in_git_worktree() {
local p="$1" d
d=$(dirname -- "$p")
while [ ! -d "$d" ] && [ "$d" != "/" ]; do
d=$(dirname -- "$d")
done
[ -d "$d" ] || return 1
git -C "$d" rev-parse --is-inside-work-tree >/dev/null 2>&1
}