#!/usr/bin/env bash # browser-verify-guard.sh 的迴歸測試(inkstone/ISEP#121)。 # # 來由:2026-09-01 雲端這台「一種瀏覽器能力都沒有」,這支閘卻每回合都要求 # mcp__Claude_Browser__*(那套工具在這台叫不出來),於是一整天每回合誤攔一次。 # 正解:閘先判斷「這個 session 有哪種瀏覽器能力」,有能力才要求用它,且教實際 # 可用的那一種(雲端=CF Browser Rendering);一種都沒有時訊息改成「缺什麼、 # 怎麼補」,且每個 session 只提醒一次。 # # 三個方向都要有證據: # A 群「該放行」——沒宣稱/明說無前端/真的用過某種瀏覽器能力/壞輸入 fail-open # B 群「該擋」 ——有能力卻沒用它(且教對的那一種) # C 群「沒能力時不誤攔」——第一次提醒一次(缺什麼怎麼補),同 session 第二次放行 # # 用法:hooks/tests/browser-verify-guard.test.sh [hooks/browser-verify-guard.sh 的路徑] # 🔴 全程在乾淨 TMP 底下造假 transcript,跑完自己清;不碰真 repo、不打網路。 # 每個案子自帶 session_id,並清掉可能殘留的 /tmp/.browser-cap-warned-* stamp。 set -u HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/browser-verify-guard.sh}" TMP=$(mktemp -d) trap 'rm -rf "$TMP"; rm -f /tmp/.browser-cap-warned-bvtest_* 2>/dev/null' EXIT rm -f /tmp/.browser-cap-warned-bvtest_* 2>/dev/null || true PASS=0; FAIL=0; N=0 # mk ... # 單一 assistant 訊息的多個 block:`T:<文字>`=文字段;`U:<工具名>`=tool_use mk() { python3 - "$@" <<'PY' import json, sys path, specs = sys.argv[1], sys.argv[2:] rows = [{"type": "user", "message": {"content": [{"type": "text", "text": "【工單】去辦這件事"}]}}] blocks = [] for s in specs: k, v = s.split(":", 1) blocks.append({"type": "text", "text": v} if k == "T" else {"type": "tool_use", "name": v, "id": "t1", "input": {"cmd": v}}) rows.append({"type": "assistant", "message": {"content": blocks}}) with open(path, "w") as f: for r in rows: f.write(json.dumps(r, ensure_ascii=False) + "\n") PY } # mk2 <早期工具名> <本回合文字> # 早一則 assistant 用過某工具(進得了 transcript 但不在「這回合的驗證」裡), # 本回合只有文字宣稱 → 用來造「有 MCP 能力、但這回合沒用它」。 mk2() { python3 - "$@" <<'PY' import json, sys path, tool, text = sys.argv[1], sys.argv[2], sys.argv[3] rows = [ {"type": "user", "message": {"content": [{"type": "text", "text": "先看一下"}]}}, {"type": "assistant", "message": {"content": [{"type": "tool_use", "name": tool, "id": "e1", "input": {}}]}}, {"type": "user", "message": {"content": [{"type": "text", "text": "【工單】去辦這件事"}]}}, {"type": "assistant", "message": {"content": [{"type": "text", "text": text}]}}, ] with open(path, "w") as f: for r in rows: f.write(json.dumps(r, ensure_ascii=False) + "\n") PY } # run → 設定/清空 env,回傳 out(含 stderr),rc 存 $RC run() { sid="$1"; cf="$2"; tr="$3" if [ "$cf" = "cf" ]; then out=$(printf '{"transcript_path":"%s","session_id":"%s"}' "$tr" "$sid" \ | CLAUDE_PROJECT_DIR="$TMP" \ CLOUDFLARE_API_TOKEN_YOULIN_CC_USE="fake-token" \ CLOUDFLARE_ACCOUNT_ID_YOULIN="fake-acct" \ sh "$HOOK" 2>&1); RC=$? else out=$(printf '{"transcript_path":"%s","session_id":"%s"}' "$tr" "$sid" \ | CLAUDE_PROJECT_DIR="$TMP" \ CLOUDFLARE_API_TOKEN_YOULIN_CC_USE="" \ CLOUDFLARE_ACCOUNT_ID_YOULIN="" \ sh "$HOOK" 2>&1); RC=$? fi } # t <期望exit> <說明> ... t() { want="$1"; cf="$2"; desc="$3"; shift 3 N=$((N+1)); tr="$TMP/tr-$N.jsonl"; mk "$tr" "$@" run "bvtest_$N" "$cf" "$tr" 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 } echo "── A 群:該放行 ──────────────────────────────────────────────" t 0 nocf "沒有前端驗過的宣稱 ⇒ 與本閘無關" \ 'T:我把後端 API 跑了一遍,回 200。' t 0 nocf "宣稱驗前端 + 明說「本次無前端」⇒ 逃生口放行" \ 'T:這條線是純腳本,本次無前端可驗。' t 0 cf "宣稱驗前端 + 這回合用過 Claude_Browser__computer ⇒ 已驗放行" \ 'T:前端我複驗過了。' 'U:mcp__Claude_Browser__computer' t 0 cf "宣稱驗前端 + 這回合打過 CF browser-rendering 端點 ⇒ 已驗放行" \ 'T:畫面確認過了。' 'U:curl -X POST https://api.cloudflare.com/client/v4/accounts/x/browser-rendering/screenshot' echo echo "── B 群:有能力卻沒用它 → 該擋,且教對的那一種 ─────────────────" # CF 能力在場、這回合只有 curl|grep 宣稱 ⇒ 擋,且教 CF 而不是 MCP N=$((N+1)); tr="$TMP/tr-cf.jsonl" mk "$tr" 'T:我 curl 抓了頁面,grep 到文案,前端驗過了。' run "bvtest_cf" "cf" "$tr" if [ "$RC" -eq 2 ] \ && printf '%s' "$out" | grep -q 'browser-rendering/screenshot' \ && ! printf '%s' "$out" | grep -q 'preview_start'; then printf ' ✅ 有 CF 能力沒用它 ⇒ 擋,且教 CF(browser-rendering),不是 MCP 三步\n'; PASS=$((PASS+1)) else printf ' ❌ CF 能力在場時,擋法或教學不對(exit=%s)\n' "$RC" printf '%s\n' "$out" | sed -n '1,8p' | sed 's/^/ /'; FAIL=$((FAIL+1)); fi # 只有 MCP 能力(transcript 早一則用過 Claude_Browser__,但非驗證類、且這回合沒用)⇒ 擋,且教 MCP N=$((N+1)); tr="$TMP/tr-mcp.jsonl" mk2 "$tr" "mcp__Claude_Browser__close" "我 curl 抓了頁面,前端驗過了。" out=$(printf '{"transcript_path":"%s","session_id":"%s"}' "$tr" "bvtest_mcp" \ | CLAUDE_PROJECT_DIR="$TMP" CLOUDFLARE_API_TOKEN_YOULIN_CC_USE="" CLOUDFLARE_ACCOUNT_ID_YOULIN="" \ sh "$HOOK" 2>&1); RC=$? if [ "$RC" -eq 2 ] \ && printf '%s' "$out" | grep -q 'preview_start' \ && ! printf '%s' "$out" | grep -q 'browser-rendering'; then printf ' ✅ 只有 MCP 能力沒用它 ⇒ 擋,且教 MCP 三步,不是 CF\n'; PASS=$((PASS+1)) else printf ' ❌ MCP 能力在場時,擋法或教學不對(exit=%s)\n' "$RC" printf '%s\n' "$out" | sed -n '1,8p' | sed 's/^/ /'; FAIL=$((FAIL+1)); fi echo echo "── C 群:一種能力都沒有 → 不誤攔(ISEP#121 的正題)─────────────" echo " 演練:這台沒有 CF env、transcript 也沒 Claude_Browser__,卻宣稱驗了前端" # 第一次:提醒一次(缺什麼、怎麼補),exit 2 N=$((N+1)); tr="$TMP/tr-nocap.jsonl" mk "$tr" 'T:我 curl 抓了 stage 頁面,grep 到標題,前端驗過了。' run "bvtest_nocap1" "nocf" "$tr" first_rc="$RC"; first_out="$out" if [ "$first_rc" -eq 2 ] \ && printf '%s' "$first_out" | grep -q '一種瀏覽器能力都沒有' \ && printf '%s' "$first_out" | grep -q 'CLOUDFLARE_API_TOKEN_YOULIN_CC_USE' \ && printf '%s' "$first_out" | grep -q 'Browser Rendering: Edit' \ && printf '%s' "$first_out" | grep -q '只在這裡提醒一次'; then printf ' ✅ 第一次:提醒「缺什麼、怎麼補」(要哪個 env、找 leo 加什麼權限)\n'; PASS=$((PASS+1)) else printf ' ❌ 第一次的提醒內容不對(exit=%s)\n' "$first_rc" printf '%s\n' "$first_out" | sed -n '1,12p' | sed 's/^/ /'; FAIL=$((FAIL+1)); fi # 第二次:同一個 session、同樣沒能力、同樣宣稱 ⇒ 放行(不再每回合誤攔) N=$((N+1)); tr2="$TMP/tr-nocap2.jsonl" mk "$tr2" 'T:我又 curl 抓了一次,前端驗過了。' run "bvtest_nocap1" "nocf" "$tr2" # ← 故意沿用同一個 session_id if [ "$RC" -eq 0 ]; then printf ' ✅ 第二次(同 session):放行 ⇒ 不再每回合誤攔(ISEP#121 主症狀已解)\n'; PASS=$((PASS+1)) else printf ' ❌ 第二次還在攔(exit=%s)⇒ 仍會每回合誤攔\n' "$RC" printf '%s\n' "$out" | sed -n '1,8p' | sed 's/^/ /'; FAIL=$((FAIL+1)); fi echo echo "── C 群:沒能力時,逃生口仍走得通 ───────────────────────────" t 0 nocf "沒能力 + 明說「本次無前端」⇒ 照樣放行(逃生口不受能力影響)" \ 'T:宣稱一下前端驗過的字樣,但其實本次無前端可驗。' echo echo "── 訊息裡用引號 heredoc,反引號不被當命令執行(mistakes.md)─────" N=$((N+1)); tr="$TMP/tr-heredoc.jsonl" mk "$tr" 'T:我 curl 抓了頁面,前端驗過了。' run "bvtest_heredoc" "cf" "$tr" if printf '%s' "$out" | grep -q 'Cache-Control: no-cache' \ && ! printf '%s' "$out" | grep -qi 'command not found\|line [0-9].*: '; then printf ' ✅ 訊息原樣印出(反引號沒被展開執行)\n'; PASS=$((PASS+1)) else printf ' ❌ 訊息疑似被 shell 展開(反引號被當命令)\n' printf '%s\n' "$out" | sed -n '1,4p' | sed 's/^/ /'; FAIL=$((FAIL+1)); fi echo echo "── 壞輸入不准亂擋(fail-open)────────────────────────────────" for payload in '{}' '{"transcript_path":"/nonexistent/xx.jsonl"}' 'not-json' ''; do N=$((N+1)) printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP" \ CLOUDFLARE_API_TOKEN_YOULIN_CC_USE="" CLOUDFLARE_ACCOUNT_ID_YOULIN="" \ sh "$HOOK" >/dev/null 2>&1; rc=$? label="${payload:-(空輸入)}" if [ "$rc" -eq 0 ]; then printf ' ✅ 壞輸入 %-34s ⇒ exit=0\n' "$label"; PASS=$((PASS+1)) else printf ' ❌ 壞輸入 %-34s ⇒ exit=%s(期望 0)\n' "$label" "$rc"; FAIL=$((FAIL+1)); fi done echo echo "────── 通過 $PASS / 失敗 $FAIL (共 $N 向)" [ "$FAIL" -eq 0 ]