#!/bin/sh # gitea-arm-common.sh — 共用設定與函式,給 gitea-arm-request.sh / gitea-arm-check.sh 用。 # 只放常數與純函式,不做任何網路呼叫、不產生副作用(source 它必須零風險)。 # # 背景:把「leo 解保險」從終端機搬到 Gitea 票上(leo 2026-08-13: # 「如果你會被通知,就不需要通過 terminal 來 arm 了」)。原始設計見: # https://git.uncle6.me/inkstone/InkStoneCo/issues/34 # # 🪦 2026-08-16 廢除「單一頻道票」設計(leo:「我不要把所有的票都放在一個 issues, # 它們應該分散在每個 issues 裡,不然就難 track 歷史記錄」,issues/34#issuecomment-2804)。 # **票號改由呼叫端在每次呼叫時指定**——解哪張票的保險,請求與回碼就貼在那張票上, # 不再有寫死的 GITEA_ARM_ISSUE 常數。#34 本身保留 open(33 則舊留言是歷史,不刪), # 但不再是新請求的預設去處。 # # 🔴 這不是重新打開 2026-08-13 那次「五個常數全部寫死」的舊爭議(history-first-guard # 會問這個,先在這裡回答):那次總管的攻擊測試是 # `GITEA_ARM_ISSUE=999 OWNER=claude-code`——**同時**換成機器自己名下的 repo # (bot 在自己的 repo 有 admin 權限,可以邀請/建立一個顯示名叫「Leo」的假帳號來自問自答)。 # OWNER/REPO 才是那個攻擊真正利用的洞:**只要留在 inkstone/InkStoneCo 這個 org 裡, # bot 沒有 admin 權限造不出第二個 login 是 `Leo` 的帳號,票號本身換到哪一張都一樣安全** # ——核心判定永遠是「留言作者的 login 精確等於 Leo」,跟去哪張票找留言無關。 # 本次只鬆 ISSUE 一個常數,OWNER/REPO/APPROVER_LOGIN 三個維持寫死、 # 不接受環境變數覆蓋,且 ISSUE 也只能透過腳本參數指定,不接受環境變數。 # ── 安全邊界常數(OWNER/REPO/APPROVER_LOGIN,不接受任何覆蓋)─────────── # # 🔴🔴 2026-08-13 總管審查抓到的洞(修正前的版本用 `: "${VAR:=default}"`, # 可被環境變數覆蓋):**「誰算是 leo」絕對不能是可覆蓋的設定。** # 舊寫法下 `GITEA_ARM_APPROVER_LOGIN=claude-code scripts/gitea-arm-check.sh` # 就能讓機器核准自己——比冒充/重放/過期/無回覆/Gitea 不通這五種都好繞, # 因為設環境變數對總管與每一條 subagent 都是零成本、零痕跡。 # ⇒ 這幾個常數全部**直接賦值、不接受任何外部覆蓋**(沒有 `:=`、沒有讀 env)。 # 「誰是核准者/打哪個 repo」這種安全邊界要嘛寫死在程式碼裡走 PR 審查才能改, # 要嘛就是可以被繞過的裝飾——**難改在這裡是特性,不是缺點**。 # (測試因此不能再靠改這幾個常數注入假狀態;改用「用真的無效 token 打真端點」 # 去驗 fail-closed,見 gitea-arm-check.test.sh 與 gitea-arm-check.sh 檔頭說明。) GITEA_ARM_OWNER="inkstone" # 🔓 2026-08-16:REPO 從「寫死」改成「預設值,只能由**腳本參數**指定」——理由與 ISSUE 同源。 # 本檔上方那段自己寫著:那次攻擊真正利用的洞是「換成**機器自己名下**的 repo」 # (bot 在自己的 repo 有 admin,能造一個顯示名叫 Leo 的假帳號自問自答)。 # ⇒ 真正的安全邊界是 **OWNER**,不是 REPO:只要留在 `inkstone` 這個 org 底下, # bot 在任何一個 repo 都沒有 admin,造不出第二個 login 是 `Leo` 的帳號。 # 實際需求(同日):出貨票在 `inkstone/arcrun-rag#115`,而解保險的請求依 leo 的規矩 # 要貼在「它解的那張票」上 ⇒ 寫死 InkStoneCo 會讓請求貼不到出貨票(實測 HTTP 500)。 # 🔴 **OWNER 仍然寫死、仍然不接受任何覆蓋**——那一條沒有鬆。 GITEA_ARM_REPO="InkStoneCo" # 預設;由 gitea_arm_set_repo() 依腳本參數覆寫,不讀 env # gitea_arm_set_repo —— 只接受 inkstone org 底下的 repo 名(純 [A-Za-z0-9._-])。 # 刻意不接受 owner/repo 形式:owner 是安全邊界,不給任何人指定的機會。 gitea_arm_set_repo() { case "$1" in ''|*[!A-Za-z0-9._-]*) echo "❌ repo 名只能是 inkstone org 底下的 repo(收到:$1)" >&2 return 1;; esac GITEA_ARM_REPO="$1" } GITEA_ARM_API="https://git.uncle6.me/api/v1" # 只認這個帳號名——不是 id、不是顯示名(leo 交代:這兩者會變)。 # 將來 leo 真的改帳號名 ⇒ 改這一行、走 PR 審查,不是設環境變數就生效。 GITEA_ARM_APPROVER_LOGIN="Leo" # 票號合法性檢查:純數字才放行,避免呼叫端傳進奇怪字串打壞 URL # (例如帶 `/` 或空白,會讓 curl 打到非預期的路徑)。 # 用法:gitea_arm_valid_issue "$ISSUE" && ... 或 if ! gitea_arm_valid_issue "$X"; then ... gitea_arm_valid_issue() { case "$1" in ''|*[!0-9]*) return 1 ;; *) return 0 ;; esac } gitea_arm_proj_dir() { printf '%s' "${CLAUDE_PROJECT_DIR:-$(pwd)}" } # 讀 token:只回名字對應的值,不印出任何除了呼叫者要的東西。 # 讀不到 → 印空字串、回傳非 0(呼叫者要判斷,不能把空字串當成功)。 gitea_arm_token() { ENV_FILE="$(gitea_arm_proj_dir)/.env" if [ ! -f "$ENV_FILE" ]; then printf '' return 1 fi TOK=$(grep '^GITEA_TOKEN_CLAUDE_CODE=' "$ENV_FILE" | head -1 | cut -d= -f2-) if [ -z "$TOK" ]; then printf '' return 1 fi printf '%s' "$TOK" return 0 } # 本機狀態目錄(gitignore 見 .claude/.gitignore)。 gitea_arm_state_dir() { D="$(gitea_arm_proj_dir)/.claude/gitea-arm" mkdir -p "$D/pending" 2>/dev/null printf '%s' "$D" } gitea_arm_consumed_log() { printf '%s/consumed.log' "$(gitea_arm_state_dir)" }