c1d80756d7
總管複驗 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/),
這台機器會失去那支唯一還能用的副本——變成真的沒有那道閘。
204 lines
13 KiB
Bash
Executable File
204 lines
13 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)); }
|
||
|
||
# ── 🔴 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
|