53328bfbe9
三支閘的 stamp_ok/退回機制都用 `stat -f %m … || stat -c %Y …` 取 mtime。 GNU (Linux) 的 `stat -f` 是 --file-system,%m 被當成檔名 ⇒ 把整張檔案系統表印到 stdout(被 $() 收進去)且 exit 1 ⇒ MT 變非數字 ⇒ case *[!0-9]* 命中 ⇒ 釋放章永遠失效。 dev 在 macOS(BSD `-f %m` 正常)所以本機測不出來,一到雲端 Linux 就咬。 實際後果(2026-08-23 雲端 cloud-worker 實撞): - prod-write-guard:總管 touch /tmp/.prod-write-ok 也放行不了 ⇒ routine 的心跳打不出去 - main-and-prod-push-guard:arm 章在 Linux 形同虛設 - stage-before-prod-guard:stage 已驗的退回機制失效 改法:GNU 先、BSD 後(stat -c %Y … || stat -f %m …),兩平台都正確。 驗證(Linux):抽出 patched stamp_ok 實跑—— 新戳記→rc=0(釋放、單次消耗)/無戳記→rc=1(擋)/逾時>900s→rc=1(過期)。 反證:舊順序帶新戳記→rc=2(確認舊版在 Linux 是壞的)。
269 lines
15 KiB
Bash
Executable File
269 lines
15 KiB
Bash
Executable File
#!/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)
|
||
# 🔴 GNU (Linux) 先、BSD (macOS) 後——順序反了戳記在雲端永遠失效:
|
||
# GNU 的 `stat -f` 是 --file-system,`%m` 被當成檔名 ⇒ 把整張檔案系統表印到
|
||
# stdout(被 $() 收進去)且 exit 1 ⇒ MT 變非數字 ⇒ 這道閘的釋放章在 Linux 永不生效。
|
||
# dev 在 macOS(BSD `-f %m` 正常)所以沒被抓到,一到雲端 Linux 就咬(實測 2026-08-23)。
|
||
MT=$(stat -c %Y "$STAMP" 2>/dev/null || stat -f %m "$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
|