修好一次性戳記恆擋:InkStoneCo 專案版 hooks 與 plugin 雙掛載
inkstone/ISEP#122:leo 三次要部署被 prod-write-guard.sh 恆擋,蓋了戳記 也放不了行。根因不在 prod-write-guard.sh 本身,而在 InkStoneCo/.claude/settings.json 還登記著一份 2026-08-20 立 ISEP plugin 之前的舊 hook,跟 plugin 自己的 hooks.json 同時掛在同一個事件上——每個 PreToolUse 都跑了兩次,一次性戳記被第一份消耗掉,第二份永遠看到空戳記。 實查(在這台機器的樹上實數):InkStoneCo 專案版登記著 38 支 hook, 34 支跟 plugin 完全同名同用途(純殘骸)、3 支是已退役機制 (claim-verify-police.sh/subagent-claim-worksheet.sh/sdd-guard.sh, ISEP#60/#91 早已裁定退役但專案版沒跟著退)、1 支(kv-write-guard.sh) 是專案版有、plugin 當時沒有的真閘。 這次: - kv-write-guard.sh 原樣搬進本 repo(B 組,PreToolUse Write|Edit|MultiEdit), 補齊那個真的缺口,附 8 條迴歸測試 - 新增 duplicate-hook-registration-guard.sh(E 組,SessionStart): 往後任何專案的 settings.json 又跟 plugin 長出同名登記,開場就點名, 不必再靠「戳記莫名其妙失效」才發現,附 8 條迴歸測試 - 兩支新閘:61→63 支、84→86 條註冊(在自己的樹上實數,不是加減推) InkStoneCo 那邊的清理(37 支殘骸從 settings.json 與 .claude/hooks/ 移除, kv-write-guard.sh 保留至本次升版並 /plugin update 之後)另外commit, 不在本 repo 範圍內。 驗證:重演過修好前的雙掛載(蓋一次戳記→專案版先吃掉→plugin 版恆擋, EXIT=2),也驗過修好後單次執行放行(EXIT=0)與不蓋戳記仍被擋(EXIT=2)。 待總管定版。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
# duplicate-hook-registration-guard.sh 的迴歸測試(inkstone/ISEP#122)
|
||||
#
|
||||
# 這支閘的價值是「不吵的時候真的不吵,該吵的時候真的吵得出正確清單」——
|
||||
# 兩邊都要驗,而且全程用假的 plugin root/專案目錄,不碰真正的
|
||||
# InkStoneCo/.claude/ 或這台機器裝著的 ISEP plugin。
|
||||
set -u
|
||||
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/duplicate-hook-registration-guard.sh}"
|
||||
PASS=0; FAIL=0; N=0
|
||||
|
||||
check() { # check <說明> <輸出> <exit碼> <該出現|!不該出現>...
|
||||
desc="$1"; out="$2"; code="$3"; shift 3; N=$((N+1)); ok=1; why=""
|
||||
if [ "$code" != "0" ]; then ok=0; why="exit 應該是 0(這支從不擋人),實際是 $code"; fi
|
||||
for w in "$@"; do
|
||||
case "$w" in
|
||||
"!"*) if printf '%s' "$out" | grep -qF -- "${w#!}"; then ok=0; why="不該出現卻出現了:${w#!}"; fi ;;
|
||||
*) if ! printf '%s' "$out" | grep -qF -- "$w"; then ok=0; why="少了:$w"; fi ;;
|
||||
esac
|
||||
done
|
||||
if [ "$ok" = 1 ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
|
||||
else printf ' ❌ %s —— %s\n' "$desc" "$why"; printf '%s\n' "$out" | sed 's/^/ /'; FAIL=$((FAIL+1)); fi
|
||||
}
|
||||
|
||||
# mkroot <名字...> → 造一份假 plugin root,hooks.json 裡真的註冊這些檔名
|
||||
mkroot() {
|
||||
d="$TMP/root-$RANDOM"; mkdir -p "$d/hooks"
|
||||
{
|
||||
printf '{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":['
|
||||
first=1
|
||||
for n in "$@"; do
|
||||
[ "$first" = 1 ] || printf ','
|
||||
printf '{"type":"command","command":"${CLAUDE_PLUGIN_ROOT}/hooks/%s"}' "$n"
|
||||
first=0
|
||||
done
|
||||
printf ']}]}}\n'
|
||||
} > "$d/hooks/hooks.json"
|
||||
printf '%s\n' "$d"
|
||||
}
|
||||
|
||||
# mkproj <名字...> → 造一份假專案目錄,settings.json 裡登記這些檔名
|
||||
mkproj() {
|
||||
d="$TMP/proj-$RANDOM"; mkdir -p "$d/.claude"
|
||||
{
|
||||
printf '{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":['
|
||||
first=1
|
||||
for n in "$@"; do
|
||||
[ "$first" = 1 ] || printf ','
|
||||
printf '{"type":"command","command":"$CLAUDE_PROJECT_DIR/.claude/hooks/%s"}' "$n"
|
||||
first=0
|
||||
done
|
||||
printf ']}]}}\n'
|
||||
} > "$d/.claude/settings.json"
|
||||
printf '%s\n' "$d"
|
||||
}
|
||||
|
||||
run() { # run <root> <proj>
|
||||
out=$(CLAUDE_PLUGIN_ROOT="$1" CLAUDE_PROJECT_DIR="$2" bash "$HOOK" 2>&1)
|
||||
code=$?
|
||||
printf '%s\x1e%s' "$out" "$code"
|
||||
}
|
||||
|
||||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
echo "── 該吵 ──────────────────────────────────────────────────────"
|
||||
|
||||
R=$(mkroot prod-write-guard.sh github-contact-guard.sh)
|
||||
P=$(mkproj prod-write-guard.sh other-thing.sh)
|
||||
o=$(run "$R" "$P"); out="${o%$'\x1e'*}"; code="${o##*$'\x1e'}"
|
||||
check "① 專案版跟 plugin 同名一支 → 點名那一支" "$out" "$code" \
|
||||
"重複掛載警報" "1 支" "prod-write-guard.sh" "!github-contact-guard.sh" "!other-thing.sh"
|
||||
|
||||
R=$(mkroot a.sh b.sh c.sh)
|
||||
P=$(mkproj a.sh b.sh c.sh)
|
||||
o=$(run "$R" "$P"); out="${o%$'\x1e'*}"; code="${o##*$'\x1e'}"
|
||||
check "② 全部同名 → 三支都點名" "$out" "$code" "3 支" "a.sh" "b.sh" "c.sh"
|
||||
|
||||
echo "── 不該吵(誤攔比漏擋嚴重)───────────────────────────────────"
|
||||
|
||||
R=$(mkroot a.sh)
|
||||
P=$(mkproj z.sh)
|
||||
o=$(run "$R" "$P"); out="${o%$'\x1e'*}"; code="${o##*$'\x1e'}"
|
||||
check "③ 完全不重疊 → 靜音" "$out" "$code" "!重複掛載"
|
||||
|
||||
R=$(mkroot a.sh)
|
||||
d="$TMP/proj-empty-$RANDOM"; mkdir -p "$d/.claude"; echo '{"hooks":{}}' > "$d/.claude/settings.json"
|
||||
o=$(run "$R" "$d"); out="${o%$'\x1e'*}"; code="${o##*$'\x1e'}"
|
||||
check "④ 專案沒登記任何 .claude/hooks/*.sh → 靜音" "$out" "$code" "!重複掛載"
|
||||
|
||||
d="$TMP/proj-nosettings-$RANDOM"; mkdir -p "$d/.claude"
|
||||
o=$(run "$R" "$d"); out="${o%$'\x1e'*}"; code="${o##*$'\x1e'}"
|
||||
check "⑤ 專案根本沒有 settings.json → 靜音" "$out" "$code" "!重複掛載"
|
||||
|
||||
o=$(CLAUDE_PROJECT_DIR="$(mkproj a.sh)" bash "$HOOK" 2>&1); code=$?
|
||||
check "⑥ 沒有 CLAUDE_PLUGIN_ROOT(不是 plugin 環境)→ 靜音" "$o" "$code" "!重複掛載"
|
||||
|
||||
d="$TMP/root-nohooksjson-$RANDOM"; mkdir -p "$d/hooks"
|
||||
o=$(CLAUDE_PLUGIN_ROOT="$d" CLAUDE_PROJECT_DIR="$(mkproj a.sh)" bash "$HOOK" 2>&1); code=$?
|
||||
check "⑦ plugin root 沒有 hooks.json(環境不完整)→ 靜音" "$o" "$code" "!重複掛載"
|
||||
|
||||
echo "── 判準是「登記」不是「檔案存在」──────────────────────────────"
|
||||
|
||||
R=$(mkroot pre-write-guard.template.sh) # 有這個檔但 hooks.json 沒收(樣板慣例)
|
||||
d="$TMP/root-unreg-$RANDOM"; mkdir -p "$d/hooks"; echo '{"hooks":{}}' > "$d/hooks/hooks.json"
|
||||
touch "$d/hooks/pre-write-guard.template.sh"
|
||||
P=$(mkproj pre-write-guard.template.sh)
|
||||
o=$(run "$d" "$P"); out="${o%$'\x1e'*}"; code="${o##*$'\x1e'}"
|
||||
check "⑧ plugin 有這個檔但沒真的註冊 → 不算重複,靜音" "$out" "$code" "!重複掛載"
|
||||
|
||||
echo ""
|
||||
echo "── 結果:$PASS 通過 / $FAIL 失敗(共 $N)──"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
# kv-write-guard.sh 的迴歸測試(inkstone/ISEP#122 搬進 ISEP 時補上——
|
||||
# 原檔 2026-08-25 立於 InkStoneCo 專案版,搬過來之前沒有自動化測試)。
|
||||
set -u
|
||||
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/kv-write-guard.sh}"
|
||||
PASS=0; FAIL=0; N=0
|
||||
|
||||
# run <tool> <file_path> <content> → 送一個 Write 事件的 payload
|
||||
run() {
|
||||
python3 -c '
|
||||
import json,sys
|
||||
tool, fp, content = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
print(json.dumps({"tool_name": tool, "tool_input": {"file_path": fp, "content": content}}))
|
||||
' "$1" "$2" "$3" | bash "$HOOK" 2>&1
|
||||
echo "EXIT:$?"
|
||||
}
|
||||
|
||||
check() { # check <說明> <輸出+EXIT行> <該擋(2)|該放行(0)> <該出現|!不該出現>...
|
||||
desc="$1"; out="$2"; want="$3"; shift 3
|
||||
code="$(printf '%s\n' "$out" | grep -oE 'EXIT:[0-9]+' | tail -1 | cut -d: -f2)"
|
||||
N=$((N+1)); ok=1; why=""
|
||||
if [ "$code" != "$want" ]; then ok=0; why="要 exit $want,實際 $code"; fi
|
||||
for w in "$@"; do
|
||||
case "$w" in
|
||||
"!"*) if printf '%s' "$out" | grep -qF -- "${w#!}"; then ok=0; why="$why 不該出現卻出現:${w#!}"; fi ;;
|
||||
*) if ! printf '%s' "$out" | grep -qF -- "$w"; then ok=0; why="$why 少了:$w"; fi ;;
|
||||
esac
|
||||
done
|
||||
if [ "$ok" = 1 ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
|
||||
else printf ' ❌ %s ——%s\n' "$desc" "$why"; printf '%s\n' "$out" | sed 's/^/ /'; FAIL=$((FAIL+1)); fi
|
||||
}
|
||||
|
||||
echo "── 該擋:往 KV binding 寫入 ──────────────────────────────────"
|
||||
out=$(run "Write" "src/app-system.ts" $'export async function save() {\n await MY_KV.put("k", "v");\n}')
|
||||
check "① 全大寫 binding.put( → 擋" "$out" 2 "長效資料不准寫進 KV" "MY_KV"
|
||||
|
||||
out=$(run "Edit" "src/app-system.ts" $'const x = 1;\nSESSION_KV.delete(id);')
|
||||
check "② .delete( 一樣擋" "$out" 2 "長效資料不准寫進 KV"
|
||||
|
||||
echo "── 不該擋(誤攔比漏擋嚴重)───────────────────────────────────"
|
||||
|
||||
out=$(run "Write" "src/app-system.ts" 'const v = await MY_KV.get("k");')
|
||||
check "③ 只是讀(.get)→ 放行" "$out" 0 "!長效資料不准寫進 KV"
|
||||
|
||||
out=$(run "Write" "src/app-system.ts" 'MY_KV.put("k", v); // kv-ok: session nonce,有 TTL')
|
||||
check "④ 加了 kv-ok 豁免留痕的那行 → 放行" "$out" 0 "!長效資料不准寫進 KV"
|
||||
|
||||
out=$(run "Write" "README.md" 'MY_KV.put("k", "v")')
|
||||
check "⑤ 非程式碼檔(.md)→ 放行" "$out" 0 "!長效資料不准寫進 KV"
|
||||
|
||||
out=$(run "Write" "src/kv-write-guard.test.ts" 'MY_KV.put("k", "v")')
|
||||
check "⑥ 閘自己的測試檔 → 放行" "$out" 0 "!長效資料不准寫進 KV"
|
||||
|
||||
out=$(run "Write" "src/app-system.ts" 'const config = { retries: 3 };')
|
||||
check "⑦ 完全不提 KV 的一般程式碼 → 放行" "$out" 0 "!長效資料不准寫進 KV"
|
||||
|
||||
out=$(run "Write" "src/app-system.ts" 'this.myKv.put("k", "v"); // 小寫 binding,不是本閘要的形狀')
|
||||
check "⑧ 小寫變數名(不是「全大寫識別字」形狀)→ 放行" "$out" 0 "!長效資料不准寫進 KV"
|
||||
|
||||
echo ""
|
||||
echo "── 結果:$PASS 通過 / $FAIL 失敗(共 $N)──"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Reference in New Issue
Block a user