87186585d6
症狀(總管 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>
235 lines
13 KiB
Bash
Executable File
235 lines
13 KiB
Bash
Executable File
#!/bin/bash
|
||
# 管什麼: Write/Edit 動 code 檔(.ts/.py/.go…)前,要不要有對應的一份 status: active SDD(design.md)。
|
||
# 為什麼: SDD 生命週期鐵律——動 code 前必須有規格可對,且整個 repo 同一時刻只准一份 active。
|
||
# 把「動手前先讀 SDD」從只能靠人記,升級成機器擋(system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
|
||
# 誤觸時怎麼關: 改文件/測試檔/3-specs 自己一律放行(下方 case 已排除);不在任何 git repo
|
||
# 裡的路徑(scratchpad、/tmp 暫存檔)一律放行,SDD 管不到它們。真的要臨時豁免
|
||
# 一次小改動,說明範圍後由人手動放行——這支閘不設「一行關掉」的旗標。
|
||
#
|
||
# PreToolUse hook — 動 code 前檢查 SDD + 單一活性 SDD 鐵律(issue #6)
|
||
# wishlist §2:把 /sdd-check 從「命令要人打」升級成「hook 自動攔」。
|
||
# 生命週期規則全文:system-dev/docs/3-specs/SDD-LIFECYCLE.md
|
||
#
|
||
# 掛在 settings.json 的 PreToolUse(matcher: Write|Edit)。
|
||
# stdin 收到 JSON:{ tool_name, tool_input: { file_path, ... } }
|
||
# 行為:
|
||
# 1. status: active 的 SDD > 1 份 → 單一活性鐵律已被違反,**不論寫什麼檔**一律擋(exit 2),
|
||
# 先收斂到一份再說。
|
||
# 2. 動 code 檔(.ts/.go/...)→ 需要「恰好 1 份」active SDD;0 份 → 擋。
|
||
# 3. 向下相容:3-specs 下完全沒有任何 design.md 帶 frontmatter(老 repo 尚未遷移生命週期制度)
|
||
# → 退回舊行為:有 design.md 就放行+提醒,沒有才擋。避免 template update 後老 repo 立刻全紅。
|
||
#
|
||
# 誠實限制(抄 arcrun):只擋語法層明顯違規(直接寫 code 檔)。
|
||
# 藏在 helper 裡、用 bash 繞道的改動擋不到。
|
||
# 價值是「想跳過會被抓到 + 留痕可審」,不是技術防偽。絕不聲稱「不可能繞過」。
|
||
|
||
set -euo pipefail
|
||
|
||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/path-resolve.sh"
|
||
|
||
INPUT=$(cat)
|
||
|
||
# 解析 file_path。優先用 jq,沒有 jq 退回 grep(容錯)。
|
||
if command -v jq >/dev/null 2>&1; then
|
||
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||
else
|
||
FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//')
|
||
fi
|
||
|
||
# 拿不到路徑 → 不擋(容錯,寧可放過也不誤殺)
|
||
[ -z "$FILE_PATH" ] && exit 0
|
||
|
||
# 🔴 2026-08-02 修:原本寫死相對路徑 `system-dev/docs/3-specs`,
|
||
# 但 hook 的工作目錄是**頂層 InkStoneCo**,改子 repo 的 code 時就去頂層找 SDD
|
||
# ⇒ 看不到子 repo 自己那份 ⇒ **一律誤報「找不到任何 SDD」**。
|
||
# 實撞:改 products/arcrun-rag/collector/... 被擋,但該 repo 明明有
|
||
# system-dev/docs/3-specs/daemon-beta/design.md(status: active)。
|
||
# ⇒ 改成從被改檔案往上找最近的 system-dev/docs/3-specs(子 repo 優先,找不到才用頂層)。
|
||
# ⚠️ 只往上找到「頂層 InkStoneCo」為止——不可讓任意路徑(如 /private/tmp/…)
|
||
# 退回頂層 SDD 而被放行,那會把原本擋得住的情況變成擋不住。
|
||
#
|
||
# 🔴 2026-08-20 修(inkstone/InkStoneCo#22):上面這套邏輯有兩個洞,都是總管 08-12 實撞的:
|
||
#
|
||
# 洞 A — scratchpad 暫存檔被當成「code 變動」:
|
||
# `/private/tmp/.../scratchpad/foo.py` 不在 `$_root` 底下、往上找不到 3-specs,
|
||
# 於是走到「找不到 SDD」擋下路徑——但 scratchpad 是 session 專用暫存區,從不進版控,
|
||
# SDD 管的是 repo 裡的產品程式碼,管不到它。**先問「這條路徑到底在不在某個 git repo
|
||
# 裡」(`path_in_git_worktree`,見 lib/path-resolve.sh),不在 ⇒ 這道閘天生管不到
|
||
# ⇒ 直接放行**,不必先繞去猜專案根、再證明找不到才擋。
|
||
# 用「有沒有 .git 可尋」判斷,比列舉路徑關鍵字(/tmp、scratchpad…)更穩:
|
||
# 不必窮舉每一種暫存區的命名法,任何真的不在 repo 裡的路徑都一視同仁。
|
||
#
|
||
# 洞 B — 訊息裡印出字面的 `/nonexistent`:
|
||
# 舊版用 `/nonexistent/3-specs` 當內部 sentinel,讓「找不到 SDD」的既有擋下路徑可以
|
||
# 重用;但這個 sentinel 值被直接印進使用者看到的訊息,讀起來像是「這支腳本認真去
|
||
# /nonexistent 這個地方找過」——具體、卻是假的。改成用 RESOLVED 旗標記「解析成不成功」,
|
||
# 擋下訊息另外用人話描述「為什麼找不到」,不洩漏內部實作用的假路徑。
|
||
#
|
||
# ⚠️ 洞 A/B 都不改變「真的解析失敗時」的判定方向:路徑確實落在某個 git repo 裡,
|
||
# 但那個 repo 沒有 3-specs(或裡面沒有 active SDD)→ 仍然 **fail-closed**(擋,不放行)。
|
||
# 為什麼是 fail-closed、不是 fail-open:這道閘存在的目的就是防止「沒有 SDD 卻能動
|
||
# code」,若把「判斷不出來」直接放行,等於把一次環境跑歪(cwd 被切走、
|
||
# `$CLAUDE_PROJECT_DIR` 沒設、worktree 缺 3-specs…)悄悄變成「這道閘關掉了,而且沒有
|
||
# 任何人被告知」——silent bypass 的代價遠高於「多打一次確認」。#22 的紅線也明寫
|
||
# 「不要把閘改成『解析失敗就放行』——那是把誤判換成漏判」。
|
||
# 洞 A 的修法,套用在 case 分岔**之前**:不管 `$_root` 猜不猜得對,
|
||
# 先問「這條路徑到底在不在某個 git repo 裡」。不在 ⇒ SDD 這道閘天生管不到,直接放行。
|
||
# 🔴 這個檢查故意放在 `$FILE_PATH` 是否落在 `$_root` 底下的判斷之前、且對兩邊都適用
|
||
# (不是只套用在「專案外」那個分支):第一版只把它放進「專案外」分支,結果測試
|
||
# (hooks/tests/sdd-guard.test.sh)就抓到一個不對稱漏洞——當 `$_root` 剛好等於
|
||
# scratchpad 的某層祖先目錄(例如 hook 被叫用時 cwd 已經跑到 /private/tmp 底下、
|
||
# `$CLAUDE_PROJECT_DIR` 也沒設),scratchpad 路徑會被判成「在 `$_root` 底下」而
|
||
# 走進另一條完全沒做 git-repo 檢查的路徑,同一個誤判换個路徑重新出現。
|
||
# 改成「先問是不是在 git repo 裡,不管路徑跟 `$_root` 的關係」就沒有這個不對稱。
|
||
if ! path_in_git_worktree "$FILE_PATH"; then
|
||
exit 0
|
||
fi
|
||
|
||
_root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||
# 預設值一律絕對路徑(不留相對路徑「system-dev/docs/3-specs」退回目前 cwd 的洞——
|
||
# 舊版這裡曾經是相對路徑,若專案內迴圈找不到就會被拿去跟 hook 執行當下的 cwd 兜,
|
||
# cwd 湊巧有同名目錄就會判斷到不相干的資料)。
|
||
SPECS_DIR="$_root/system-dev/docs/3-specs"
|
||
RESOLVED=1 # 1=SPECS_DIR 是有意義的答案;0=真的解析失敗,SPECS_DIR 留空,訊息另外講原因
|
||
|
||
case "$FILE_PATH" in
|
||
"$_root"/*)
|
||
_d=$(dirname "$FILE_PATH")
|
||
while [ "$_d" != "/" ] && [ -n "$_d" ]; do
|
||
if [ -d "$_d/system-dev/docs/3-specs" ]; then
|
||
SPECS_DIR="$_d/system-dev/docs/3-specs"
|
||
break
|
||
fi
|
||
[ "$_d" = "$_root" ] && break
|
||
_d=$(dirname "$_d")
|
||
done
|
||
;;
|
||
*)
|
||
# 專案外的路徑:`$_root` 猜錯,或這條路徑本來就不屬於目前的 `$_root`。
|
||
# 已知落在某個 git repo 裡(上面剛確認過):往上找它自己的 3-specs。
|
||
# **不可退回 `$_root` 的 3-specs 就放行**——那會把「這個 repo 沒有 SDD」
|
||
# 誤判成「用別的 repo 的 SDD 蒙混過關」,原本擋得住的會變成擋不住。
|
||
SPECS_DIR=""
|
||
RESOLVED=0
|
||
_d=$(dirname "$FILE_PATH")
|
||
while [ "$_d" != "/" ] && [ -n "$_d" ]; do
|
||
if [ -d "$_d/system-dev/docs/3-specs" ]; then
|
||
SPECS_DIR="$_d/system-dev/docs/3-specs"
|
||
RESOLVED=1
|
||
break
|
||
fi
|
||
_d=$(dirname "$_d")
|
||
done
|
||
;;
|
||
esac
|
||
|
||
# 給訊息用的人話描述:解析成功就印真路徑,失敗就誠實講「為什麼」,不印假路徑
|
||
# (洞 B 的修法——舊版這裡印的是內部 sentinel `/nonexistent/3-specs`)。
|
||
if [ "$RESOLVED" -eq 1 ]; then
|
||
SPECS_DIR_DESC="${SPECS_DIR}/"
|
||
SPECS_NOT_FOUND_MSG="${SPECS_DIR}/ 下找不到任何 SDD"
|
||
SPECS_NOT_ACTIVE_MSG="${SPECS_DIR}/ 下沒有任何 status: active 的 SDD"
|
||
else
|
||
SPECS_DIR_DESC=""
|
||
SPECS_NOT_FOUND_MSG="這條路徑所在的 git repo 裡找不到 system-dev/docs/3-specs,也就沒有任何 SDD 可對(或這支閘沒能定位到正確的專案根——這是 fail-closed:寧可誤擋也不悄悄放行,見檔頭註解)"
|
||
SPECS_NOT_ACTIVE_MSG="$SPECS_NOT_FOUND_MSG"
|
||
fi
|
||
|
||
# ── 統計 active / frontmatter ──────────────────────
|
||
# 排除 archive/(已封存)與 TEMPLATE(範本自帶 status: draft frontmatter,不算數——
|
||
# 否則 update 一鋪新版 TEMPLATE-sdd,老 repo 就被誤判「已遷移」而全紅,向下相容破功)。
|
||
# frontmatter 判定=design.md 前 10 行有 ^status: 行(機器可查,見 SDD-LIFECYCLE.md)。
|
||
ACTIVE_COUNT=0
|
||
FM_COUNT=0
|
||
ACTIVE_LIST=""
|
||
if [ -n "$SPECS_DIR" ] && [ -d "$SPECS_DIR" ]; then
|
||
while IFS= read -r f; do
|
||
[ -n "$f" ] || continue
|
||
HEAD10=$(head -10 "$f" 2>/dev/null || true)
|
||
if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*'; then
|
||
FM_COUNT=$((FM_COUNT + 1))
|
||
if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*active'; then
|
||
ACTIVE_COUNT=$((ACTIVE_COUNT + 1))
|
||
ACTIVE_LIST="${ACTIVE_LIST} • ${f}
|
||
"
|
||
fi
|
||
fi
|
||
done < <(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null)
|
||
fi
|
||
|
||
# ── 鐵律 1:單一活性被違反(active > 1)→ 不論寫什麼檔一律擋 ──
|
||
if [ "$ACTIVE_COUNT" -gt 1 ]; then
|
||
cat >&2 <<EOF
|
||
🚫 SDD 單一活性鐵律違反:偵測到 ${ACTIVE_COUNT} 份 status: active 的 SDD(任何時刻整個 repo 最多一份):
|
||
${ACTIVE_LIST}
|
||
請先收斂到一份:其餘改 status: paused / closed(closed 且被取代者填 superseded_by 並移入 3-specs/archive/)。
|
||
規則全文見 system-dev/docs/3-specs/SDD-LIFECYCLE.md。收斂前擋下所有寫檔。
|
||
(本 hook 攔 Write/Edit;修 frontmatter 可用 bash 直改,或由人裁決哪份是現行。)
|
||
EOF
|
||
exit 2
|
||
fi
|
||
|
||
# 只管 code 檔。docs/markdown/設定檔等放行。
|
||
case "$FILE_PATH" in
|
||
*.ts|*.tsx|*.js|*.jsx|*.go|*.py|*.rs|*.java|*.rb|*.php|*.c|*.cpp|*.h|*.hpp|*.swift|*.kt) ;;
|
||
*) exit 0 ;;
|
||
esac
|
||
|
||
# 改 SDD 自己 / 測試檔 → 放行
|
||
case "$FILE_PATH" in
|
||
*system-dev/docs/3-specs/*) exit 0 ;;
|
||
*_test.*|*.test.*|*.spec.*|*/tests/*|*/test/*) exit 0 ;;
|
||
esac
|
||
|
||
# ── 向下相容:整個 3-specs 沒有任何帶 frontmatter 的 design.md ──
|
||
# =老 repo 還沒遷移生命週期制度 → 退回舊行為(有 design.md 就放行+提醒),
|
||
# 避免 template update 一裝新 hook,老 repo 所有 code 寫入立刻全紅。
|
||
if [ "$FM_COUNT" -eq 0 ]; then
|
||
SDD_COUNT=0
|
||
if [ -n "$SPECS_DIR" ] && [ -d "$SPECS_DIR" ]; then
|
||
SDD_COUNT=$(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null | wc -l | tr -d ' ')
|
||
fi
|
||
|
||
if [ "$SDD_COUNT" -eq 0 ]; then
|
||
cat >&2 <<EOF
|
||
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_NOT_FOUND_MSG}。
|
||
|
||
絕對鐵律:任何 code 變動前必須有對應 SDD(design.md),且遵守單一活性生命週期
|
||
(system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
|
||
|
||
請先:
|
||
1. 確認這個改動屬於哪個子系統
|
||
2. 在 [子系統的] system-dev/docs/3-specs/[子系統]/ 建立 design.md(可用 /sdd-check 協助),frontmatter 標 status: active
|
||
3. 在回覆開頭宣告已讀 SDD + 對應 task
|
||
|
||
小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。
|
||
EOF
|
||
exit 2
|
||
fi
|
||
|
||
# 舊行為放行 + 提醒遷移(stderr 警告,不擋)
|
||
echo "📋 提醒:${SPECS_DIR_DESC} 有 SDD 但尚未掛生命週期 frontmatter(老結構)。動手前確認已讀對應 design.md;建議依 SDD-LIFECYCLE.md 補 status 標記(現行那份標 active)。" >&2
|
||
exit 0
|
||
fi
|
||
|
||
# ── 新行為:寫 code 檔需「恰好 1 份」active SDD ──
|
||
if [ "$ACTIVE_COUNT" -eq 0 ]; then
|
||
cat >&2 <<EOF
|
||
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_NOT_ACTIVE_MSG}。
|
||
|
||
單一活性鐵律:所有開發任務唯一對應源=那份 active SDD(規則見 system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
|
||
|
||
請先(擇一,都是人的決定,CC 不得自行建 SDD):
|
||
1. 把現行規格的 design.md frontmatter 標成 status: active(一份、只能一份)
|
||
2. 或依 SDD-LIFECYCLE.md 第 3、4 條:proposal 進 pending-changes.md → 使用者 confirm → 開新 SDD 標 active
|
||
然後在回覆開頭宣告已讀 active SDD + 對應 task。
|
||
|
||
小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。
|
||
EOF
|
||
exit 2
|
||
fi
|
||
|
||
# 恰好 1 份 active:放行,留痕提醒要宣告(stderr 警告,不擋)
|
||
printf '📋 提醒:現行 active SDD=\n%s動手前請確認已讀它的 design.md、對應到 tasks,並在回覆宣告。\n' "$ACTIVE_LIST" >&2
|
||
exit 0
|