Files
ISEP/hooks/main-and-prod-push-guard.sh
Leo 41c56acd32 推 main 的戳記改綁「push 真正的目標 repo」,不再綁 hook 自己的 cwd
跨 repo 交辦時(總管站在 A repo,要推 B repo 的 main)main-and-prod-push-guard
的戳記機制永遠對不上:HERE 讀的是 hook 自己的 cwd(=session 的真身,不會變),
WANT 是總管替目標 repo(B)寫進戳記的路徑——兩者結構性地不可能相等,不是
判斷錯,是這個情境在舊模型裡根本不存在(inkstone/ISEP#30 comment 3949,
脈絡 inkstone/InkStoneCo#57,2026-08-21 實撞)。

新增 hooks/lib/push_target_dir.py:純 tokenize(不執行任何指令)解析指令裡
`cd <path> && git push` 或 `git -C <path> push` 真正會落地的目錄,對多層 cd
鏈與子殼(`(cd A && ...); git push` 這種子殼 cd 不能外洩出去)都做了範圍化——
這條範圍化是防穿透的關鍵,不是順手:沒有它,`(cd A && true); git push`
會被誤判成推向 A,讓替 A 開的舊戳記錯誤地放行推到殼外真正的目標。解不出來
一律退回舊行為(hook 自己的 cwd),維持 fail-closed 方向不變。

順手修掉補測時自己抓到的另一個洞:`(git push origin HEAD:main)`——單純加一層
括號——舊版目的地判斷完全偵測不到,整段直接放行,跟戳記無關。成因是截斷
refspec 尾巴的 sed 只認 `;`/`&`/`|` 三種字元,沒算到 `)`;補上即可,git 的
refspec 語法本來就不允許出現 `)`,這裡截斷永遠安全。

綁 repo+單次用完即丟兩條 2026-08-11/12 用血換來的性質完全沒有鬆動:只是把
「現在人在哪個 repo」問得更準,比對邏輯一個字沒動。

實測:
- hooks/tests/main-and-prod-push-guard.test.sh 舊有 8 向:8/8
- scripts/test-main-and-prod-push-guard.sh 舊有 11 向:11/11
- 新增 hooks/tests/main-and-prod-push-guard-cross-repo.test.sh 17 向
  (跨 repo 正向/反向不准鬆/git -C/子殼範圍化/括號洞/單次用完即丟/
  900 秒逾時/空戳記/既有行為零回歸):17/17

本輪只驗證,未拿去放行任何真實推送;plugin.json 隨慣例 bump 0.3.4 -> 0.3.5
並重跑 vendor-to-shell.py(.shell-payload 為 gitignore 產物,不入版控)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:18:28 +08:00

392 lines
25 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 — PreToolUseBash):**兩層手動確認閘**
#
# 🔴 立這道閘的來由(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-firstKBDB-first/派工-firststage-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-20inkstone/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.shstage-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-23inkstone/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..<branch>`、`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` 指過去後的第 1719 站**
# ⇒ 規則早就存在,只是這道閘不知道。本段就是把它教給機器。
#
# 為什麼該放行: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`,而且命令裡沒有任何 stagestaging 字樣 ⇒ 視同**打 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