Files
ISEP/hooks/tests/gitea-arm-check.test.sh
Leo c1d80756d7 審核補件:第二個空殼閘(irreversible_dispatch_check.py)+既有測試套
總管複驗 PR 時發現 subagent 只補了一個空殼,還有第二個同款的:

  arcrun-intent-guard.sh      → exec 不存在的 .py ⇒ 擋掉全部(吵,PR 已修)
  irreversible-dispatch-guard.sh → 同款,但寫法是
      python3 <不存在> 2>/dev/null || echo '{"verdict":"OK"}'
    ⇒ **靜默放行全部**(危險,本 commit 補)

實測同一份派工單「驗過了就把舊分支刪掉」:
  InkStoneCo 版(有 .py)exit=2 擋 / ISEP 版(缺 .py)exit=0 放行
補完後:不可逆派工 exit=2、正常派工 exit=0、合規工作流 exit=0。

系統性掃描 ISEP 全部 hooks 引用的同目錄檔案:補完後 0 個缺檔(InkStoneCo 本來就是 0)。
順帶把 InkStoneCo 的 hooks/tests/ 四支既有測試一併帶過來。

🔴 這件事很重要:若先併 InkStoneCo#64(刪掉 .claude/hooks/),
   這台機器會失去那支唯一還能用的副本——變成真的沒有那道閘。
2026-08-20 20:06:23 +08:00

204 lines
13 KiB
Bash
Executable File
Raw Permalink 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
# gitea-arm-check.sh 的迴歸測試——只測「不必打真網路/不必真的等 leo」的幾種:
# 沒有待核請求/已過期/已被消耗過(重放保護)/Gitea 認證失敗/
# 核准者常數不接受環境變數覆蓋/票號缺失或非數字的待核檔會被略過不猜。
# 另有一組**用真 token 打真端點**的測試,驗證 2026-08-16 起「票號由呼叫端指定」
# 這件事真的有路由到對的票(見下方「不同票號各自路由」段)。
#
# 🔴 「Leo 真的回覆才放行」與「機器冒充 Leo」這兩種**必須打真的 Gitea**才有意義
# (核心判準就是比對 Gitea 上留言的 `user.login`),不適合塞進不碰網路的單元測試。
# 這兩種已經在 https://git.uncle6.me/inkstone/InkStoneCo/issues/34 上人工實測過,
# 證據見本次 PR 說明,不在這支重跑。
#
# 🔴 2026-08-13 總管審查後修正:`GITEA_ARM_OWNER/REPO/API/APPROVER_LOGIN`
# 全部改成寫死在 gitea-arm-common.sh,不再接受環境變數覆蓋(否則
# `GITEA_ARM_APPROVER_LOGIN=claude-code` 就能讓機器核准自己)。
# ⇒ 「Gitea 打不到」這條測資不能再用假網址注入,改用**真端點+無效 token**
# (回真的 401,一樣走得到「非 200 → fail-closed」那條分支)。
#
# 🪦 2026-08-16ISSUE 從第五個「寫死常數」名單裡移出來,改成請求時的參數
# (見 lib/gitea-arm-common.sh 檔頭說明:真正的安全邊界是 OWNER/REPO/
# APPROVER_LOGIN,不是票號本身)。相對地,pending 檔的 schema 多了 `issue` 欄位,
# 下面所有測資的 JSON 都要帶上它,否則會被「票號缺失」判定為壞檔而被略過。
#
# 用法:.claude/hooks/tests/gitea-arm-check.test.sh <repo根目錄>
# repo 根目錄要有 scripts/gitea-arm-check.sh + scripts/gitea-arm-request.sh
set -u
PROJ="${1:?用法: $0 <repo根目錄>}"
CHECK="$PROJ/scripts/gitea-arm-check.sh"
TMPPROJ=$(mktemp -d)
trap 'rm -rf "$TMPPROJ"' EXIT
# 借用真的 scripts/(唯讀),但狀態目錄與 .env 都指到隔離的臨時目錄
ln -s "$PROJ/scripts" "$TMPPROJ/scripts"
# 假 .envtoken 隨便填,「Gitea 打不到」那條測資會在網路那層失敗(本來就該擋),
# 其餘測資在打到 Gitea 之前就已經因為本地狀態被擋下,不會真的送出請求。
printf 'GITEA_TOKEN_CLAUDE_CODE=test-token-not-real\n' > "$TMPPROJ/.env"
PASS=0; FAIL=0
t(){ # t <期望 block|pass> <說明> <exit code>
got=$([ "$3" -eq 0 ] && echo pass || echo block)
if [ "$got" = "$1" ]; then echo " ✅ $2"; PASS=$((PASS+1))
else echo " ❌ $2 —— 期望 $1,實得 $got"; FAIL=$((FAIL+1)); fi
}
echo "── 完全沒有待核請求 ──"
rm -rf "$TMPPROJ/.claude/gitea-arm"
CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" >/tmp/gitea-arm-test-out.$$ 2>&1
t block "沒有 pending 目錄就該擋" $?
echo "── 已過期的請求 ──"
mkdir -p "$TMPPROJ/.claude/gitea-arm/pending"
NOW=$(date +%s)
jq -n --arg nonce "ARM-testexpired" --arg mission "測試" --arg issue "34" \
--argjson requested_at "$((NOW-3600))" --argjson expires_at "$((NOW-1))" \
--arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \
'{nonce:$nonce, mission:$mission, issue:$issue, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \
> "$TMPPROJ/.claude/gitea-arm/pending/ARM-testexpired.json"
CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" ARM-testexpired >/tmp/gitea-arm-test-out.$$ 2>&1
rc=$?
t block "過期的 nonce 該擋" $rc
[ -f "$TMPPROJ/.claude/gitea-arm/pending/ARM-testexpired.json" ] \
&& { echo " ❌ 過期後 pending 檔應該被清掉,卻還在"; FAIL=$((FAIL+1)); } \
|| { echo " ✅ 過期後 pending 檔已清掉"; PASS=$((PASS+1)); }
echo "── 已被消耗過的 nonce(防重放)──"
rm -rf "$TMPPROJ/.claude/gitea-arm"
mkdir -p "$TMPPROJ/.claude/gitea-arm/pending"
jq -n --arg nonce "ARM-testreplay" --arg mission "測試" --arg issue "34" \
--argjson requested_at "$NOW" --argjson expires_at "$((NOW+1800))" \
--arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \
'{nonce:$nonce, mission:$mission, issue:$issue, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \
> "$TMPPROJ/.claude/gitea-arm/pending/ARM-testreplay.json"
printf 'ARM-testreplay\t2026-01-01 00:00:00\t舊任務\n' > "$TMPPROJ/.claude/gitea-arm/consumed.log"
CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" ARM-testreplay >/tmp/gitea-arm-test-out.$$ 2>&1
rc=$?
t block "已消耗過的 nonce 該擋(即使有效期還沒到)" $rc
[ -f "$TMPPROJ/.claude/gitea-arm/pending/ARM-testreplay.json" ] \
&& { echo " ❌ 重放判定後 pending 檔應該被清掉,卻還在"; FAIL=$((FAIL+1)); } \
|| { echo " ✅ 重放判定後 pending 檔已清掉"; PASS=$((PASS+1)); }
echo "── Gitea 認證失敗(真端點+無效 tokenfail-closed)──"
rm -rf "$TMPPROJ/.claude/gitea-arm"
mkdir -p "$TMPPROJ/.claude/gitea-arm/pending"
jq -n --arg nonce "ARM-testunreach" --arg mission "測試" --arg issue "34" \
--argjson requested_at "$NOW" --argjson expires_at "$((NOW+1800))" \
--arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \
'{nonce:$nonce, mission:$mission, issue:$issue, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \
> "$TMPPROJ/.claude/gitea-arm/pending/ARM-testunreach.json"
# $TMPPROJ/.env 裡的 token 是假的(見檔頭),這條打的是**真的** git.uncle6.me——
# 拿假 token 打真端點,預期真的收到 401,藉此驗「非 200 → fail-closed」那條分支。
CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" ARM-testunreach >/tmp/gitea-arm-test-out.$$ 2>&1
rc=$?
t block "Gitea 認證失敗要 fail-closed(不放行)" $rc
grep -q "非 200\|打不到" /tmp/gitea-arm-test-out.$$ \
&& { echo " ✅ 錯誤訊息確實指向 fail-closed 分支(不是別的原因擋下)"; PASS=$((PASS+1)); } \
|| { echo " ❌ 沒看到預期的 fail-closed 訊息:"; cat /tmp/gitea-arm-test-out.$$; FAIL=$((FAIL+1)); }
echo "── 🔴 票號缺失/非數字的待核檔——不猜票號,略過不當機 ──"
rm -rf "$TMPPROJ/.claude/gitea-arm"
mkdir -p "$TMPPROJ/.claude/gitea-arm/pending"
jq -n --arg nonce "ARM-testnoissue" --arg mission "測試" \
--argjson requested_at "$NOW" --argjson expires_at "$((NOW+1800))" \
--arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \
'{nonce:$nonce, mission:$mission, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \
> "$TMPPROJ/.claude/gitea-arm/pending/ARM-testnoissue.json"
CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" ARM-testnoissue >/tmp/gitea-arm-test-out.$$ 2>&1
rc=$?
t block "缺 issue 欄位的舊格式待核檔該擋(不放行)" $rc
grep -q "issue 缺失或非數字" /tmp/gitea-arm-test-out.$$ \
&& { echo " ✅ 錯誤訊息點名是 issue 欄位的問題(不是猜成別的原因)"; PASS=$((PASS+1)); } \
|| { echo " ❌ 沒看到預期的訊息:"; cat /tmp/gitea-arm-test-out.$$; FAIL=$((FAIL+1)); }
echo "── 🔴 核准者不接受環境變數覆蓋(本次審查抓到的洞,補的測試)──"
GOT_LOGIN=$(CLAUDE_PROJECT_DIR="$TMPPROJ" GITEA_ARM_APPROVER_LOGIN="claude-code" bash -c '
. "'"$PROJ"'/scripts/lib/gitea-arm-common.sh"
printf "%s" "$GITEA_ARM_APPROVER_LOGIN"
')
if [ "$GOT_LOGIN" = "Leo" ]; then
echo " ✅ 設了 GITEA_ARM_APPROVER_LOGIN=claude-codesource 進去的常數仍是 Leo(覆蓋無效)"
PASS=$((PASS+1))
else
echo " ❌ 常數被環境變數改成了「$GOT_LOGIN」——核准者可以被外部覆蓋,這是安全洞"
FAIL=$((FAIL+1))
fi
echo "── 🔴 不同票號各自路由(真 token 打真端點):一個指到存在的票、一個指到不存在的票 ──"
# 這條要證明的是 2026-08-16 這次改動的核心:每個請求真的用它自己的 issue 欄位
# 去打對應的票,不是仍然只認某個寫死的號碼。用真 token(唯讀 GET,不會寫入任何東西):
# · nonce A 指到 #34(真實存在,開放中)→ 預期 200 OK,只是假 nonce 找不到 Leo 回覆
# · nonce B 指到一個不存在的超大票號 → 預期 404/非 200 → fail-closed
# 如果程式碼還在用舊的單一票號邏輯,這兩筆會得到**相同**的結果(不會一個過一個不過)。
REAL_ENV="$PROJ/.env"
if [ -f "$REAL_ENV" ] && grep -q '^GITEA_TOKEN_CLAUDE_CODE=' "$REAL_ENV"; then
cp "$REAL_ENV" "$TMPPROJ/.env"
rm -rf "$TMPPROJ/.claude/gitea-arm"
mkdir -p "$TMPPROJ/.claude/gitea-arm/pending"
jq -n --arg nonce "ARM-testrouteA" --arg mission "路由測試A" --arg issue "34" \
--argjson requested_at "$NOW" --argjson expires_at "$((NOW+1800))" \
--arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \
'{nonce:$nonce, mission:$mission, issue:$issue, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \
> "$TMPPROJ/.claude/gitea-arm/pending/ARM-testrouteA.json"
jq -n --arg nonce "ARM-testrouteB" --arg mission "路由測試B" --arg issue "999999999" \
--argjson requested_at "$NOW" --argjson expires_at "$((NOW+1800))" \
--arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \
'{nonce:$nonce, mission:$mission, issue:$issue, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \
> "$TMPPROJ/.claude/gitea-arm/pending/ARM-testrouteB.json"
CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" >/tmp/gitea-arm-test-out.$$ 2>&1
rc=$?
t block "兩者都是假 nonce,不會被核准,整體仍該回不放行" $rc
if grep -q "ARM-testrouteA#34)還沒等到 Leo 的回覆" /tmp/gitea-arm-test-out.$$; then
echo " ✅ #34(存在):真的打到了、200 OK,只是假 nonce 沒有匹配的留言"
PASS=$((PASS+1))
else
echo " ❌ 沒看到 #34 該有的「還沒等到」訊息:"; cat /tmp/gitea-arm-test-out.$$
FAIL=$((FAIL+1))
fi
if grep -q "#999999999Gitea 打不到/回應非 200" /tmp/gitea-arm-test-out.$$; then
echo " ✅ #999999999(不存在):真的打到了那個號碼,並且 fail-closed(不是誤放行也不是誤判成別的錯)"
PASS=$((PASS+1))
else
echo " ❌ 沒看到 #999999999 該有的 fail-closed 訊息:"; cat /tmp/gitea-arm-test-out.$$
FAIL=$((FAIL+1))
fi
else
echo " ⚠️ 跳過(找不到真的 GITEA_TOKEN_CLAUDE_CODE,這條測資需要唯讀真端點)"
fi
echo "── 🔴 gitea-arm-request.sh:缺票號/票號非數字要在打網路前就擋 ──"
REQ="$PROJ/scripts/gitea-arm-request.sh"
CLAUDE_PROJECT_DIR="$TMPPROJ" "$REQ" >/tmp/gitea-arm-test-out.$$ 2>&1
t block "完全沒帶參數該擋(用法錯誤)" $?
CLAUDE_PROJECT_DIR="$TMPPROJ" "$REQ" "not-a-number" "測試任務" >/tmp/gitea-arm-test-out.$$ 2>&1
rc=$?
t block "票號非數字該擋" $rc
# 2026-08-16:票號格式擴充為「N 或 repo#N」,訊息跟著改;斷言改成看**意圖**(有沒有點名票號)
grep -qE "票號.*純數字|純數字票號" /tmp/gitea-arm-test-out.$$ \
&& { echo " ✅ 錯誤訊息點名是票號格式問題"; PASS=$((PASS+1)); } \
|| { echo " ❌ 沒看到預期訊息:"; cat /tmp/gitea-arm-test-out.$$; FAIL=$((FAIL+1)); }
# ── 🔴 2026-08-16 新增:repo 可指定,但 owner 絕不可 ──────────────────
echo "── 🔴 gitea-arm-commonrepo 可由參數指定,owner 是安全邊界不給指定 ──"
( . "$PROJ/scripts/lib/gitea-arm-common.sh" >/dev/null 2>&1
gitea_arm_set_repo "arcrun-rag" >/dev/null 2>&1 && [ "$GITEA_ARM_REPO" = "arcrun-rag" ] ) \
&& { echo " ✅ 同 org 的 repo 名可指定"; PASS=$((PASS+1)); } \
|| { echo " ❌ 同 org 的 repo 名竟然不能指定"; FAIL=$((FAIL+1)); }
( . "$PROJ/scripts/lib/gitea-arm-common.sh" >/dev/null 2>&1
gitea_arm_set_repo "claude-code/evil" >/dev/null 2>&1 ) \
&& { echo " ❌ 帶斜線的 owner/repo 竟然被接受——那正是 08-13 那次攻擊的形狀"; FAIL=$((FAIL+1)); } \
|| { echo " ✅ 帶斜線的被擋(owner 不給任何人指定)"; PASS=$((PASS+1)); }
( GITEA_ARM_OWNER=claude-code; . "$PROJ/scripts/lib/gitea-arm-common.sh" >/dev/null 2>&1
[ "$GITEA_ARM_OWNER" = "inkstone" ] ) \
&& { echo " ✅ OWNER 不受環境變數影響(仍是 inkstone"; PASS=$((PASS+1)); } \
|| { echo " ❌ OWNER 被 env 蓋掉了——安全邊界破了"; FAIL=$((FAIL+1)); }
rm -f /tmp/gitea-arm-test-out.$$
echo
echo "結果:通過 $PASS 失敗 $FAIL"
[ $FAIL -eq 0 ] || exit 1