Files
ISEP/hooks/tests/dispatch-format-guard.test.sh
T
Leo 5ac06abc95 工人有名字+回覆也是派工+未經調查不寫診斷(inkstone/ISEP#86/#87/#88)
三張票同一族(誰在派、派給誰、派的內容住哪裡),做在同一條分支:

#86 工人名單:agents/ 七位有名字的工人+scripts/roster+hooks/roster-guard.sh
    派工用 Task 的 subagent_type 指名,派工單格式一個字都沒改;
    指對名字就把那位的檔案原文注入(你是誰/先讀什麼/你的紅線)。
    【身份】欄同時吃得下工人名字(原本三個角色照舊)。

#87 未經調查不寫診斷:hooks/diagnosis-evidence-guard.sh + investigate-first-stamp.sh
    三個結構訊號(派過人查沒/有沒有走得過去的出處/有沒有份量),
    一個關鍵字比對都沒有;轉述有出處不會被誤擋。

#88 回覆也是派工:不另造閘,把攔截點加掛上去。
    hooks/lib/dispatch_parse.py 的 tool_channel() 一次列全所有通往 subagent 的路
    (SendMessage/雲端 session・trigger/claude -p);擋下來時把那段內容原文印出來。
    subagent 往上回報(to: "main")=交件不是派工,刻意不管。

順手修掉一個真的會咬人的 flake:dispatch-format-guard 原本開四支 python 各讀一個欄位,
機器忙的時候某個欄位會靜靜變空字串(實測連跑 10 次有 1 次「豁免了卻還是被擋」)。
四個欄位改成一次讀完。

版本號待總管定(plugin.json 只更新了描述裡的數字,版本沒動)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBu4Sa1cGntKFRBYNZ6xs
2026-08-28 01:15:09 +00:00

326 lines
16 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
# dispatch-format-guard.sh 的迴歸測試(inkstone/ISEP#30 comment 432243254327)。
#
# **離線、不打網路、不花錢、每次結果一樣**——這支閘是純結構判斷,沒有語意判官,
# 所以它不像 ask-user-question-guard 那樣需要一支 live 測試量準度。
#
# 三群,兩個方向都要有證據:
# A 群「該放行」——合規的派工(只有票號)、不是派工的動作、沒有【工單】(別人的地盤)、
# 豁免戳記;**而且合規時要真的把共通規定注入出去**
# B 群「該擋」 ——真跡一份(那 5 次違規裡唯一還拿得回來的),
# 加上一條規則一個最小案例
# C 群「訊息本身」——講得出出路、沒有被 shell 展開
#
# 用法:hooks/tests/dispatch-format-guard.test.sh [hook 路徑]
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOK="${1:-$(cd "$HERE/.." && pwd)/dispatch-format-guard.sh}"
FIX="$HERE/fixtures"
PASS=0; FAIL=0; N=0
# payload <prompt 檔或字串> [tool_name]
payload() {
python3 - "$1" "${2:-Task}" <<'PY'
import json, os, sys
src, tool = sys.argv[1], sys.argv[2]
prompt = open(src, encoding="utf-8").read() if os.path.isfile(src) else src
print(json.dumps({"session_id": "S-TEST", "hook_event_name": "PreToolUse",
"tool_name": tool, "tool_input": {"prompt": prompt}},
ensure_ascii=False))
PY
}
# reply_payload <tool_name> <裝話的欄位名> <內容> [額外欄位 JSON]
# ISEP#88:通往 subagent 的路不只 Task/Agent 一條,每一條裝話的欄位還不一樣。
reply_payload() {
python3 - "$1" "$2" "$3" "${4:-{\}}" <<'RP'
import json, sys
tool, key, text, extra = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
ti = {key: text}
ti.update(json.loads(extra))
print(json.dumps({"session_id": "S-TEST", "hook_event_name": "PreToolUse",
"tool_name": tool, "tool_input": ti}, ensure_ascii=False))
RP
}
# t <期望 exit> <說明> <payload JSON>
t() {
want="$1"; desc="$2"; body="$3"
N=$((N+1))
err=$(printf '%s' "$body" | bash "$HOOK" 2>&1 >/dev/null); 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' "$err" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
clean() { rm -f /tmp/.dispatch-ok-S-TEST 2>/dev/null || true; }
echo "── A 群:該放行 ────────────────────────────────────────────────"
clean
t 0 "① 合規:整份派工單只有一行票號" \
"$(payload '【工單】inkstone/ISEP#30 → comment 4322')"
t 0 "② 合規:兩張票就兩行【工單】" \
"$(payload '【工單】inkstone/ISEP#30
【工單】inkstone/InkStoneCo#55')"
t 0 "③ 合規:用 #issuecomment- 定址也認得" \
"$(payload '【工單】inkstone/ISEP#30#issuecomment-4327')"
# ── inkstone/ISEP#65CLAUDE.md 規定的合格格式(全形/半形括號包住 → comment M)
# 曾經被本閘自己擋下——這裡把它釘成迴歸測試,不再走回頭路。
t 0 "③b 合規:全形括號包住 → comment M(CLAUDE.md 標準寫法,曾被自己的閘擋下)" \
"$(payload '【工單】inkstone/ISEP#65(→ comment 4515')"
t 0 "③c 合規:半形括號變體" \
"$(payload '【工單】inkstone/ISEP#65(→ comment 4515)')"
# ── inkstone/ISEP#65 test 4:不該擋的三種雜訊 ────────────────────────
t 0 "③d 不該擋:票號後面只有空行" \
"$(payload '【工單】inkstone/ISEP#65
')"
t 0 "③e 不該擋:票號後面只有一句「謝謝」(零資訊禮貌收尾,白名單豁免)" \
"$(payload '【工單】inkstone/ISEP#65
謝謝')"
t 0 "③f 不該擋:禮貌收尾帶標點也認得(「謝謝!」)" \
"$(payload '【工單】inkstone/ISEP#65
謝謝!')"
t 2 "③g 白名單不是漏洞:禮貌詞混在真內容裡照樣算數" \
"$(payload '【工單】inkstone/ISEP#65
辛苦了,順便告訴你 main 現在是 8e28041')"
t 0 "④ 不是派工的動作(Bash)→ 一律不管" \
'{"tool_name":"Bash","tool_input":{"command":"ls"}}'
t 0 "⑤ 整包不是合法 JSON → fail-open" 'this is not json at all'
t 0 "⑥ 沒有【工單】→ 閉嘴,那是 no-ticket-no-dispatch 的地盤(不准兩支閘同時開口)" \
"$(payload '去把 arcrun 那個 worker 修一修,順便跑一下測試')"
# 注入:合規時要真的把共通規定送出去(leo 的驗收條件之一——
# 「收工方**沒讀派工單**也知道要貼回原票」)
N=$((N+1))
inj=$(printf '%s' "$(payload '【工單】inkstone/ISEP#30')" | bash "$HOOK" 2>/dev/null)
if printf '%s' "$inj" | python3 -c '
import sys, json
d = json.load(sys.stdin)
c = d["hookSpecificOutput"]["additionalContext"]
assert "貼回那張票" in c, "沒有交件方式"
assert "【身份】" in c, "沒有身份欄規定"
assert "不准 push" in c, "沒有 main 紅線"
' 2>/dev/null; then
printf ' ✅ ⑦ 合規的派工會被注入共通規定(交件方式+身份欄+不准 push main\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑦ 共通規定沒有被注入,或缺了其中一項\n'
printf '%s\n' "$inj" | sed -n '1,4p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
clean
touch /tmp/.dispatch-ok-S-TEST
t 0 "⑧ 明示豁免戳記在 → 放行一次" \
"$(payload '【工單】inkstone/ISEP#30
這一次真的有例外')"
N=$((N+1))
if [ -f /tmp/.dispatch-ok-S-TEST ]; then
printf ' ❌ ⑧b 豁免戳記用完沒被消掉(會變成永久開關)\n'; FAIL=$((FAIL+1))
else
printf ' ✅ ⑧b 豁免戳記用完就消失,不是永久開關\n'; PASS=$((PASS+1))
fi
clean
echo
echo "── B 群:該擋 ──────────────────────────────────────────────────"
t 2 "⑨ **真跡**:產生 ISEP#30 這條線的那一次派工(散文開場+指令圍欄+三點就地+交件)" \
"$(payload "$FIX/real-violation-isep30.txt")"
t 2 "⑩ 只多一行散文——沒有「一句而已」這種豁免" \
"$(payload '【工單】inkstone/ISEP#30
記得先讀 CLAUDE.md')"
t 2 "⑪ 帶【交件】——那是「每次都一樣」,該進共通規定" \
"$(payload '【工單】inkstone/ISEP#30
【交件】貼回 inkstone/ISEP#30,回報分支名')"
t 2 "⑫ 帶【就地】——那是「這次才知道」,該寫進票" \
"$(payload '【工單】inkstone/ISEP#30
【就地】main 現在是 8e7e265')"
t 2 "⑬ 帶【人格】——已收回的欄位" \
"$(payload '【工單】inkstone/ISEP#30
【人格】ISEP plugin 維護者')"
t 2 "⑭ 票號形狀不對(裸號,跨 repo 會撞號)" \
"$(payload '【工單】#30')"
t 2 "⑮ 內容躲在圍欄裡也算數(圍欄只讓裡面的【】不被當欄位,不讓內容變成不存在)" \
"$(payload '【工單】inkstone/ISEP#30
```
【就地】main 現在是 8e7e265
```')"
# ── inkstone/ISEP#65 test 5:拿「寫完規則之後總管又犯的 8 次」當回歸樣本 ──
# 內容是從各自票的真實內文摘錄(hooks/tests/fixtures/README.md 記著來歷),
# 不是想像出來的例子。8 種形狀都要被擋,證明這不是只補了一個特例。
t 2 "⑮b 回歸:ISEP#60 那次的內容(自造第四套機制的診斷)" \
"$(payload "$FIX/real-content-isep60.txt")"
t 2 "⑮c 回歸:ISEP#61 那次的內容(push-guard 戳記機制失敗史)" \
"$(payload "$FIX/real-content-isep61.txt")"
t 2 "⑮d 回歸:ISEP#64 那次的內容(PR 分診現況數字)" \
"$(payload "$FIX/real-content-isep64.txt")"
t 2 "⑮e 回歸:Arcrun#142 那次的內容(庫索引從沒被產生過)" \
"$(payload "$FIX/real-content-arcrun142.txt")"
t 2 "⑮f 回歸:Arcrun#144 那次的內容(樹狀圖驗收記錄)" \
"$(payload "$FIX/real-content-arcrun144.txt")"
t 2 "⑮g 回歸:Arcrun#165 那次的內容(MCP 徹查現場)" \
"$(payload "$FIX/real-content-arcrun165.txt")"
t 2 "⑮h 回歸:arcrun-rag#104 那次的內容(收檔實測數字)" \
"$(payload "$FIX/real-content-arcrunrag104.txt")"
t 2 "⑮i 回歸:InkStoneCo#102 那次的內容(milestone 打假背景)" \
"$(payload "$FIX/real-content-inkstoneco102.txt")"
echo
echo "── C 群:訊息本身 ──────────────────────────────────────────────"
clean
msg=$(printf '%s' "$(payload "$FIX/real-violation-isep30.txt")" | bash "$HOOK" 2>&1 >/dev/null)
N=$((N+1))
if printf '%s' "$msg" | grep -q '這句話換一張票還成立嗎' \
&& printf '%s' "$msg" | grep -q '共通規定' \
&& printf '%s' "$msg" | grep -q '寫進那張票'; then
printf ' ✅ ⑯ 訊息講得出判準與兩條出路(共通規定/寫進票)\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑯ 訊息缺了判準或其中一條出路\n'
printf '%s\n' "$msg" | sed -n '1,10p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
# 迴歸:訊息裡的反引號一度在別支閘被 shell 當成命令替換執行掉
# 2026-08-26 ask-user-question-guard:閘照擋,但**它教人怎麼解的那兩行變成空白**)
N=$((N+1))
if printf '%s' "$msg" | grep -q 'scripts/ticket say' \
&& printf '%s' "$msg" | grep -q 'touch /tmp/.dispatch-ok-S-TEST' \
&& ! printf '%s' "$msg" | grep -qi 'is a directory\|command not found'; then
printf ' ✅ ⑰ 訊息原文照印:反引號沒被當命令執行,出路那兩行沒有變空白\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑰ 訊息被 shell 展開了(指令消失,或冒出 shell 錯誤)\n'
printf '%s\n' "$msg" | sed -n '1,20p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
# 訊息要點名是哪一格違規(可迭代的最小單位——leo 4325:「照這種散文寫法根本無法迭代」)
N=$((N+1))
if printf '%s' "$msg" | grep -q '派工單不只有票號'; then
printf ' ✅ ⑱ 訊息點得出違反的是哪一條規則,不是一句籠統的「格式不對」\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑱ 訊息沒有點名違反哪一條\n'; FAIL=$((FAIL+1))
fi
echo
echo "── D 群:回覆也是派工(inkstone/ISEP#88)────────────────────────"
clean
# 驗收 1:回覆一個正在跑的 subagent,訊息裡夾一段修改要求 ⇒ 擋
t 2 "⑲ **驗收 1**SendMessage 夾了一段修改要求 ⇒ 擋" \
"$(reply_payload SendMessage message '順便把 hooks.json 也改一下,改完重跑一次測試貼給我' '{"to":"isep-hand"}')"
# 驗收 2:內容貼上票之後,回覆只給票號 ⇒ 放行
t 0 "⑳ **驗收 2**:內容貼上票之後,回覆只有票號 ⇒ 放行" \
"$(reply_payload SendMessage message '【工單】inkstone/ISEP#88 → comment 5001' '{"to":"isep-hand"}')"
# 驗收 1 的後半:擋的時候要把那段內容原文印出來(不然人得回頭自己找)
N=$((N+1))
rmsg=$(printf '%s' "$(reply_payload SendMessage message '去 main 分支重驗那三支 hook,把輸出貼回來' '{"to":"scout"}')" \
| bash "$HOOK" 2>&1 >/dev/null)
if printf '%s' "$rmsg" | grep -q '直接貼上票' \
&& printf '%s' "$rmsg" | grep -q '去 main 分支重驗那三支 hook'; then
printf ' ✅ ㉑ 擋下來時把那段內容**原文**印出來,可以直接貼上票\n'; PASS=$((PASS+1))
else
printf ' ❌ ㉑ 沒有把原文印出來(出路要求「寫進票」,找不到原文就得回頭自己翻)\n'
printf '%s\n' "$rmsg" | sed -n '1,10p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
N=$((N+1))
if printf '%s' "$rmsg" | grep -q '回覆也是派工'; then
printf ' ✅ ㉒ 訊息說得出這是「回覆」那條路,不是照抄派工單那段\n'; PASS=$((PASS+1))
else
printf ' ❌ ㉒ 回覆被擋時給的是派工單的說法,人會找不到自己違反了什麼\n'; FAIL=$((FAIL+1))
fi
# 真跡重演(驗收 4):那兩則回覆的原文只活在總管的對話裡,拿不回來;
# 這裡改用**同一天真的寫進派工單的那幾段內容**fixtures/README.md 記著來歷),
# 從**回覆這條路**再送一次——形狀一樣:一大段這次才知道的事實,票上一個字都沒有。
for fx in real-content-isep60 real-content-arcrun142 real-content-arcrunrag104; do
t 2 "㉓ 真跡($fx)從回覆這條路送出去 ⇒ 一樣擋" \
"$(reply_payload SendMessage message "$(cat "$FIX/$fx.txt")" '{"to":"isep-hand"}')"
done
echo "── E 群:通往 subagent 的路要列全(一條都不能漏)──────────────"
t 2 "㉔ 雲端開新 sessioncreate_session 的 prompt" \
"$(reply_payload mcp__Claude_Code_Remote__create_session prompt '去把那個 worker 修好,順便升版')"
t 2 "㉕ 送訊息進某個 sessionsend_message 的 text" \
"$(reply_payload mcp__Claude_Code_Remote__send_message text '改一下剛剛那段,然後重跑測試')"
t 2 "㉖ 排程派工(create_trigger 的 prompt" \
"$(reply_payload mcp__Claude_Code_Remote__create_trigger prompt '每天早上去檢查 PR 有沒有結論,有的話處理掉')"
t 2 "㉗ 當場點燃排程派工(fire_trigger 的 text" \
"$(reply_payload mcp__Claude_Code_Remote__fire_trigger text '這次順便把 bundle 也重打一次')"
t 2 "㉘ 排一則訊息給自己(send_later 的 message" \
"$(reply_payload mcp__Claude_Code_Remote__send_later message '記得回頭把那三支 hook 的測試補齊')"
t 2 "㉙ 用 CLI 開一個新 sessionBash 的 claude -p" \
'{"session_id":"S-TEST","tool_name":"Bash","tool_input":{"command":"claude -p '"'"'去把 arcrun 那個 worker 修一修'"'"'"}}'
echo "── F 群:這幾條**不該**被這支閘碰(誤攔比漏擋嚴重)─────────────"
t 0 "㉚ subagent 往上回報(SendMessage to: main)=交件不是派工 ⇒ 不管" \
"$(reply_payload SendMessage message '做完了,分支是 feat/x,測試 26/26' '{"to":"main"}')"
t 0 "㉛ 一般的 Bash 指令裡剛好有【工單】字樣(判準是指令名不是內容)⇒ 不管" \
'{"session_id":"S-TEST","tool_name":"Bash","tool_input":{"command":"echo 【工單】inkstone/ISEP#88 順便改一下那個檔 > /tmp/x.md"}}'
t 0 "㉜ claude 但沒有 -p(開互動 session,不是一次性派工)⇒ 不管" \
'{"session_id":"S-TEST","tool_name":"Bash","tool_input":{"command":"claude --version"}}'
t 0 "㉝ 回覆是空的(這條路這次沒帶任何話)⇒ 不管" \
"$(reply_payload SendMessage message '' '{"to":"isep-hand"}')"
N=$((N+1))
child=$(printf '%s' "$(reply_payload SendMessage message '順便把那個檔也改一下' '{"to":"peer"}')" \
| CLAUDE_CODE_CHILD_SESSION=1 bash "$HOOK" 2>&1 >/dev/null); crc=$?
if [ "$crc" -eq 0 ]; then
printf ' ✅ ㉞ 子 session 送出的回覆不管(它送的是交件,不是派工)\n'; PASS=$((PASS+1))
else
printf ' ❌ ㉞ 子 session 的回覆被擋了(exit=%s)——那會擋掉交件本身\n' "$crc"; FAIL=$((FAIL+1))
fi
N=$((N+1))
kid=$(printf '%s' "$(payload '【工單】inkstone/ISEP#88
順便把 hooks.json 也改一下')" | CLAUDE_CODE_CHILD_SESSION=1 bash "$HOOK" 2>&1 >/dev/null); krc=$?
if [ "$krc" -eq 2 ]; then
printf ' ✅ ㉟ 但子 session **自己往下派工**(Task)時,派工單的規矩照樣管它\n'; PASS=$((PASS+1))
else
printf ' ❌ ㉟ 子 session 派工被整個放掉了(exit=%s)——那是一個逃逸艙口\n' "$krc"; FAIL=$((FAIL+1))
fi
clean
echo
echo "══ $PASS/$N 通過${FAIL:+$FAIL 個失敗} ══"
[ "$FAIL" -eq 0 ]