#!/usr/bin/env bash # ask-user-question-guard.sh 的離線迴歸測試(inkstone/InkStoneCo#55)。 # # 這一檔測的是**管路**:抓不抓得到那個動作、擋的時候 exit 幾、 # 判官掛掉會不會把 session 卡死、同一題會不會擋第二次。 # **判官的準度另外一檔測**(`ask-user-question-guard.live.test.sh`,那支真的叫 haiku)。 # # 判官在這裡用替身(`ISEP_ASK_JUDGE_CMD` 指向一支印固定 JSON 的假腳本), # 所以本檔 **不打網路、不花錢、跑得完**。 # # 兩個方向都要有證據: # A 群「該放行」——非 AskUserQuestion 的動作、判官說 allow、判官壞掉、豁免戳記、第二次重送 # B 群「該擋」 ——判官說 block(純技術路徑選擇) # C 群「訊息承諾的出路真的走得通」——重送與豁免戳記各一個實例 # # 用法:hooks/tests/ask-user-question-guard.test.sh [hook 路徑] set -u HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/ask-user-question-guard.sh}" TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT PASS=0; FAIL=0; N=0 # 判官替身:吃 stdin、印一段固定的裁決 mkstub() { cat > "$TMP/judge-$1.sh" < /dev/null printf '%s' '$2' EOF chmod +x "$TMP/judge-$1.sh" printf '%s' "$TMP/judge-$1.sh" } STUB_BLOCK=$(mkstub block '{"decision":"block","gate":"none","reason":"純技術路徑選擇,四題全否"}') STUB_ALLOW=$(mkstub allow '{"decision":"allow","gate":"④","reason":"UI 取捨是品味題"}') STUB_EMPTY=$(mkstub empty '') STUB_JUNK=$(mkstub junk 'Error: model overloaded') STUB_NEVER="$TMP/judge-does-not-exist.sh" # payload <問題> [選項...] payload() { python3 - "$@" <<'PY' import json, sys q, opts = sys.argv[1], sys.argv[2:] print(json.dumps({ "session_id": "S-TEST", "hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion", "tool_input": {"questions": [{ "question": q, "header": "決策", "multiSelect": False, "options": [{"label": o, "description": o + " 的說明"} for o in opts], }]}, }, ensure_ascii=False)) PY } # t <期望 exit> <判官替身> <說明> t() { want="$1"; judge="$2"; desc="$3"; body="$4" N=$((N+1)) out=$(printf '%s' "$body" | ISEP_ASK_JUDGE_CMD="$judge" sh "$HOOK" 2>&1); rc=$? if [ "$rc" -eq "$want" ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1)) else printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc" printf '%s\n' "$out" | sed -n '1,6p' | sed 's/^/ /' FAIL=$((FAIL+1)) fi } clean() { rm -f /tmp/.ask-gate-warned-* /tmp/.ask-ok-S-TEST 2>/dev/null || true; } echo "── A 群:該放行 ────────────────────────────────────────────────" clean t 0 "$STUB_BLOCK" "① 不是 AskUserQuestion 的動作,一律不管(連判官都不叫)" \ '{"tool_name":"Bash","tool_input":{"command":"ls"}}' t 0 "$STUB_BLOCK" "② tool_input 是空的 → 沒東西可判,放行" \ '{"tool_name":"AskUserQuestion","session_id":"S-TEST","tool_input":{}}' t 0 "$STUB_BLOCK" "③ 整包不是合法 JSON → fail-open" \ 'this is not json at all' clean t 0 "$STUB_ALLOW" "④ 真人閘之一(品味/方向):樹要不要捲——判官 allow,leo 該收到" \ "$(payload '總圖那棵樹,同層節點超過 20 個時要不要預設收合?' '預設收合' '全部展開')" clean t 0 "$STUB_EMPTY" "⑤ 判官回空(掛了/沒網路)→ fail-open,不准把問題卡住" \ "$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')" clean t 0 "$STUB_JUNK" "⑥ 判官回的不是 JSON → fail-open" \ "$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')" clean t 0 "$STUB_NEVER" "⑦ 判官根本不存在(claude 不在 PATH)→ fail-open" \ "$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')" clean touch /tmp/.ask-ok-S-TEST t 0 "$STUB_BLOCK" "⑧ 明示豁免戳記在 → 放行(C 群:訊息裡承諾的出路)" \ "$(payload '這題我確定是人閘,先放我過' 'A' 'B')" if [ -f /tmp/.ask-ok-S-TEST ]; then printf ' ❌ ⑧b 豁免戳記用完沒被消掉(會變成永久開關)\n'; FAIL=$((FAIL+1)) else printf ' ✅ ⑧b 豁免戳記用完就消失,不是永久開關\n'; PASS=$((PASS+1)) fi N=$((N+1)) echo echo "── B 群:該擋(純技術路徑選擇,四題全否)─────────────────────" clean TECH="$(payload '重試邏輯要放在 client 還是 worker 那一層?' '放 client' '放 worker')" t 2 "$STUB_BLOCK" "⑨ 判官 block → exit 2,問題不會送到 leo 面前" "$TECH" # 訊息內容檢查:要點名 leo 的原話與四題公式,且要給得出出路 # (先清戳記,否則這次會走「同一題只擋一次」那條路而沒有訊息) clean msg=$(printf '%s' "$TECH" | ISEP_ASK_JUDGE_CMD="$STUB_BLOCK" sh "$HOOK" 2>&1 >/dev/null || true) N=$((N+1)) if printf '%s' "$msg" | grep -q '為什麼 hooks 沒有攔下來' \ && printf '%s' "$msg" | grep -q '四題' \ && printf '%s' "$msg" | grep -q '重送'; then printf ' ✅ ⑩ 擋下的訊息帶了:leo 原話+四題公式+重送的出路\n'; PASS=$((PASS+1)) else printf ' ❌ ⑩ 擋下的訊息缺了「leo 原話/四題公式/出路」其中一項\n' printf '%s\n' "$msg" | sed -n '1,8p' | sed 's/^/ /' FAIL=$((FAIL+1)) fi # 迴歸:訊息裡的反引號一度被 shell 當成命令替換執行掉 # (原本寫 `cat >&2 <