Files
ISEP/hooks/tests/gate-log.test.sh
T
Claude dda4852938 gate-log: 透明記錄層——每一支閘的擋/放行/繞過都留得下(InkStoneCo#48)
在此之前 36 支閘裡只有 2 支會記錄自己擋了什麼,六種逃生口
(NOT_MY_BRANCH_OK=1/no-ticket-needed/wiki-secret-ok/credential-ok/
solo-ok/kbdb-down)用了完全不留痕 ⇒ 擋對 100 次跟擋錯 100 次在資料上
一模一樣 ⇒ 規則只會累積不會被淘汰。

機制:hooks/lib/gate-log-wrap.sh 包在每一支閘外面(hooks.json 的 82 條
gate command 全部前綴這支 wrapper;2 條 scripts/ refresher 不是閘、不包)。
它從不 exit 2,只把真正那支閘的離開碼原樣傳出去 ⇒ 記錄壞掉最多漏記一筆,
不可能誤攔(票紅線:寧可漏記,不可擋錯)。每次留一筆中繼資料:
哪支閘/何時/對什麼工具/判成什麼/(若繞過)用了哪個逃生口。

- 只記中繼資料,完全不寫入指令原文或 payload ⇒ 金鑰在結構上進不了帳本(D36)
- 純 POSIX shell 記錄,不另起 python ⇒ 不讓閘變慢變脆
- 紀錄長在既有閘動作上,零輪詢/零 cron(D20)
- 帳本沿用 milestone-account 慣例(不寫進會被 plugin update 換掉的目錄)

報告:scripts/gate-report all 回答票上三題(誤擋嫌疑排序/最近沒擋過的規則/
同一 session 反覆撞同一支閘)。基線與反面驗證見票留言。

測試:hooks/tests/gate-log.test.sh 22 條(透明/留痕/反面驗證/不變脆/不外洩)
+ scripts/test-gate-report.sh 8 條(三題數字對不對)。
hooks 檔數 61、hooks.json 註冊 84 均不變(wrapper 住 hooks/lib,非 gate .sh)。

版本:待總管定版(改了會被載入的東西需升版才傳得到,但 tag 由總管打)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNfNypeDaCVEZQW1Wqwfxq
2026-09-06 21:01:42 +00:00

145 lines
8.6 KiB
Bash
Executable File
Raw 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.
#!/usr/bin/env bash
# gate-log-wrap.sh 的迴歸測試(inkstone/InkStoneCo#48
#
# A 群「透明」——包在外面絕不改變閘的行為:離開碼、stdout、stderr、stdin 全部原樣穿過。
# 🔴 這是最高紅線:寧可漏記一筆,不可擋錯一次。
# B 群「留痕」——擋(2)/放行(0)/被逃生口繞過,各留一筆,欄位答得出票上要的五件事。
# C 群「反面驗證」——故意用一次逃生口,那一筆一定要以 verdict=bypass 出現(票上驗收 #4)。
# D 群「不變脆」——記錄失敗(帳本目錄不可寫)絕不影響閘的離開碼。
# E 群「不外洩」——payload 帶金鑰時,帳本裡不得出現金鑰真身(D36)。
#
# 全程離線:帳本走 ISEP_GATE_LOG 指到 mktemp 的檔,不打網路、不碰真實帳本。
set -u
WRAP="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/gate-log-wrap.sh}"
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
LED="$TMP/gate-log.jsonl"; : > "$LED"
PASS=0; FAIL=0; N=0
ok() { N=$((N+1)); printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
bad() { N=$((N+1)); printf ' ❌ %s\n' "$1"; FAIL=$((FAIL+1)); shift; for l in "$@"; do printf ' %s\n' "$l"; done; }
# ── 造幾支假閘:行為可控,用來驗「包在外面有沒有原樣穿過」──────────────────
mkgate() { # mkgate <name> <exit> <stdout> <stderr>
local f="$TMP/$1.sh"
{
echo '#!/bin/sh'
echo 'IN=$(cat)' # 讀 stdin
printf 'printf "%%s" "%s"\n' "$3" # 印到 stdout
printf 'printf "%%s" "%s" >&2\n' "$4" # 印到 stderr
printf 'printf "STDIN=[%%s]" "$IN" >&2\n' # 把收到的 stdin 回吐到 stderr 供比對
printf 'exit %s\n' "$2"
} > "$f"
chmod +x "$f"; printf '%s' "$f"
}
run() { # run <gate-file> <payload> ; 設定 OUT/ERR/RC/last log line
local gate="$1" payload="$2"
ERRF="$TMP/err"; OUTF="$TMP/out"
printf '%s' "$payload" | ISEP_GATE_LOG="$LED" "$WRAP" "$gate" >"$OUTF" 2>"$ERRF"
RC=$?; OUT=$(cat "$OUTF"); ERR=$(cat "$ERRF")
LOGLINE=$(tail -n1 "$LED" 2>/dev/null)
}
jget() { printf '%s' "$1" | python3 -c "import json,sys;print(json.load(sys.stdin).get('$2',''))" 2>/dev/null; }
echo "── A 群:透明——閘的行為原樣穿過(寧可漏記,不可擋錯)───────"
G=$(mkgate blockgate 2 "" "擋你了")
run "$G" '{"hook_event_name":"PreToolUse","tool_name":"Bash","session_id":"s1"}'
[ "$RC" = 2 ] && ok "① 閘 exit 2 → 包完仍 exit 2(不會把擋變成放行,也不會把放行變成擋)" \
|| bad "① 期望 rc=2 得 $RC"
G=$(mkgate passgate 0 "" "")
run "$G" '{"hook_event_name":"PreToolUse","tool_name":"Bash","session_id":"s1"}'
[ "$RC" = 0 ] && ok "② 閘 exit 0 → 包完仍 exit 0" || bad "② 期望 rc=0 得 $RC"
G=$(mkgate outgate 0 "注入的context" "")
run "$G" '{"hook_event_name":"UserPromptSubmit","session_id":"s1"}'
[ "$OUT" = "注入的context" ] && ok "③ 閘的 stdout 原樣穿過(SessionStart/UserPromptSubmit 靠它注入)" \
|| bad "③ stdout 沒穿過" "得:[$OUT]"
G=$(mkgate errgate 2 "" "紅字說明")
run "$G" '{"hook_event_name":"PreToolUse","tool_name":"Bash","session_id":"s1"}'
case "$ERR" in *"紅字說明"*) ok "④ 閘的 stderr 原樣穿過(被擋的人看得到為什麼)" ;; *) bad "④ stderr 沒穿過" "得:[$ERR]" ;; esac
G=$(mkgate echogate 0 "" "")
run "$G" '{"tool_name":"Bash","session_id":"s1","tool_input":{"command":"樣本指令XYZ"}}'
case "$ERR" in *"樣本指令XYZ"*) ok "⑤ stdin 原樣餵給閘(閘拿到的 payload 一字不差)" ;; *) bad "⑤ stdin 沒穿過" "得:[$ERR]" ;; esac
# 沒給閘路徑:不擋、不報錯
printf '%s' '{}' | ISEP_GATE_LOG="$LED" "$WRAP" >/dev/null 2>&1
[ $? = 0 ] && ok "⑥ 沒給閘路徑 → exit 0(自己絕不擋、絕不報錯)" || bad "⑥ 空參數應 exit 0"
echo "── B 群:留痕——三種 verdict 各留一筆,欄位齊全 ──────────────"
G=$(mkgate github-contact-guard 2 "" "x")
run "$G" '{"hook_event_name":"PreToolUse","tool_name":"Bash","session_id":"sess-AAA"}'
[ "$(jget "$LOGLINE" verdict)" = block ] && ok "⑦ 擋下 → verdict=block" || bad "⑦ verdict 不是 block" "$LOGLINE"
[ "$(jget "$LOGLINE" gate)" = github-contact-guard ] && ok "⑧ 記得下是哪支閘" || bad "⑧ gate 欄錯" "$LOGLINE"
[ "$(jget "$LOGLINE" tool)" = Bash ] && ok "⑨ 記得下對什麼工具動作" || bad "⑨ tool 欄錯" "$LOGLINE"
[ "$(jget "$LOGLINE" sid)" = sess-AAA ] && ok "⑩ 記得下哪個 sessionQ3 要用)" || bad "⑩ sid 欄錯" "$LOGLINE"
[ -n "$(jget "$LOGLINE" ts)" ] && ok "⑪ 記得下什麼時候" || bad "⑪ ts 欄空" "$LOGLINE"
G=$(mkgate someguard 0 "" "")
run "$G" '{"tool_name":"Edit","session_id":"s2"}'
[ "$(jget "$LOGLINE" verdict)" = pass ] && ok "⑫ 放行 → verdict=pass(活著但沒擋,Q2 靠它)" || bad "⑫ 放行沒記成 pass" "$LOGLINE"
echo "── C 群:反面驗證——故意用逃生口,一定要記成 bypass(驗收#4)─"
# credential-only-guard 的逃生口 = 內容裡有 credential-ok,且閘放行(0)
G=$(mkgate credential-only-guard 0 "" "")
run "$G" '{"tool_name":"Write","session_id":"s3","tool_input":{"new_string":"token=x # credential-ok"}}'
[ "$(jget "$LOGLINE" verdict)" = bypass ] && ok "⑬ credential-ok 放行 → verdict=bypass" || bad "⑬ 逃生口沒記成 bypass" "$LOGLINE"
[ "$(jget "$LOGLINE" bypass)" = credential-ok ] && ok "⑭ 記得下用了哪個逃生口(理由)" || bad "⑭ bypass 理由欄錯" "$LOGLINE"
# NOT_MY_BRANCH_OK=1 envowner = not-my-branch-guard),閘放行
G=$(mkgate not-my-branch-guard 0 "" "")
printf '%s' '{"tool_name":"Bash","session_id":"s3"}' | NOT_MY_BRANCH_OK=1 ISEP_GATE_LOG="$LED" "$WRAP" "$G" >/dev/null 2>&1
L=$(tail -n1 "$LED")
[ "$(jget "$L" bypass)" = NOT_MY_BRANCH_OK ] && ok "⑮ 環境變數逃生口 NOT_MY_BRANCH_OK 也記得下" || bad "⑮ env 逃生口沒記" "$L"
# solo-ok = 檔案 /tmp/.solo-ok-<sid>owner = subagent-first-guard
SID="solo-test-$$"; touch "/tmp/.solo-ok-$SID"
G=$(mkgate subagent-first-guard 0 "" "")
run "$G" "{\"tool_name\":\"Agent\",\"session_id\":\"$SID\"}"
[ "$(jget "$LOGLINE" bypass)" = solo-ok ] && ok "⑯ 檔案式逃生口 solo-ok 也記得下" || bad "⑯ solo-ok 沒記" "$LOGLINE"
rm -f "/tmp/.solo-ok-$SID"
# 逃生口只有在「放行」時才算 bypass:同一個 token 但閘照樣擋(2) → 維持 block
G=$(mkgate credential-only-guard 2 "" "x")
run "$G" '{"tool_name":"Write","session_id":"s3","tool_input":{"new_string":"# credential-ok"}}'
[ "$(jget "$LOGLINE" verdict)" = block ] && ok "⑰ token 在場但閘照樣擋 → 維持 block(逃生口沒生效不算繞過)" || bad "⑰ 錯記成 bypass" "$LOGLINE"
# owner-scoped:別支閘的指令裡剛好有 credential-ok,不該被記成它繞過
G=$(mkgate github-contact-guard 0 "" "")
run "$G" '{"tool_name":"Bash","session_id":"s3","tool_input":{"command":"echo credential-ok"}}'
[ "$(jget "$LOGLINE" verdict)" = pass ] && ok "⑱ 非擁有者的閘看到別人的 token 字樣 → 仍是 pass(不誤記)" || bad "⑱ 誤記成 bypass" "$LOGLINE"
echo "── D 群:不變脆——記錄失敗不影響閘的離開碼 ──────────────────"
G=$(mkgate blockgate2 2 "" "x")
printf '%s' '{"tool_name":"Bash","session_id":"s4"}' \
| ISEP_GATE_LOG="/this/dir/does/not/exist/nope.jsonl" "$WRAP" "$G" >/dev/null 2>&1
[ $? = 2 ] && ok "⑲ 帳本目錄不可寫 → 閘照樣 exit 2(記錄壞掉不擋錯)" || bad "⑲ 記錄失敗改變了 rc"
G=$(mkgate passgate2 0 "" "")
printf '%s' '{"tool_name":"Bash","session_id":"s4"}' \
| ISEP_GATE_LOG="/dev/null" "$WRAP" "$G" >/dev/null 2>&1
[ $? = 0 ] && ok "⑳ 明示停用(/dev/null)→ 閘照樣放行、不留痕" || bad "⑳ /dev/null 改變了 rc"
echo "── E 群:不外洩——payload 帶金鑰,帳本裡不得出現金鑰真身(D36)─"
SECRET="ghp_ABCDEF1234567890abcdef1234567890XYZ"
G=$(mkgate github-contact-guard 2 "" "x")
run "$G" "{\"tool_name\":\"Bash\",\"session_id\":\"s5\",\"tool_input\":{\"command\":\"curl -H 'Authorization: Bearer $SECRET' x\"}}"
if grep -qF "$SECRET" "$LED"; then bad "㉑ 金鑰真身出現在帳本裡(D36 破口)" "$LOGLINE"; else ok "㉑ 帳本只有中繼資料,金鑰真身進不去(D36)"; fi
# 每一行都是合法 JSON
BADJSON=0
while IFS= read -r l; do [ -z "$l" ] && continue; printf '%s' "$l" | python3 -c 'import json,sys;json.load(sys.stdin)' 2>/dev/null || BADJSON=$((BADJSON+1)); done < "$LED"
[ "$BADJSON" = 0 ] && ok "㉒ 帳本每一行都是合法 JSON" || bad "㉒ 有 $BADJSON 行不是合法 JSON"
echo ""
echo "通過 $PASS 條,失敗 $FAIL 條(共 $N"
[ "$FAIL" = 0 ]