diff --git a/hooks/history-first-guard.sh b/hooks/history-first-guard.sh index b86fb9f..6deaf89 100755 --- a/hooks/history-first-guard.sh +++ b/hooks/history-first-guard.sh @@ -56,15 +56,32 @@ STAMP="/tmp/.history-guard-$(printf '%s' "$FILE_PATH" | shasum | cut -c1-12)" # # 時戳由 kbdb-asked-stamp.sh(PostToolUse,matcher 對 kbdb_* 工具)寫下。 # 逃生口:KBDB 真的連不上時 `touch /tmp/.kbdb-down`——放行但留痕,且回覆裡要說明。 -KBDB_STAMP=/tmp/.kbdb-asked -KBDB_DOWN=/tmp/.kbdb-down +# +# 🔴 **逃生門要真的走得通**(inkstone/ISEP#122,2026-09-02 實撞,本檔第二次修): +# 上面那句訊息叫人 `touch /tmp/.kbdb-down`,而 `touch` 造出來的是**空檔** +# ⇒ 舊版 `t=$(cat "$f")` 讀到空字串 ⇒ 算式當成 0 ⇒ 「距今 17 億秒」 +# ⇒ 永遠不新鮮 ⇒ **照著訊息打完,還是被擋**。 +# 實測(2026-09-02,KBDB 兩支工具都回 Connection closed 時): +# $ touch /tmp/.kbdb-down → 再送同一個編輯 → 一模一樣的擋,訊息一字不差 +# ⇒ 那行逃生門是印出來好看的,**沒有人照著打過一次** +# (同族:ISEP#125 worktree 閘印的逃生門原文照打 rc=2;本票 comment 6071)。 +# +# 改法:**內容不是數字就用檔案的 mtime**——`touch` 做的正是更新 mtime, +# 所以訊息裡那一行從此真的走得通,而 `kbdb-asked-stamp.sh` 寫數字的舊格式照樣相容。 +# 路徑用 `KBDB_STAMP_DIR` 可覆寫,**只為了讓迴歸測試不去動這台機器真正的戳記** +# (測試把別的 session 的戳記清掉=把閘弄成隨機的)。 +KBDB_STAMP="${KBDB_STAMP_DIR:-/tmp}/.kbdb-asked" +KBDB_DOWN="${KBDB_STAMP_DIR:-/tmp}/.kbdb-down" kbdb_fresh=0 now=$(date +%s) for f in "$KBDB_STAMP" "$KBDB_DOWN"; do - if [ -f "$f" ]; then - t=$(cat "$f" 2>/dev/null || echo 0) - [ $((now - t)) -lt 3600 ] && kbdb_fresh=1 - fi + [ -f "$f" ] || continue + t=$(cat "$f" 2>/dev/null || true) + case "$t" in + ''|*[!0-9]*) t=$(date -r "$f" +%s 2>/dev/null || stat -c %Y "$f" 2>/dev/null || echo 0) ;; + esac + [ "${t:-0}" -gt 0 ] || continue + [ $((now - t)) -lt 3600 ] && kbdb_fresh=1 done if [ "$kbdb_fresh" -eq 0 ]; then cat >&2 <<'KEOF' diff --git a/hooks/kbdb-asked-stamp.sh b/hooks/kbdb-asked-stamp.sh index 9c664b8..c14716a 100755 --- a/hooks/kbdb-asked-stamp.sh +++ b/hooks/kbdb-asked-stamp.sh @@ -16,5 +16,6 @@ # KBDB(一次看所有庫,最便宜)→ 該 repo 的 wiki/tasks → git log -S → 讀源碼(最貴, # 只看得到「現在長怎樣」,看不到「為什麼變成這樣」) set -eu -date +%s > /tmp/.kbdb-asked +# 路徑跟 history-first-guard.sh 用同一個覆寫變數,否則測試會兩邊指到不同地方 +date +%s > "${KBDB_STAMP_DIR:-/tmp}/.kbdb-asked" exit 0 diff --git a/hooks/tests/history-first-guard.test.sh b/hooks/tests/history-first-guard.test.sh new file mode 100755 index 0000000..5885779 --- /dev/null +++ b/hooks/tests/history-first-guard.test.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# history-first-guard.sh 的迴歸測試 —— 重點在**逃生門真的走得通**(inkstone/ISEP#122) +# +# ── 這支在守什麼 ──────────────────────────────────────────────────── +# 2026-09-02 實撞:KBDB 兩支工具都回 `Connection closed`,照這支閘印出來的那行 +# `touch /tmp/.kbdb-down` +# 打完再送同一個編輯,**被一模一樣地擋第二次**——因為 `touch` 造的是空檔, +# 而舊版是 `cat` 檔案內容當時戳,空字串被當成 0 ⇒ 「距今 17 億秒」⇒ 永遠不新鮮。 +# +# 🔴 一道閘印出來的出路走不通,比沒有出路更糟:人照做了、還是被擋, +# 下一步就是去繞過這道閘(本 repo 的 ticket-api-bypass-guard.sh 檔頭記過同一課)。 +# 所以這支測試的第一等公民是**逃生門那一條**,不是「擋不擋得住」。 +# +# 🔴 **這支測試自己怎麼被證明有鑑別力**(不要只看它全綠): +# 把修好之前那一版拿來跑,第 ④ 條要紅。因為舊版沒有 KBDB_STAMP_DIR, +# 直接餵它會去讀這台機器真正的 /tmp 戳記而「剛好過關」,所以要先把路徑隔離: +# git show <修好前的 commit>:hooks/history-first-guard.sh > /tmp/old.sh +# sed 's#/tmp/\.kbdb-asked#${KBDB_STAMP_DIR:-/tmp}/.kbdb-asked#; +# s#/tmp/\.kbdb-down#${KBDB_STAMP_DIR:-/tmp}/.kbdb-down#' /tmp/old.sh > /tmp/old-iso.sh +# bash hooks/tests/history-first-guard.test.sh /tmp/old-iso.sh +# 2026-09-02 實跑:舊版 9/10(④ 紅),修好後 10/10。 +# ⚠️ 第 ③ 條**兩版都是綠的**——它只驗離開碼 2,而過了第 0 道之後歷史警察照樣會擋。 +# 真正分辨得出來的是 ④「擋人的是誰」。這一格是本支最容易寫成假綠的地方。 +# +# 🔴 全程用 KBDB_STAMP_DIR 指到 TMP,**不碰這台機器真正的 /tmp/.kbdb-* 戳記** +# (把別的 session 的戳記清掉=把閘弄成隨機的)。不打網路。 +set -u +HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/history-first-guard.sh}" +TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT +export KBDB_STAMP_DIR="$TMP/stamps"; mkdir -p "$KBDB_STAMP_DIR" + +PASS=0; FAIL=0; N=0 + +# 造一個「有前科」的 code 檔:放在一個有 git 歷史的假 repo 裡 +REPO="$TMP/repo"; mkdir -p "$REPO" +( cd "$REPO" && git init -q && git config user.email t@t && git config user.name t \ + && printf 'echo old\n' > victim.sh && git add . && git commit -qm "第一版" ) >/dev/null 2>&1 +VICTIM="$REPO/victim.sh" + +# run <檔案> → 印出離開碼(送一份 Edit 的 payload 進去) +run() { + printf '{"tool_input":{"file_path":"%s"}}' "$1" \ + | CLAUDE_PROJECT_DIR="$REPO" bash "$HOOK" >/dev/null 2>"$TMP/err" + printf '%s' "$?" +} + +check() { # check <說明> <實際> <期望> + N=$((N+1)) + if [ "$2" = "$3" ]; then printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)) + else printf ' ❌ %s —— 期望離開碼 %s,實際 %s\n' "$1" "$3" "$2"; sed 's/^/ /' "$TMP/err" | head -6; FAIL=$((FAIL+1)); fi +} + +clean_stamps() { rm -f "$KBDB_STAMP_DIR/.kbdb-asked" "$KBDB_STAMP_DIR/.kbdb-down"; rm -f /tmp/.history-guard-*; } + +echo "── 第 0 道:問過 KBDB 了沒 ──────────────────────────────────" +clean_stamps +check "① 沒有任何戳記 → 擋" "$(run "$VICTIM")" 2 +grep -q "KBDB" "$TMP/err" && printf ' ✅ ② 擋下時訊息講得出是哪一道\n' && PASS=$((PASS+1)) || { printf ' ❌ ② 訊息沒提到 KBDB\n'; FAIL=$((FAIL+1)); }; N=$((N+1)) + +echo "── 🔴 逃生門:照訊息打那一行,就要真的過得去 ──────────────────" +clean_stamps +touch "$KBDB_STAMP_DIR/.kbdb-down" # ← 訊息裡原文那一行做的事 +check "③ touch 出來的空檔(本票的實撞)→ 要放行過第 0 道" "$(run "$VICTIM")" 2 +# ↑ 期望仍是 2:過了第 0 道之後會被「歷史警察」擋(這個檔真的有前科), +# 所以要看訊息換人了沒——這是這支測試最容易寫錯的一格。 +grep -q "歷史警察" "$TMP/err" && printf ' ✅ ④ 擋人的換成歷史警察 ⇒ 第 0 道確實被逃生門放過了\n' && PASS=$((PASS+1)) || { printf ' ❌ ④ 還是卡在 KBDB 那一道 ⇒ 逃生門是假的\n'; sed 's/^/ /' "$TMP/err" | head -4; FAIL=$((FAIL+1)); }; N=$((N+1)) + +clean_stamps +date +%s > "$KBDB_STAMP_DIR/.kbdb-down" # 舊格式(檔案內容是時戳) +run "$VICTIM" >/dev/null +grep -q "歷史警察" "$TMP/err" && printf ' ✅ ⑤ 舊格式(內容寫時戳)照樣認得 ⇒ 沒有把既有的弄壞\n' && PASS=$((PASS+1)) || { printf ' ❌ ⑤ 舊格式壞了\n'; FAIL=$((FAIL+1)); }; N=$((N+1)) + +clean_stamps +date +%s > "$KBDB_STAMP_DIR/.kbdb-asked" +run "$VICTIM" >/dev/null +grep -q "歷史警察" "$TMP/err" && printf ' ✅ ⑥ 真的問過 KBDB(kbdb-asked-stamp.sh 寫的戳記)→ 過第 0 道\n' && PASS=$((PASS+1)) || { printf ' ❌ ⑥ 問過了還被擋\n'; FAIL=$((FAIL+1)); }; N=$((N+1)) + +echo "── 過期的戳記不算數(逃生門不能變成永久後門)──────────────────" +clean_stamps +touch -t "$(date -v-2H '+%Y%m%d%H%M' 2>/dev/null || date -d '2 hours ago' '+%Y%m%d%H%M')" "$KBDB_STAMP_DIR/.kbdb-down" +run "$VICTIM" >/dev/null +grep -q "KBDB" "$TMP/err" && printf ' ✅ ⑦ 兩小時前的空戳記 → 過期,回到擋\n' && PASS=$((PASS+1)) || { printf ' ❌ ⑦ 過期的戳記還在放行 ⇒ 一次 touch 就永久免疫\n'; FAIL=$((FAIL+1)); }; N=$((N+1)) + +echo "── 不該擋的(誤攔比漏擋嚴重)────────────────────────────────" +clean_stamps +printf '# doc\n' > "$REPO/readme.md" +check "⑧ 文件檔(.md)→ 一路放行,連第 0 道都不該碰" "$(run "$REPO/readme.md")" 0 +clean_stamps +check "⑨ 不存在的新檔 → 放行" "$(run "$REPO/brand-new.sh")" 0 +clean_stamps +mkdir -p "$REPO/tests"; printf 'echo t\n' > "$REPO/tests/a.sh" +check "⑩ 測試檔 → 放行" "$(run "$REPO/tests/a.sh")" 0 + +echo +printf '通過 %s 條,失敗 %s 條(共 %s 條)\n' "$PASS" "$FAIL" "$N" +[ "$FAIL" = 0 ] || exit 1