#!/bin/sh # 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 # ── 先放行明確不發佈的動作(讀取、查狀態、寫本地版控、演練)────────────── # 關鍵字出現在 commit 訊息、在 sed/grep 的參數裡,都不是「執行」。 case "$CMD" in 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 路徑且對得上,要嘛不放行。 HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '') 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 } if true; then case "$CMD" in *"git push"*) # 只擋打到 main/master 的;推自己的 feature 分支照常放行 case "$CMD" in # 🔴 2026-08-12 拿掉 `push -u` / `push --set-upstream` 這兩個條件。 # 它們本來是想抓「沒寫分支的 push」,但實際抓到的是 # `git push -u gitea fix/xxx`——**subagent 發表自己分支的標準動作** # (第一次推當然要 -u)。⇒ 舊版等於「agent 永遠推不出自己的分支」, # 而 leo 2026-08-12 的設計是「主線禁止動,大家都走 PR」,推分支是那條路的第一步。 # 08-12 當天四張 PR 全是繞成 `git push gitea a:a` 才推出去的。 # `*main*`/`*master*` 兩條照舊——真正該擋的是目標分支,不是有沒有帶旗標。 *main*|*master*) 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 ;; esac ;; esac 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