#!/bin/sh # prod-write-guard.sh — PreToolUse(Bash + 會寫到線上實例的 MCP 工具) # # 🔴 立這道閘的來由(leo 2026-08-11,當天實際發生): # 兩條 subagent 在同一個 session 裡把東西推上了 leo21c 線上實例—— # ① `#79` 覆寫了現役出貨管線的 `ship_refresh_cdn` 工作流定義 # ② `#85` 推了一支新工作流上去 # **兩次都沒有被任何閘擋到**,儘管派工單第一條紅線就寫著「不可以部署到正式環境」。 # # leo 當場點破,而他的說法比「再補一種推送」更根本: # 「**其實問題是它跟正式環境是斷開的,所以 hook 要不讓它對正式環境做任何推送, # 不是某種推送,這種只有透過你。**」 # # ⇒ 判準不是「這是哪一種推送」,是「**這個動作會不會改到線上那台**」。 # 會 ⇒ 擋,交回總管。總管要動之前,上面還有 leo 的保險(D20/.github-armed)。 # # 為什麼既有的閘接不住(2026-08-11 實查,不是推測): # · `main-and-prod-push-guard.sh` 只認 `wrangler deploy|publish|versions deploy` # · `stage-before-prod-guard.sh` 只比對 `arcrun-rag-bundles`/`github-arm`/`publish-github` # · **兩支都只掛在 `Bash` 上**,而 `arcrun_push_workflow` 是 **MCP 工具,根本不經過 Bash** # ⇒ 推工作流定義這條路,從頭到尾沒有任何一雙眼睛。 # # 設計紀律(沿用前兩支用血換來的): # • **fail-closed**:內部錯誤不准變成靜默放行。exit 1 不擋,只有 exit 2 才擋。 # • **先排除「談論/讀取/演練」再比對**:誤攔比漏攔更容易殺死一道閘 # ——被擋得莫名其妙,人就會想辦法繞過它。 # • **stage 一律自由**:擋的是 prod,不是所有部署。測試場本來就該隨便跑。 set -eu INPUT="$(cat)" TOOL=$(printf '%s' "$INPUT" | python3 -c ' import sys, json try: print(json.load(sys.stdin).get("tool_name", "") or "") except Exception: print("") ' 2>/dev/null || printf '') 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 '') # ── 總管戳記:**單次、15 分鐘失效** ──────────────────────────────────── # # 🔴 這一版一開始寫成「15 分鐘內無限次放行」,而同一天 `main-and-prod-push-guard.sh` # 就是因為這個寫法被穿透的:總管為了推頂層 touch 了戳記, # **15 分鐘內一條並行的 subagent 把 commit 推上了另一個 repo 的 main**。 # ⇒ **多條 subagent 並行時,「時間窗」本身就是漏洞。** 所以改成單次用完即丟。 # ⇒ 而且只在**真的要擋的那一刻**才檢查——放在檔頭會被任何一條無關指令把戳記燒掉。 STAMP="/tmp/.prod-write-ok" 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 rm -f "$STAMP" 2>/dev/null || true # 單次:用完即丟 return 0 } # ══ ① MCP 工具:會改到線上實例的那幾支 ═════════════════════════════════ # 這一段是本閘存在的理由——上面兩支既有的閘完全看不到這裡。 case "$TOOL" in *arcrun_push_workflow*|*arcrun_delete_workflow*|\ *arcrun_recipe_push*|*arcrun_recipe_delete*|\ *arcrun_create_tag*|*arcrun_delete_tag*|\ *arcrun_tag_resource*|*arcrun_untag_resource*|\ *kbdb_create_record*|*kbdb_create_template*) stamp_ok && exit 0 cat >&2 <<'MSG' 🚫 這個動作會改到「線上那台」——不是你能自己按的(leo 2026-08-11 立) leo 原話: 「**其實問題是它跟正式環境是斷開的,所以 hook 要不讓它對正式環境做任何推送, 不是某種推送,這種只有透過你。**」 ⇒ 判準不是「這是哪一種推送」,是「**這會不會改到線上那台**」。會,就交回總管。 ━━━ 你是 subagent ━━━ **不要想辦法過這道閘。** 把成品留在自己的分支/檔案裡,交件時寫清楚: · 你要推什麼上去(名稱+它會覆蓋掉什麼,還是全新的) · 為什麼現在該推、你驗過哪些(逐項,不要整包說「驗過了」) · 推上去之後**用什麼指令可以看出它生效了** 總管會看過再決定。**這是一句話的事。** 🔴 特別注意「**覆寫現役的東西**」:2026-08-11 真的發生過—— 一條 subagent 覆寫了現役出貨管線的工作流定義。那次內容是對的(舊版真的壞了), **但「內容對不對」和「該不該先問」是兩件事**(D54)。 ━━━ 你是總管 ━━━ 看清楚它會覆蓋什麼(`arcrun_get_workflow` 先撈一份現況存起來,好回滾),確定要推,再: touch /tmp/.prod-write-ok # ⚠️ 只放行「下一個」動作,用完即消失 戳記 15 分鐘有效。**若這一推會讓封測者/用戶當場拿到東西,那就不是你的層級——要 leo 解保險。** MSG exit 2 ;; esac # ── 非 Bash 的其他工具一律放行 ───────────────────────────────────────── [ "$TOOL" = "Bash" ] || exit 0 [ -z "$CMD" ] && exit 0 # ── 「執行它」vs「談論它」:動詞要在**指令位置**才算數 ───────────────── # # 🔴 2026-08-13 第六次同款誤攔(前五次記在頂層 `wiki/mistakes.md`): # 總管要把 `#108` 的驗收證據**留言到 Gitea**,指令長這樣—— # BODY=$(cat <<'EOF' … 正文裡引述了 `acr update --force` … EOF # ) # curl -X POST https://git.uncle6.me/api/v1/…/comments # ⇒ 命中舊的 `*"acr update"*` ⇒ **一則留言被當成部署擋掉。** # (curl 那半本來是對的:打 Gitea 不是打實例,下面的分支會 exit 0。 # 擋它的是那條純字串比對,而且它排在 curl 那支前面。) # # 本檔第 25 行自己寫著「誤攔比漏攔更容易殺死一道閘」,而這是同一形狀的第六次: # **判準看「字串有沒有出現」,不是「它在做什麼」。** # # 判準改成兩層:① 剝掉 heredoc 內文(那是資料,不是指令) # ② 動詞要在指令位置——行首/`;` `&` `|` `(` `$(` 之後/引號開頭 # 仍會擋:`bash -c "acr update"`、`a && acr push`、`npx wrangler deploy`。 # 不再擋:反引號或 markdown code span 裡的引述、commit 訊息、註解、heredoc 正文。 # # 🔴 這段**必須算在所有豁免之前**(2026-08-13 被自己補的測試抓到): # `cat <<'EOF' > note.md … EOF` 換行 `acr update --force` # ⇒ 指令以 `cat ` 開頭,而下面認投送工具的 `*" acr "*` 是**空格界定**、 # 認不出換行後的 `acr` ⇒ 掉進「開頭是唯讀工具」的豁免直接放行。 # 同款的還有 `git commit -m 'x' && acr update`(吃 `*"git commit"*` 豁免)。 # ⇒ 命中指令位置的,一概不吃任何豁免。 CLI_HIT=$(printf '%s' "$CMD" | python3 -c ' import sys, re cmd = sys.stdin.read() cmd = re.sub(r"<<-?[ \t]*([\x27\x22]?)([A-Za-z_][A-Za-z0-9_]*)\1.*?^[ \t]*\2[ \t]*$", " ", cmd, flags=re.S | re.M) LAUNCH = r"(?:npx\s+|pnpm\s+(?:exec\s+)?|yarn\s+|sudo\s+|env\s+\S+=\S+\s+)*" # 指令位置=行首/`;` `&` `|` `(` `$(` 之後/**shell 呼叫旗標後面的引號**。 # 🔴 引號本身不算(2026-08-13 被自己補的測試抓到):先前把 `\x22\x27` 直接放進 # 前綴字元類,結果 `grep \x27acr push\x27 <檔>` 被當成在執行 ⇒ 又是一次誤攔。 # 只有 `bash -c "…"`/`sh -lc \x27…\x27`/`eval "…"` 這種**真的會把字串當指令跑**的才算。 SHELLC = r"(?:bash|sh|zsh|dash|eval|xargs)\s+(?:-\w+\s+)*[\x22\x27]" PAT = (r"(?:^|[\n;&|(]|\$\(|" + SHELLC + r")\s*" + LAUNCH + r"(acr|wrangler)\b([^\n;&|)]*)") for m in re.finditer(PAT, cmd): tool, rest = m.group(1), m.group(2) if re.search(r"(^|\s)(--help|-h)(\s|$)", rest): continue # 問說明不是執行 if tool == "acr" and re.search(r"\b(push|update)\b", rest): print("cli"); break if tool == "wrangler" and re.search(r"\b(deploy|publish)\b", rest): print("cli"); break ' 2>/dev/null || printf '__PYFAIL__') # fail-closed:python 掛掉不准變成靜默放行 ⇒ 退回舊的整串比對,寧可誤攔 if [ "$CLI_HIT" = "__PYFAIL__" ]; then CLI_HIT="" case "$CMD" in *"acr push"*|*"acr update"*|*"wrangler deploy"*|*"wrangler publish"*) CLI_HIT="cli" ;; esac fi # ══ ② Bash:先排除明確不是「推上線」的動作 ═══════════════════════════ # 關鍵字出現在 commit 訊息裡、在 sed/grep 的參數裡,都不是「執行」。 # ⚠️ 下面每一條豁免都只在 CLI_HIT 為空時才輪得到(見上)。 # 🔴 2026-08-12 修漏攔:這個「開頭是唯讀工具就放行」的豁免**本身就是一條穿牆路**。 # 實撞:`cat payload.json | curl -X POST <實例>/webhooks/named` —— # 指令以 `cat ` 開頭 ⇒ 整條直接 exit 0 ⇒ **寫進線上實例,本閘一聲不吭**。 # 任何人只要在前面接一個管線就能繞過,而這正是本檔下方註解自己警告的 # 「換個工具就穿過去了」。⇒ 豁免只在「整條指令裡沒有任何投送工具」時才成立。 if [ -z "$CLI_HIT" ]; then case "$CMD" in *curl*|*wget*|*httpie*|*" http "*|*wrangler*|*" acr "*|acr\ *) ;; # 有投送工具 → 不吃開頭豁免 sed\ *|cat\ *|grep\ *|head\ *|tail\ *|wc\ *|less\ *|ls\ *|awk\ *|rg\ *|echo\ *|find\ *) exit 0 ;; esac case "$CMD" in *"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 ;; # 問說明不是執行(實測撞到:`acr update --help` 被自己擋掉) # ——`--help` 現在也在 CLI_HIT 那層逐段判,這裡留著給 curl/wrangler 之外的情況 *" --help"*|*" -h"*|*" help "*) exit 0 ;; esac fi # stage/staging 一律自由——擋的是 prod,不是所有部署 # # 🔴 2026-08-12 補:`youlin`(youlin-hsieh-dev)**就是測試場**,不是 prod。 # arcrun-rag CLAUDE.md 白紙黑字:「要看範例或做任何測試 → **只在 youlin(D37 stage)**」, # 而 leo 2026-07-25 令「測試一律用 youlin,別拿 leo21c 當探針(會製造假信號)」。 # 本閘原本只認 `staging` 這個字 ⇒ 打 youlin 做實驗被誤擋 # ⇒ **這種誤攔比漏攔更危險**:被擋得莫名其妙,人就會學會繞過它,那它就等於不存在。 case "$CMD" in *staging*|*-staging*|*"--env stage"*|*"--env=stage"*|*stage.*|*"-staging"*) exit 0 ;; *youlin-hsieh-dev*|*"X-Arcrun-API-Key: youlin"*) exit 0 ;; esac # 會把東西推上線的 CLI 動作 # `acr update`=「拉新 release 並重新部署到你的 Cloudflare」(它 --help 的原話) # ⇒ 它就是一次完整的實例部署,比 `acr push` 影響更大(一次重部所有 worker)。 CMD_M="${CLI_HIT}|$CMD" case "$CMD_M" in cli\|*) ;; # 直接對實例跑 wrangler 也算——`main-and-prod-push-guard.sh` 有擋,但它認的是 # 「有沒有 .github-armed」;這裡多一層是因為打錯帳號的風險在 wrangler 這一側: # 2026-08-11 實查 kbdb/wrangler.toml 的 D1 id 是**官方 prod 的**, # 而本機 wrangler 登入的是官方帳號 ⇒ 照直覺跑會把 self-hosted 實例接到官方資料庫上。 # (2026-08-13:`wrangler deploy|publish` 已併進上面的 CLI_HIT 判定—— # 原本這裡也是整串比對,「文章裡提到 wrangler deploy」同樣會被誤擋。) # 🔴 2026-08-12 補的洞:**直接用 HTTP 打實例的寫入端點**。 # 由來:總管要把 `ship_refresh_cdn` v2 推上 leo21c,用的是 # `curl -X POST .../webhooks/named`(工作流部署端點,認證只是 namespace 明碼) # ——本閘的三個 CLI 關鍵字**一個都沒命中**,等於「不被擋地寫進 leo 那台」。 # ⇒ 這正是 D75 那句話要防的事:leo 說「**不是某種推送**,是會不會改到線上那台」。 # 本閘卻是照「某幾種指令」寫的 ⇒ 換個工具就穿過去了。 # 判準改成看**目標**:打到實例主機(workers.dev/arcrun.dev)+**帶寫入語意**才擋。 # 刻意不擋的:讀(沒有 -X 寫入動詞也沒有 body)、打 Gitea(git.uncle6.me 不是實例)、 # 以及上面已經放行的 staging。 *curl*|*httpie*|*" http "*|*wget*) case "$CMD" in *workers.dev*|*arcrun.dev*) # 🔴 2026-08-12 修誤攔:` -d ` 不只是 curl 的 --data,**一堆唯讀工具也用 -d** # (`tr -d`/`cut -d=`/`sort -d`/`date -d`/`xargs -d`/`paste -d`…)。 # 實撞:總管要「唯讀」驗證一把金鑰能不能開 kbdb 的閘,指令第一行是 # ACC=$(grep … | cut -d= -f2 | tr -d '\r') # ⇒ `tr -d ` 命中 `*" -d "*` ⇒ **一條純 GET 被當成寫入擋掉**。 # 這正是本檔第 124 行自己寫的那句:「誤攔比漏攔更危險——被擋得莫名其妙, # 人就會學會繞過它,那它就等於不存在。」 # ⇒ 比對前先把「已知的唯讀 -d 用法」剪掉,再看剩下的有沒有寫入語意。 CMD_W=$(printf '%s' "$CMD" | sed -E 's/(^|[|;&( ])(tr|cut|sort|uniq|date|xargs|paste|join|du|logger|split|comm)[[:space:]]+-d/\1\2 __READONLY_D__/g') case "$CMD_W" in *"-X POST"*|*"-X PUT"*|*"-X PATCH"*|*"-X DELETE"*|\ *"--request POST"*|*"--request PUT"*|*"--request PATCH"*|*"--request DELETE"*|\ *"--data"*|*" -d "*|*" -d'"*|*' -d"'*) ;; *) exit 0 ;; esac ;; *) exit 0 ;; esac ;; *) exit 0 ;; esac stamp_ok && exit 0 cat >&2 <<'MSG' 🚫 `acr push` 會把東西推上線上實例——交回總管(leo 2026-08-11 立) leo 原話: 「**hook 要不讓它對正式環境做任何推送,不是某種推送,這種只有透過你。**」 ━━━ 你是 subagent ━━━ 把 YAML/定義檔留著,交件時附上檔案路徑與「它會覆蓋什麼」,由總管推。 ━━━ 你是總管 ━━━ 確認過要推什麼、會蓋掉什麼之後: touch /tmp/.prod-write-ok # ⚠️ 只放行「下一個」動作,用完即消失 ⚠️ `acr recipe push` 另有一道**互動式暴露同意閘**(終端機裡要人親手輸入資源名)。 那道閘擋的是「把資源變成可被外部呼叫」,**本閘的戳記蓋不過它**——那是 leo 的手。 MSG exit 2