#!/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-16:ISSUE 從第五個「寫死常數」名單裡移出來,改成請求時的參數 # (見 lib/gitea-arm-common.sh 檔頭說明:真正的安全邊界是 OWNER/REPO/ # APPROVER_LOGIN,不是票號本身)。相對地,pending 檔的 schema 多了 `issue` 欄位, # 下面所有測資的 JSON 都要帶上它,否則會被「票號缺失」判定為壞檔而被略過。 # # 用法:.claude/hooks/tests/gitea-arm-check.test.sh # (repo 根目錄要有 scripts/gitea-arm-check.sh + scripts/gitea-arm-request.sh) set -u PROJ="${1:?用法: $0 }" CHECK="$PROJ/scripts/gitea-arm-check.sh" TMPPROJ=$(mktemp -d) trap 'rm -rf "$TMPPROJ"' EXIT # 借用真的 scripts/(唯讀),但狀態目錄與 .env 都指到隔離的臨時目錄 ln -s "$PROJ/scripts" "$TMPPROJ/scripts" # 假 .env:token 隨便填,「Gitea 打不到」那條測資會在網路那層失敗(本來就該擋), # 其餘測資在打到 Gitea 之前就已經因為本地狀態被擋下,不會真的送出請求。 printf 'GITEA_TOKEN_CLAUDE_CODE=test-token-not-real\n' > "$TMPPROJ/.env" PASS=0; FAIL=0 t(){ # t <期望 block|pass> <說明> 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 認證失敗(真端點+無效 token,fail-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-code,source 進去的常數仍是 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 "#999999999:Gitea 打不到/回應非 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-common:repo 可由參數指定,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