b9533527cc
browser-verify-guard.sh 舊版每回合都要求 mcp__Claude_Browser__*,但那套工具在 雲端這台叫不出來(ToolSearch:No matching deferred tools),容器內 chromium 也 連不出去(ERR_TUNNEL_CONNECTION_FAILED)⇒ 2026-09-01 一整天每回合誤攔一次, 在懲罰一個「這台辦不到」的要求。 閘現在先判斷「這個 session 有哪種瀏覽器能力」(正向存在性檢查,不是關鍵字): - CF Browser Rendering:環境變數 CLOUDFLARE_API_TOKEN_YOULIN_CC_USE + CLOUDFLARE_ACCOUNT_ID_YOULIN 都在場(雲端首選) - Browser MCP:這個 session 的 transcript 出現過 Claude_Browser__ - 容器內 chromium:ISEP#121 實測雲端連不出去,不當能力來源 判準是「有沒有用某種瀏覽器能力」這個動作:這回合叫過 Claude_Browser__(computer…) 或打過 .../browser-rendering/ 端點 ⇒ 放行。有能力卻沒用它 ⇒ 擋,且教實際可用的 那一種(CF 教 browser-rendering 端點的 curl,MCP 教三步)。一種能力都沒有 ⇒ 訊息改成「缺什麼、怎麼補」(找 leo 幫 token 加 Browser Rendering: Edit/明說 「本次無前端」),且每個 session 只提醒一次(stamp),不再每回合誤攔。 測試 hooks/tests/browser-verify-guard.test.sh(14/14,全離線): - C 群演練「一種能力都沒有」:第一次提醒一次、同 session 第二次放行(不再誤攔) - B 群:有 CF 能力教 CF 不教 MCP;只有 MCP 能力教 MCP 不教 CF - A 群:用過某種瀏覽器能力/明說本次無前端 ⇒ 放行;fail-open docs/TESTING.md 加 A43;docs/hooks-inventory.md 更新該支說明(+0 支、+0 條、 腳本不變:62 支、88 條當場數出)。 版本待總管定(plugin.json 未動)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TGxitYq49FzYC7EFkbhzF5
198 lines
9.8 KiB
Bash
Executable File
198 lines
9.8 KiB
Bash
Executable File
#!/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 <transcript 路徑> <block spec>...
|
||
# 單一 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 <transcript 路徑> <早期工具名> <本回合文字>
|
||
# 早一則 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 <session_id> <cf?> <transcript> → 設定/清空 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> <cf|nocf> <說明> <block spec>...
|
||
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 ]
|