#!/bin/sh # 管什麼: subagent 推 gitea main 要先有總管戳記;wrangler 部署到非 stage 環境要 leo 手動解保險。 # 為什麼: leo 2026-08-10——「subagent 推 main 你確認,推 prod 我確認」;規則存在但沒機制驗證有沒有照做,於是閘長在機器上。 # 誤觸時怎麼關: 讀取/查狀態/演練一律放行;真要推 main,總管看過 commit 後 `git rev-parse --show-toplevel > /tmp/.main-push-ok`;真要打 stage,指令裡帶 staging/--env stage 字樣。 # main-and-prod-push-guard.sh — PreToolUse(Bash):**兩層手動確認閘** # # 🔴 立這道閘的來由(leo 2026-08-10): # 「**subagent 要推 gitea main 應該要你手動確認,要推 prod 要我手動確認。**」 # # 當天的事故(本閘的直接成因): # 總管派 subagent 去整理 `polaris/mira` 的散落分支,指令寫「驗過了 → 合併(這是預設)」, # 引的是 leo 08-05 那句「驗過就要 merge」。subagent 照做,**合併並直接 push 到 gitea main**。 # ⇒ 而頂層 CLAUDE.md 規則二的四題公式**白紙黑字列著「push 到 main」是人閘觸發項**。 # ⇒ **規則早就存在,但沒有任何機制驗證有沒有照做**——這是同款的第 N 次 # (history-first/KBDB-first/派工-first/stage-first 都是這個形狀)。 # ⇒ 閘漏的是總管:派工指令裡沒把那道閘標出來。**所以閘要長在機器上,不是長在我的記性上。** # # 兩條,各自對應一個人: # ① subagent(`CLAUDE_CODE_CHILD_SESSION=1`)**一律不准 push 到 main** → 交回總管 # ② **prod 部署**(會讓封測者/用戶拿到東西)需要 leo 親手解保險 `.github-armed` # ——補 `stage-before-prod-guard.sh` 的破口:那支只比對 # `arcrun-rag-bundles`/`github-arm`/`publish-github` 三個關鍵字, # **`wrangler deploy` 打 prod 它抓不到**(2026-08-10 實查)。 # # 設計紀律(沿用 stage-before-prod-guard 用血換來的兩條): # • **fail-closed**:任何內部錯誤都不准變成靜默放行。exit 1 不擋,只有 exit 2 才擋。 # • **先排除「談論/讀取」再比對關鍵字**:誤攔比漏攔更容易殺死一道閘 # (被擋得莫名其妙,人就會想辦法繞過它)。 set -eu INPUT="$(cat)" CMD=$(printf '%s' "$INPUT" | python3 -c ' import sys, json try: print(json.load(sys.stdin).get("tool_input", {}).get("command", "") or "") except Exception: print("") ' 2>/dev/null || printf '') [ -z "$CMD" ] && exit 0 # 🔴 2026-08-20(inkstone/InkStoneCo#23、#56):heredoc 的 body 是資料,不是指令。 # 直接對整條指令字串(含 heredoc 內文)做關鍵字掃描,會把「檔案內容/留言引用裡 # 剛好提到 git push」當成「真的在推」——同一天兩起實撞:寫 docs/TESTING.md 的 # 一行範例文字、貼一則引用 #56 敘述的 Gitea 留言,都被這樣擋下。 # 拿掉 heredoc body 再比對,判準不變,範圍變準。失敗就退回原字串(fail-open # 在「這支有沒有幫上忙」,不是「放行與否」——見 hooks/lib/strip_heredoc.py 檔頭)。 CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_heredoc.py" 2>/dev/null || printf '%s' "$CMD") # ── 先放行明確不發佈的動作(讀取、查狀態、寫本地版控、演練)────────────── # 🔴 2026-08-20 修正(release-tag-guard.sh/stage-before-prod-guard.sh 已修過同款洞): # 舊版「CMD 開頭是讀取工具就整條放行」本身是穿牆路—— # echo x && git push gitea HEAD:main # 開頭是 echo,照 glob 前綴比對會整條 exit 0,後面真的在推 main 完全不看。 # ⇒ 改成「先看有沒有出現危險關鍵字,出現了就不吃開頭豁免」,交給下面的 # 「指令位置」判準去判斷它是不是真的在執行。 case "$CMD" in *"git push"*|*"wrangler deploy"*|*"wrangler publish"*|*"wrangler versions deploy"*) ;; sed\ *|cat\ *|grep\ *|head\ *|tail\ *|wc\ *|less\ *|ls\ *|awk\ *|rg\ *|echo\ *) exit 0 ;; *"git commit"*|*"git add"*|*"git tag"*|*"git stash"*) exit 0 ;; *"git status"*|*"git log"*|*"git diff"*|*"git show"*|*"git branch"*) exit 0 ;; *" --dry-run"*|*"--dry-run "*) exit 0 ;; esac # ── ① push 到 main 要有「總管決定了」的戳記 ──────────────────────────── # # 🔴 **原本的判斷方式是錯的,上線當天就被自己擋到才發現(2026-08-10)**: # 本來用 `CLAUDE_CODE_CHILD_SESSION=1` 當「這是 subagent」的標記—— # **實測總管主 session 也是 1**(Claude Desktop,`CLAUDE_CODE_ENTRYPOINT=claude-desktop`)。 # ⇒ 那個變數不是身分標記 ⇒ 這道閘**把該放行的人也鎖住了**,總管推不了 main。 # ⚠️ `guard-cross-project.sh` 用同一個變數,**可能也一直在誤判**(另記,待查)。 # # leo 2026-08-10 把權責講清楚了: # 「**subagent 的總管是你,是否可以推 gitea main 由你來決定,我管推到 prod。**」 # ⇒ 這道閘要守的不是「誰在敲鍵盤」,是「**有沒有人做過那個決定**」。 # ⇒ 沒有可靠的身分辨識時,改成**正向確認**:推 main 前要有一枚新鮮的戳記。 # subagent 當然造得出那枚戳記——但它得刻意繞過一段明講「不要這樣做」的訊息。 # **閘擋的是無心,不是惡意。** 而現在這個版本連無心都擋不了(它誰都擋)。 STAMP="/tmp/.main-push-ok" # 🔴 2026-08-11 這道閘被自己的戳記穿透了,修法寫在這裡: # 總管為了推「頂層 InkStoneCo」而 touch 了戳記,15 分鐘之內 # **一條並行的 subagent 把 commit 推上了 `matrix/arcrun` 的 main**,本閘毫無反應。 # ⇒ 舊版有兩個洞,缺一不可: # ① **不綁 repo**:替 A repo 開的門,B repo 也走得過 # ② **不會用掉**:一次 touch 在 15 分鐘內可以放行無限多次推送 # ⇒ 現在改成 **綁 repo + 單次用完即丟**,而且**只在真的要擋的那一刻才檢查** # (放在檔頭會被任何一條無關的 Bash 指令把戳記燒掉)。 stamp_ok() { [ -f "$STAMP" ] || return 1 NOW=$(date +%s 2>/dev/null || echo 0) MT=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0) case "$NOW$MT" in *[!0-9]*) return 1 ;; esac [ "$NOW" -gt 0 ] && [ "$MT" -gt 0 ] || return 1 [ $((NOW - MT)) -lt 900 ] || return 1 # 綁 repo:戳記內容要對得上「現在人在哪個 repo」 # # 🔴 2026-08-12 補洞:舊版寫成「內容非空才比對」⇒ **`touch` 造出來的空檔跳過整個綁定**, # 等於一把萬用鑰匙——正是 08-11 那次穿透的形狀(替 A repo 開的門 B repo 也走得過)。 # 而且 `.claude/settings.local.json` 裡真的放行過 `touch /tmp/.main-push-ok`。 # ⇒ 現在**空內容一律不算數**:要嘛寫得出 repo 路徑且對得上,要嘛不放行。 # # 🔴 2026-08-23(inkstone/ISEP#30 comment 3949,脈絡 InkStoneCo#57): # `HERE` 原本一律讀 hook 自己的 cwd(=session 站著的那個 repo)。 # 只要要推的 repo **不是**「session 站著的那個 repo」——例如指令自己 # `cd <別的 repo> && git push` 或 `git -C <別的 repo> push`——HERE 永遠是 # 總管的真身,而總管替目標 repo 開的 WANT 永遠對不上,這道閘就**永遠沒辦法 # 合法通過**。不是判斷錯,是這個情境在舊模型裡根本不存在(照閘的指示做 # 戳記,戳記內容天生就贏不了)。 # 改法:先看指令本身有沒有把 push 的執行目錄改掉 # (lib/push_target_dir.py——純 tokenize,不執行任何指令, # 對 `cd A && cd B && git push` 這種多層鏈與 `(cd A && …); git push` 這種 # 子殼會不會外洩都做了範圍化,理由見該檔檔頭);解得出來就 `cd` 進那個 # 目錄(唯讀操作,`cd` 本身不會執行任何東西)問 git 那裡的 toplevel 是誰; # 解不出來(沒有 cd/-C,或指令太怪解析失敗)才退回舊行為=hook 自己的 cwd。 # 🔴 綁 repo+單次用完即丟兩條性質完全沒有鬆動:這裡只是把「現在人在哪個 # repo」問得更準,比對邏輯(下面兩行)一個字沒動。 _push_target_dir="$(dirname "$0")/lib/push_target_dir.py" _target_expr="" if [ -f "$_push_target_dir" ]; then _target_expr=$(printf '%s' "$CMD" | python3 "$_push_target_dir" 2>/dev/null || printf '') fi if [ -n "$_target_expr" ]; then HERE=$(cd "$_target_expr" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || printf '') else HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '') fi WANT=$(head -1 "$STAMP" 2>/dev/null || printf '') [ -n "$WANT" ] || return 1 [ -n "$HERE" ] || return 1 [ "$WANT" = "$HERE" ] || return 1 rm -f "$STAMP" 2>/dev/null || true # 單次:用完即丟 return 0 } # reworked 2026-08-20 (inkstone/InkStoneCo#23, #56): 'does this command contain # git push' moved from a whole-string substring test to a command-position test -- # same pattern already validated on release-tag-guard.sh: the phrase only counts # at the start of the string, or right after ; & | ( ` && ||. A heredoc body was # already stripped above; this closes the remaining gap where the phrase shows up # quoted inside the *same* command line (e.g. a python -c call whose string argument # discusses git push as prose). if printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git([[:space:]]+[^;&|]*)?[[:space:]]+push([[:space:]]|$)'; then # only block pushes that target main/master; pushing your own feature branch is fine # # 2026-08-12: dropped the `push -u` / `push --set-upstream` conditions -- those were # meant to catch "push with no branch named", but what they actually caught was # `git push -u gitea fix/xxx`, the normal first-push-of-a-branch shape for a # subagent. The target branch is what should be gated, not whether a flag is present. # 2026-08-20: match the target branch on a word boundary, not a bare substring -- # a glob like *main* also matches "domain" (d-o-**m-a-i-n**), e.g. a push to # `fix/custom-domain-setup` would have false-positived. # 2026-08-21: 只看 **push 的目標**,不再掃整條指令。 # 舊版掃整條 ⇒ 一個晚上誤攔四次,全都是推 feature branch 或 tag: # git checkout -b fix/x main && git push origin fix/x ← 「main」在 checkout 上 # gh pr create --base main ← 根本不是 git push # git checkout origin/main --detach; git push origin refs/tags/v0.3.3 # ⇒ **紅線寫得越細,命中關鍵字的機率越高**(leo 2026-08-17 的觀察, # 文字層封路必敗)。這裡改成判動作的目標,不是判字面。 # 2026-08-23(順著 inkstone/ISEP#30 comment 3949 補測時自己抓到的洞,不在原票範圍 # 但屬於同一支閘、同一段邏輯,且直接讓下面「反向不准鬆」的驗證跑不過,所以一併修): # `(git push origin HEAD:main)`——單純用括號包住整條指令——舊版會整段放行, # 跟 HERE/戳記完全無關,**連目的地判斷本身都沒觸發**。 # 成因:截斷 refspec 尾巴只切 `;`/`&`/`|` 三種字元,沒算到 `)`—— # 於是「HEAD:main)」被當成一個 token,`${_tok##*:}` 剝完冒號還剩「main)」, # 跟 `^main$` 對不上 ⇒ 判定成「看不出目標」⇒ 整段放行。加 `)` 進截斷字元。 # git 的 refspec/分支名語法本來就不允許出現 `)`,所以在這裡截斷永遠安全, # 不會誤傷任何合法的推送目標。 _push_seg=$(printf '%s' "$CMD" | sed -E 's/.*git[[:space:]]+(-[^[:space:]]+[[:space:]]+)*push//' | sed -E 's/[;&|)].*//') _dest="" _seen_remote=0 _saw_refspec=0 for _tok in $_push_seg; do case "$_tok" in -*) continue ;; # 旗標 refs/tags/*|*:refs/tags/*) _saw_refspec=1; continue ;; # 推 tag 不是推分支 esac if [ "$_seen_remote" = "0" ]; then _seen_remote=1; continue; fi # 第一個非旗標=remote _saw_refspec=1 _dest="$_dest ${_tok##*:}" # a:b 的目標是 b;沒有冒號就是它自己 done # 🔴 只有「一個 refspec 都沒給」才退回猜當前分支。 # 看到 refspec(哪怕是 tag)就照它判——否則推 tag 會被當成推當前分支, # 而當前分支若剛好叫 main 就誤擋(2026-08-21 實測抓到)。 if [ "${_saw_refspec:-0}" = "0" ]; then _dest=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "") fi if printf '%s' "$_dest" | tr ' ' '\n' | grep -qE '^(main|master)$'; then stamp_ok && exit 0 # ── 擋下的同時,把「誰想推什麼」留成一份請求(leo 2026-08-12)─────────── # # leo 原話:「**它會問你的意見,所以每個你叫起來的 subagent 都有名字。**」 # # 做得到的與做不到的,先講清楚: # ❌ **做不到「同步問總管」**——hook 跑在子 session 自己的行程裡,總管在另一個行程。 # 要同步問只能 block 等一個檔案出現,那會把 subagent 掛死在那裡。 # ✅ **做得到「當場擋 + 留下原始請求」**:總管在自己的迴圈裡讀這個目錄, # 看到的是 repo/分支/逐筆 commit 的**原始資料**,不是 subagent 的散文轉述。 # ——這才是名字真正值錢的地方:**不是判斷你是誰,是留下是誰要求的**。 # # 🔴 身分的方向刻意不改:**沒有名字不等於總管**(那是 fail-open—— # 子 session 繼承環境變數,把名字拿掉就升格了)。放行的唯一憑證仍然只有 # 那枚綁 repo、用完即丟的戳記。名字只是署名,不是權限。 # 📌 名字只在 `claude -p` 起的子 session 上可靠(乾淨的環境邊界); # Agent tool 起的 subagent 與總管同一個行程、共用環境 ⇒ 那條路上名字塞不進也擦不掉。 # 而改子 repo code 的正路本來就是 `claude -p`,所以夠用。 _who="${CLAUDE_AGENT_NAME:-未署名}" _hookdir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || _hookdir="" _reqdir="${_hookdir%/hooks}/pending-main-push" if [ -n "$_hookdir" ] && mkdir -p "$_reqdir" 2>/dev/null; then _root=$(git rev-parse --show-toplevel 2>/dev/null || printf 'unknown') # 檔名只用 ASCII(`未署名` 之類會被 tr 打成一排 dash,看不出是誰) _slugwho=$(printf '%s' "${CLAUDE_AGENT_NAME:-unnamed}" | tr -c 'A-Za-z0-9._-' '-') case "$_slugwho" in *[!-]*) : ;; *) _slugwho=unnamed ;; esac # ⚠️ 先 printf 再 tr:`basename` 會帶一個換行,直接餵 tr 會變成結尾多一根 dash _slugrepo=$(printf '%s' "$(basename "$_root")" | tr -c 'A-Za-z0-9._-' '-') _slug="${_slugwho}--${_slugrepo}" # ⚠️ 這幾行刻意用 `printf '%s\n' "整句"`,不要把內容寫進 printf 的格式字串裡。 # 2026-08-12 實撞:格式字串裡同時有反引號與 %s 時,那幾行整行不見(而前後行都在), # ——**寫完當場肉眼檢查產出的檔案才發現**,hook 自己不會叫。內容一律當資料傳。 _branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?') _when=$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null || printf '?') _fence='```' { printf '%s\n\n' "# 推 main 的請求:$_who" printf '%s\n' "- repo:$_root" printf '%s\n' "- 分支:$_branch" printf '%s\n\n' "- 時間:$_when" printf '%s\n' "- 它想跑的指令:" printf '%s\n%s\n%s\n\n' "$_fence" "$CMD" "$_fence" printf '%s\n\n%s\n' "## 還沒推上去的 commit(原始資料,不是轉述)" "$_fence" git log --oneline '@{upstream}..HEAD' 2>/dev/null \ || git log --oneline -20 2>/dev/null \ || printf '(列不出來)\n' printf '%s\n\n%s\n\n%s\n' "$_fence" "## 改了哪些檔" "$_fence" git diff --stat '@{upstream}..HEAD' 2>/dev/null | tail -40 || printf '(列不出來)\n' printf '%s\n\n---\n%s\n' "$_fence" "總管裁完請刪掉這個檔——留著代表「還沒裁」。" } > "$_reqdir/$_slug.md" 2>/dev/null || true fi cat >&2 <<'MSG' 🚫 推 main 要先有「總管決定了」的戳記(leo 2026-08-10 立) leo 原話: 「**subagent 要推 gitea main 應該要你手動確認,要推 prod 要我手動確認。**」 「**subagent 的總管是你,是否可以推 gitea main 由你來決定,我管推到 prod。**」 ━━━ 你是 subagent ━━━ **不要造那枚戳記。** 把改動留在自己的分支上,推那條分支,交件裡寫清楚: · 分支名 · 這幾筆各是什麼、**各自**驗過了沒有(逐筆,不要整包說「驗過了」) · 你建議合併還是先擱著,理由是什麼 總管看過才會併進 main。**這是一句話的事,不要自己想辦法過閘。** 📮 **你的請求已經自動留下來了**(`.claude/pending-main-push/` 底下,用你的 `CLAUDE_AGENT_NAME` 署名;沒有這個變數就署「未署名」)。裡面是 repo/分支/ 逐筆 commit 的原始資料 ⇒ **你不必在交件裡重抄一遍那些,總管會直接讀。** ━━━ 你是總管 ━━━ 逐筆看過那些 commit(`git log --oneline gitea/main..`、`git diff --stat`), 確定它們該進 main,再: git rev-parse --show-toplevel > /tmp/.main-push-ok && <你的 git push 指令> 戳記 **綁這個 repo、只能用一次、15 分鐘失效**——它代表「**這一次、這個 repo,我看過了**」。 🔴 **為什麼從 `touch` 改成寫入 repo 路徑(2026-08-11 被穿透過一次)**: 總管為了推頂層而 `touch` 了戳記,**15 分鐘內一條並行的 subagent 把 commit 推上了另一個 repo 的 main**, 本閘毫無反應。舊版既不綁 repo、也不會用掉 ⇒ 一次確認等於全域開門。 **在多條 subagent 並行的情況下,「時間窗」本身就是漏洞。** 【為什麼不是形式主義】2026-08-10 真的發生過: 總管派工寫「驗過了 → 合併(這是預設)」,subagent 照做並直接推上 gitea main。 而頂層 CLAUDE.md 規則二的四題公式**白紙黑字列著「push 到 main」是人閘觸發項**。 ⇒ 內容是好的,但**該不該先問,和內容對不對,是兩件事**。 ⇒ 而且那次是**總管的指令漏了那道閘**——所以現在由機器守,不靠誰記得。 【真的該推 main 的例外】不存在。交回總管,一句話的事。 MSG exit 2 fi fi # ── ② prod 部署要 leo 親手解保險 ──────────────────────────────────────── # 先排除 stage/staging 的同名動作(它們本來就該自由跑) # # 🔴 2026-08-13 leo 拍板:「**你可以標示整套測試環境,告訴閘『這些是 stage』, # 做這些事時白名單跳過**」。 # # 為什麼要補(同款第三次):`prod-write-guard.sh:188-191` 在 2026-08-12 就補過 # 一模一樣的例外,理由白紙黑字寫在那裡—— # 「`youlin`(youlin-hsieh-dev)**就是測試場**,不是 prod⋯⋯ # 本閘原本只認 `staging` 這個字 ⇒ 打 youlin 做實驗被誤擋」 # **但同一批補丁漏了這一支。** ⇒ 修法只改其中一份、沒有機制保證另一份跟上 # (同日另兩例:`kbdb-api-wall-guard.sh` 本機版與 plugin 版不同步,擋了九次)。 # # 🔴 判準不變,只是把「測試環境」講完整:**放行的是目標,不是動作。** # 命令看不出打哪裡 ⇒ 不放行。想被放行就把目標寫進命令裡, # **不要用假的 `# staging` 註解騙閘**(2026-08-13 一條 subagent 明確拒絕這樣做,是對的)。 # # 整套測試環境=下列任一出現在命令裡: # · `youlin-hsieh-dev` ── 帳號名(D37 定的 stage 帳號) # · `1129efd7df2e8899d537e9c8fbabb6cb` ── 該帳號 ID(用 env 指過去時只有它看得見) # · `_YOULIN_` ── 憑證變數名(如 `CLOUDFLARE_API_TOKEN_YOULIN_CC_USE`) # · `X-Arcrun-API-Key: youlin` ── 打該實例 API 時的身分標頭 case "$CMD" in *staging*|*-staging*|*"--env stage"*|*"--env=stage"*|*stage.*) exit 0 ;; *youlin-hsieh-dev*|*1129efd7df2e8899d537e9c8fbabb6cb*|*_YOULIN_*|*"X-Arcrun-API-Key: youlin"*) exit 0 ;; esac # 🔴 2026-08-12 leo 拍板「加閘」:**geek6688(出貨機)是明文授權「總管可以直接動」的那一台。** # # 依據——`system-dev/wiki/credentials-map.md` 早就寫死: # `CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE`|geek6688 帳號 CF token(**leo 2026-08-12 開**) # ——**總管唯一可以直接動的實例**|用途:**把那台拉到最新/推出貨工作流/ # `ARCRUN_SHIP_BASE` 指過去後的第 17~19 站** # ⇒ 規則早就存在,只是這道閘不知道。本段就是把它教給機器。 # # 為什麼該放行:geek6688 的職務就是「**不受出貨控制**」——出貨機與被出貨的是同一套程式, # 要出的貨若本身是引擎更新會形成死結,所以要有一台先更新的機器。 # 每出一次貨都要 leo 親手 arm 它一次 = 把他變成出貨流程裡的按鈕, # **而那正是這台實例存在的意義要拔掉的東西**。 # # 🔴 **範圍刻意開到最窄:只認「命令裡明確指名 geek6688 這台」。** # 放行的是**目標**,不是動作——打 leo21c/uncle6/任何其他實例一律照舊要 arm。 # 命令看不出打哪裡 ⇒ 不放行(與上方 stage 同一個原則:那正是該讓它看得出來的理由)。 case "$CMD" in *geek6688*|*CC_SHIPPING_CORE*|*ACCOUNT_ID_GEEK6688*) exit 0 ;; esac case "$CMD" in *"wrangler deploy"*|*"wrangler publish"*|*"wrangler versions deploy"*) ;; *) exit 0 ;; esac PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}" ARMED="$PROJ/.github-armed" # ── 🔐 leo 解保險的第二條路:Gitea 票上回一句話(2026-08-13,示範接這一道閘)── # # leo 2026-08-13:「如果你會被通知,就不需要通過 terminal 來 arm 了」。 # 身分分離做完後(`Leo/InkStoneCo#32`),票上作者是 `Leo` 的留言機器偽造不出來, # 可以當人閘證據——於是 arm 可以搬到手機。 # 🪦 2026-08-16 起不再是單一頻道票(原 issues/34)——票號由發請求的當下指定, # 解哪個任務的保險就貼在那個任務自己的票上(issues/34#issuecomment-2804)。 # # 這條路完全獨立於上面的 `$ARMED`(`.github-armed`,終端機那條路照舊在,兩者並存)。 # 只有本機**已經有人跑過 `scripts/gitea-arm-request.sh`**(本地留下待核請求)時才會 # 去打一次 Gitea——不是每次 wrangler deploy 都主動打;沒有待核請求就跳過, # 不浪費一次 API 呼叫,也不構成輪詢(只在「機器需要解閘的當下」查一次)。 if [ ! -f "$ARMED" ] && [ -d "$PROJ/.claude/gitea-arm/pending" ] \ && [ -n "$(ls -A "$PROJ/.claude/gitea-arm/pending" 2>/dev/null)" ]; then # 🔴 `--consume`:這一行放行的就是 prod 部署本身(下面直接 `exit 0`) # ⇒ 核准必須當場用掉。2026-08-16 起 check 預設唯讀,不帶這個旗標=同一組碼 # 可以無限次放行 prod,比原本的「探測會誤消耗」嚴重得多。 if "$PROJ/scripts/gitea-arm-check.sh" --consume >/tmp/.gitea-arm-check-last.log 2>&1; then exit 0 # leo 已在 Gitea 回覆過有效代碼,且核對通過(單次用完,見該腳本) fi fi if [ ! -f "$ARMED" ]; then cat >&2 <<'MSG' 🚫 prod 部署要 leo 親手確認(leo 2026-08-10:「要推 prod 要我手動確認」) 偵測到 `wrangler deploy`,而且命令裡沒有任何 stage/staging 字樣 ⇒ 視同**打 prod**。 **推 prod 就發佈了**——封測者/用戶當場拿到。 【怎麼過這道閘】把指令交給 leo 自己跑,並且照 `/issue-handle` 的規矩帶三樣: · 他要打開什麼(確切網址/確切指令,**你要先 `--dry-run` 打過**) · 他該看到什麼(正確的樣子長怎樣,例如版本字串會變成什麼) · 什麼情況算失敗(看到什麼就回你一聲) 【這道閘補的破口】`stage-before-prod-guard.sh` 只比對 `arcrun-rag-bundles`/`github-arm`/`publish-github` 三個關鍵字, **抓不到 `wrangler deploy`**(2026-08-10 實查)。它擋的是 bundle 出貨鏈,不是所有發佈。 【真的是 stage?】命令裡明確帶 `--env stage`/`staging` 就會自動放行。 若你確實在打 stage 卻被擋,代表**命令本身看不出打哪裡**——那正是該讓它看得出來的理由。 【2026-08-13 新增:leo 現在也能用手機解,不必開電腦】 1. 跑 `scripts/gitea-arm-request.sh <這件任務對應的票號> "任務描述"` 生一組代碼, 貼在**這件任務自己那張票上**(不是固定某張頻道票——2026-08-16 起票號由你指定) 2. 把印出來的那句轉告 leo(Telegram/人話一句+一個代碼) 3. leo 在 Gitea 上(App 或網頁都行)回那則貼上代碼 4. 重跑一次你剛剛被擋的指令——這道閘會自動去核對,核對過就放行一次 MSG exit 2 fi exit 0