#!/usr/bin/env bash # roster-guard.sh 的迴歸測試(inkstone/ISEP#86) # # 票上的四條驗收條件,一條一組: # 1 列出名單 → 看得到有哪幾個工人、各自管什麼 (scripts/roster list) # 2 派工要能指名派給誰 (subagent_type 認得出來) # 3 票上的紀錄看得出是哪個工人做的 (【身份】欄吃得下工人名字) # 4 派一個名單裡沒有的名字 → 要講得出「沒有這個人」 # # 另外兩群:**不該擋**(誤攔比漏擋嚴重)、**注入**(指對名字就把它的檔案原文送過去)。 # # 🔴 全程離線,不打網路、不花錢、每次結果一樣(純結構判斷,沒有語意判官)。 # # 用法:hooks/tests/roster-guard.test.sh [hook 路徑] set -u unset CLAUDE_CODE_CHILD_SESSION # 殘留會讓別的閘行為不同;本閘不看它,但別讓環境有變數 HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$HERE/../.." && pwd)" HOOK="${1:-$ROOT/hooks/roster-guard.sh}" TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT PASS=0; FAIL=0; N=0 # payload [tool_name] payload() { python3 - "$1" "$2" "${3:-Task}" <<'PY' import json, sys print(json.dumps({"session_id": "S-ROSTER", "hook_event_name": "PreToolUse", "tool_name": sys.argv[3], "tool_input": {"prompt": sys.argv[1], "subagent_type": sys.argv[2]}}, ensure_ascii=False)) PY } # t <期望 exit> <說明> [必須出現的字串] [ISEP_ROSTER_DIR] t() { want="$1"; desc="$2"; body="$3"; must="${4:-}"; rdir="${5:-}" N=$((N+1)) if [ -n "$rdir" ]; then err=$(printf '%s' "$body" | ISEP_ROSTER_DIR="$rdir" bash "$HOOK" 2>&1 >/dev/null); rc=$? else err=$(printf '%s' "$body" | bash "$HOOK" 2>&1 >/dev/null); rc=$? fi ok=1 [ "$rc" -eq "$want" ] || ok=0 if [ -n "$must" ] && ! printf '%s' "$err" | grep -qF "$must"; then ok=0; fi if [ "$ok" -eq 1 ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1)) else printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \ "${must:+ 且訊息含「$must」}" "$rc" printf '%s\n' "$err" | sed -n '1,6p' | sed 's/^/ /' FAIL=$((FAIL+1)) fi } clean() { rm -f /tmp/.roster-ok-S-ROSTER 2>/dev/null || true; } clean echo "── 驗收 1:列出名單 ────────────────────────────────────────────" N=$((N+1)) out=$(python3 "$ROOT/scripts/roster" list 2>&1) if printf '%s' "$out" | grep -q 'isep-hand' && printf '%s' "$out" | grep -q 'inkstone/ISEP' \ && printf '%s' "$out" | grep -q '負責 repo'; then printf ' ✅ ① `roster list` 印得出「有哪幾個工人、各自管什麼」\n'; PASS=$((PASS+1)) else printf ' ❌ ① `roster list` 沒有名字或沒有負責的 repo\n'; printf '%s\n' "$out" | sed 's/^/ /' FAIL=$((FAIL+1)) fi N=$((N+1)) if python3 "$ROOT/scripts/roster" show isep-hand 2>&1 | grep -q '## 紅線'; then printf ' ✅ ② `roster show` 印得出那位工人的紅線(它開工時會讀到的東西)\n'; PASS=$((PASS+1)) else printf ' ❌ ② `roster show` 沒有印出紅線段\n'; FAIL=$((FAIL+1)) fi N=$((N+1)) if [ "$(python3 "$ROOT/scripts/roster" which inkstone/ISEP 2>/dev/null)" = "isep-hand" ]; then printf ' ✅ ③ `roster which ` 答得出這個 repo 該派給誰\n'; PASS=$((PASS+1)) else printf ' ❌ ③ `roster which inkstone/ISEP` 沒有回 isep-hand\n'; FAIL=$((FAIL+1)) fi echo "── 驗收 2:派工要能指名派給誰(不該擋)────────────────────────" for who in isep-hand arcrun-hand arcrun-rag-hand inkstoneco-hand mira-hand collector-hand scout; do t 0 "④ 指名 $who ⇒ 放行" "$(payload '【工單】inkstone/ISEP#86' "$who")" done t 0 "⑤ Agent 這條路也認得(不是只認 Task)" \ "$(payload '【工單】inkstone/ISEP#86' 'scout' 'Agent')" echo "── 驗收 4:名單上沒有的名字 → 講得出「沒有這個人」──────────────" t 2 "⑥ 派 general-purpose(就是「沒有名字的臨時工」)⇒ 擋,並說沒有這個人" \ "$(payload '【工單】inkstone/ISEP#86' 'general-purpose')" '沒有「general-purpose」這個人' t 2 "⑦ 完全沒指名(subagent_type 是空的)⇒ 擋" \ "$(payload '【工單】inkstone/ISEP#86' '')" '沒有指名工人' t 2 "⑧ 打錯字(isep-hands)⇒ 擋。名字是唯一識別碼,不做模糊比對" \ "$(payload '【工單】inkstone/ISEP#86' 'isep-hands')" '沒有「isep-hands」這個人' t 2 "⑨ 擋下來的時候要把整份名單印出來(不然人不知道有誰可以派)" \ "$(payload '【工單】inkstone/ISEP#86' 'nobody')" 'arcrun-rag-hand' echo "── 不該擋(誤攔比漏擋嚴重)──────────────────────────────────" t 0 "⑩ 沒有【工單】⇒ 閉嘴(那是 no-ticket-no-dispatch 的地盤,不准兩支閘同時開口)" \ "$(payload '去查一下那個 worker 為什麼沒更新' 'general-purpose')" t 0 "⑪ 不是派工的動作(Bash)⇒ 一律不管" \ '{"session_id":"S-ROSTER","tool_name":"Bash","tool_input":{"command":"ls"}}' t 0 "⑫ 整包不是合法 JSON ⇒ fail-open" 'this is not json at all' t 0 "⑬ 名單目錄是空的 ⇒ fail-open(讀不到的名單不該讓整台機器派不了工)" \ "$(payload '【工單】inkstone/ISEP#86' 'general-purpose')" '' "$TMP" clean touch /tmp/.roster-ok-S-ROSTER t 0 "⑭ 明示豁免戳記在 ⇒ 放行一次" \ "$(payload '【工單】inkstone/ISEP#86' 'general-purpose')" N=$((N+1)) if [ -f /tmp/.roster-ok-S-ROSTER ]; then printf ' ❌ ⑮ 豁免戳記用完沒被消掉(會變成永久開關)\n'; FAIL=$((FAIL+1)) else printf ' ✅ ⑮ 豁免戳記用完就消失,不是永久開關\n'; PASS=$((PASS+1)) fi clean echo "── 注入:指對名字就把那位工人的檔案原文送過去 ──────────────────" N=$((N+1)) inj=$(printf '%s' "$(payload '【工單】inkstone/ISEP#86' 'isep-hand')" | bash "$HOOK" 2>/dev/null) if printf '%s' "$inj" | python3 -c ' import sys, json c = json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"] assert "isep-hand" in c, "沒有名字" assert "inkstone/ISEP" in c, "沒有負責的 repo" assert "開工前先讀" in c, "沒有「開工前先讀」" assert "紅線" in c, "沒有紅線" ' 2>/dev/null; then printf ' ✅ ⑯ 指對名字 ⇒ 注入「你是誰/負責哪個 repo/開工前先讀/紅線」四件\n'; PASS=$((PASS+1)) else printf ' ❌ ⑯ 沒有注入,或缺了其中一件\n'; printf '%s\n' "$inj" | sed -n '1,4p' | sed 's/^/ /' FAIL=$((FAIL+1)) fi N=$((N+1)) inj2=$(printf '%s' "$(payload '【工單】inkstone/Arcrun#142' 'arcrun-hand')" | bash "$HOOK" 2>/dev/null) if printf '%s' "$inj2" | grep -q 'arcrun-hand' && ! printf '%s' "$inj2" | grep -q 'isep-hand'; then printf ' ✅ ⑰ 注入的是**那一位**的檔案,不是整份名單\n'; PASS=$((PASS+1)) else printf ' ❌ ⑰ 注入的內容不是指名的那一位\n'; FAIL=$((FAIL+1)) fi echo "── 驗收 3:票上的紀錄看得出是哪個工人做的(身份欄)──────────────" N=$((N+1)) if python3 - "$ROOT" <<'PY' 2>/dev/null import sys, os sys.path.insert(0, os.path.join(sys.argv[1], "hooks", "lib")) import dispatch_parse as dp ok, _ = dp.parse_identity("【身份】isep-hand/inkstone/ISEP/feat/x") assert ok, "工人名字不被身份欄接受" ok2, _ = dp.parse_identity("【身份】總管/inkstone/ISEP/-") assert ok2, "原本的三個角色被弄壞了" bad, _ = dp.parse_identity("【身份】某某某/inkstone/ISEP/-") assert not bad, "亂寫的名字被放行了" PY then printf ' ✅ ⑱ 【身份】欄吃得下工人名字,原本三個角色照舊,亂寫的仍然擋\n'; PASS=$((PASS+1)) else printf ' ❌ ⑱ 身份欄沒有吃下工人名字(或把原本的三個角色弄壞了)\n'; FAIL=$((FAIL+1)) fi echo "── 驗收 4:真實的 subagent_type 形狀(2026-08-28 實撞的死鎖)──────" N=$((N+1)) if python3 - "$ROOT" <<'PY' 2>/dev/null import sys, os sys.path.insert(0, os.path.join(sys.argv[1], "hooks", "lib")) import roster d = os.path.join(sys.argv[1], "agents") # Claude Code 給 plugin agent 的真實值帶前綴,agents/*.md 的 name 沒有 assert roster.find("isep:isep-hand", d), "帶 plugin 前綴的真實 subagent_type 認不出來" assert roster.find("isep:scout", d), "帶前綴的 scout 認不出來" # 名單上的裸名字仍然要收(人會照 roster list 直接貼) assert roster.find("isep-hand", d), "裸名字被弄壞了" # 剝前綴不能變成放行任何東西 assert roster.find("isep:nobody", d) is None, "不存在的名字被放行了" assert roster.find("nobody", d) is None, "不存在的裸名字被放行了" assert roster.find("", d) is None, "空字串被放行了" PY then printf ' ✅ ⑲ 帶 plugin 前綴的真實 subagent_type 認得出來,不存在的仍然擋\n'; PASS=$((PASS+1)) else printf ' ❌ ⑲ 真實的 subagent_type(isep:isep-hand)認不出來——就是這個造成 8/28 的死鎖\n'; FAIL=$((FAIL+1)) fi echo printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N" [ "$FAIL" -eq 0 ] || exit 1