4d2036af72
leo 一天只看兩眼,要在一長串留言裡一眼認出「這則在等我動手」。 機器產生的 ARM 請求第一行是 `🔐 ARM 請求`(機器術語)沒有 👤, 而他手寫的交棒都以 `👤 Leo:` 開頭——規範在人身上、不在機器上。 - scripts/gitea-arm-request.sh:第一行改成 `👤 Leo:回這則、貼下面那組碼,就放行這一次 —— <任務>`(🔐/nonce 照舊, nonce 仍單獨成行,gitea-arm-check 的比對不受影響)。加離線印內文的 ISEP_ARM_PRINT_BODY=1 供測試。 - scripts/ticket handback --to Leo:交棒留言第一行改掛 `👤 Leo:<下一步>`, 🏃 那一塊照舊留著(baton-handback-guard 靠留言有沒有 🏃 判斷交棒了沒, 拔掉會漏擋);交回總管 claude-code 維持第一行 🏃、不掛 👤。 body 抽成純函式 handback_comment_lines 供離線驗。 - 掃過的其他 comment-poster 皆非「要 leo 動手」的閘、未改(decide/subtask/ handoff/triage/compose/claim/pr-verdict/milestone-account/gitea-arm-check/ isep-notify 退路留言,理由記在 docs/hooks-inventory.md #123 note)。 測試:gitea-arm-check.test.sh 16→19、test-ticket-handoff-writeback.sh 49→54; comment-carries-task-guard(29/29)、baton-handback-guard(24/24)無回歸。 版本待總管定。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TGxitYq49FzYC7EFkbhzF5
228 lines
14 KiB
Bash
Executable File
228 lines
14 KiB
Bash
Executable File
#!/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根目錄>
|
||
# (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"
|
||
# 假 .env:token 隨便填,「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 認證失敗(真端點+無效 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)); }
|
||
|
||
# ── 🔴 inkstone/ISEP#123:請求留言第一行就有人閘記號 👤 +「要 leo 做什麼」──
|
||
# leo 一天只看兩眼,他要在一長串留言裡一眼認出「這則在等我動手」。舊版第一行是
|
||
# `🔐 ARM 請求`(機器術語)沒有 👤 ⇒ 他認不出來。這一組離線印內文(ISEP_ARM_PRINT_BODY=1:
|
||
# 不打網路、不讀 token、不寫本地狀態)驗第一行,不必真的等 leo、也不留任何測試票。
|
||
echo "── 🔴 inkstone/ISEP#123:請求留言第一行有 👤 +「要 leo 做什麼」,nonce 仍單獨成行 ──"
|
||
BODY_OUT=$(CLAUDE_PROJECT_DIR="$TMPPROJ" ISEP_ARM_PRINT_BODY=1 "$REQ" 41 "出 prod:demo" 30 2>/dev/null)
|
||
FIRST=$(printf '%s\n' "$BODY_OUT" | head -1)
|
||
case "$FIRST" in
|
||
"👤"*) echo " ✅ 第一行以 👤 開頭(等 leo 動手一眼認得出):$FIRST"; PASS=$((PASS+1)) ;;
|
||
*) echo " ❌ 第一行沒有 👤(leo 認不出這則在等他):$FIRST"; FAIL=$((FAIL+1)) ;;
|
||
esac
|
||
if printf '%s' "$FIRST" | grep -qE "貼|放行|解鎖"; then
|
||
echo " ✅ 第一行講得出「要 leo 做什麼」(不是只有記號與機器術語)"; PASS=$((PASS+1))
|
||
else
|
||
echo " ❌ 第一行只有記號、沒說要做什麼:$FIRST"; FAIL=$((FAIL+1))
|
||
fi
|
||
# 🔴 nonce 仍要單獨成行:gitea-arm-check 靠「leo 回覆的留言含這組 nonce」比對,
|
||
# 把 nonce 併進句子裡不會壞比對,但單獨成行是 leo 複製貼上最不會出錯的形狀。
|
||
if printf '%s\n' "$BODY_OUT" | grep -qE '^ARM-[0-9a-f]+$'; then
|
||
echo " ✅ nonce 仍單獨成行(arm-check 比對照舊)"; PASS=$((PASS+1))
|
||
else
|
||
echo " ❌ nonce 不再單獨成行:"; printf '%s\n' "$BODY_OUT"; FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
# ── 🔴 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
|