Files
ISEP/hooks/prod-write-guard.sh
T
claude-code 53328bfbe9 修三支閘的戳記釋放在 Linux 永遠失效:stat -c %Y 要排在 stat -f %m 前面
三支閘的 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 是壞的)。
2026-08-23 20:55:59 +00:00

269 lines
15 KiB
Bash
Executable File
Raw 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
# prod-write-guard.sh — PreToolUseBash 會寫到線上實例的 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 在 macOSBSD `-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-closedpython 掛掉不准變成靜默放行 ⇒ 退回舊的整串比對,寧可誤攔
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
# stagestaging 一律自由——擋的是 prod,不是所有部署
#
# 🔴 2026-08-12 補:`youlin`youlin-hsieh-dev**就是測試場**,不是 prod。
# arcrun-rag CLAUDE.md 白紙黑字:「要看範例或做任何測試 → **只在 youlinD37 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.devarcrun.dev)+**帶寫入語意**才擋。
# 刻意不擋的:讀(沒有 -X 寫入動詞也沒有 body)、打 Giteagit.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