Files
ISEP/hooks/prod-write-guard.sh
T
Leo 17a74d7d3a 每一則回覆都自己說出拖了多久(inkstone/ISEP#63 → comment 5106)
leo 2026-08-27:「前面說過每個回覆要戴上已經花了總時長,這為什麼沒出現?」
              「這應該寫在 ISEP,隨時看自己拖了多久」
重點在後面那句:不是要總管記得戴,是要它長在機器上。
總管當時答「我沒做,現在開始戴」——而那正是這條規則第一次失效的方式。

一支閘掛兩個事件,是同一件事的兩半(票上點名的失效模式就在這裡):
  UserPromptSubmit → 注入算好的那一行(模型不必自己算,也算不準)
  Stop            → 查核這一回合的回覆裡到底有沒有那一行,沒有就擋一次
只做前半=又一個會被忽略的提醒;只做後半=罰它做一件拿不到資料的事。

判準不是關鍵字黑名單,是「那個被要求的輸出元素在不在」——
whitelist-of-one:要求一個機器產生的標記在場,不是猜哪些字不該在場。
換講法照樣要帶標記,多寫什麼都不會觸發。

時長從這段對話的第一則訊息算起,理由寫在 hooks/lib/countdown.py 檔頭:
「任務」在機器上沒有起點,而 CLAUDE.md 規則三點七「一段對話=一個 release」
剛好讓對話起點就是這個交付的起點——這個數字沒有人要維護,也不會說謊。
resume 取較早的那個:接關不是重新開始。

連帶修好票上的兩個卡點(prod-write-guard):
  卡點一 發一則 Telegram 跟部署工作流在閘眼裡一模一樣 ⇒ 判準改看打的是哪一個
        named webhook(路徑形狀+名字),放行範圍只有 notify_leo 一個名字
  卡點二 連「把卡點寫進票裡」都被同一支閘擋(同款第八次)⇒ 剝掉內文再判,
        起始行保留,所以真的在部署的寫法照樣擋
        (修這支的過程又撞了一次同款——那就是這一格最好的證據)

測試:countdown 20/20、prod-write-guard 29→37/37,全程離線
     (時鐘定住、狀態走環境變數、不打網路)。
實測:起點 06:28 台北、現在 08:03 ⇒ 期望「已過 1 小時 35 分」,實得同一字串。

plugin.json 0.9.0 → 0.10.0(版本沒動=沒有人吃得到)。

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

319 lines
18 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 '')
# ── 🔴 卡點二(inkstone/ISEP#63 → comment 5106):「執行它」vs「談論它」,第八次 ──
#
# 總管要把「本閘擋住了發通知」這件事**貼到票上**,而那則留言的內文**引用了**
# 本閘的判準(實例主機名、寫入旗標的字樣)⇒ **一則留言被自己擋下來。**
# 同款的第八次(`inkstone/InkStoneCo#23` 的標題已經寫著「第七次」)。
# ⇒ 本次修這支閘時**又撞了一次**:連「把修法寫進檔案」的那條指令都被擋,
# 因為它的內文引用了主機名。那就是這一格最好的證據。
#
# leo 2026-08-17 的診斷:文字層的閘那天 **8 次誤攔、0 次正確攔截**,且方向穩定——
# **紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
#
# 那一段內文**是資料,不是指令**。這支 lib`github-contact-guard`
# `main-and-prod-push-guard``stage-before-prod-guard` 三支已經在用)只剝內文、
# **保留起始行**——所以真的在部署的寫法(起始行就帶著實例網址與寫入旗標)
# 仍然整條看得到、照樣擋。
#
# ⚠️ 繞法(把內文先寫成檔案、再用不含關鍵字的指令送出)本來就通,
# 但那正是 leo 說的「**人就會學會繞過它,那它就等於不存在**」⇒ 要修的是閘。
CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_""heredoc.py" 2>/dev/null || printf '%s' "$CMD")
# ── 總管戳記:**單次、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-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*)
# 🔴 卡點一(inkstone/ISEP#63 → comment 5106):發一則通知被當成部署擋下
#
# leo 2026-08-28 要的是「每小時一個版本,用 Telegram 通知我」。
# 通道是 `notify_leo` 工作流(wiki `agent-memory.md`:一條 curl、不需任何金鑰)。
# 而發一則 Telegram **一定是帶 body 的寫入型請求打到那台實例**
# ⇒ 在本閘眼裡它跟「部署一個工作流上線」長得一模一樣。
#
# 🔴 **這不是誤攔,是解析度不夠**:兩者打的是同一個 named webhook 家族路徑。
# 真正的差別在**打的是哪一個 named webhook**——
# `…/webhooks/named/<ns>/notify_leo/trigger` = 發一則訊息,什麼都沒改
# `…/webhooks/named`(無名字、無 /trigger) = 部署工作流定義
# `…/webhooks/named/<ns>/ship_refresh_cdn/trigger` 真的動線上狀態
#
# 判準因此看**目標的結構**(路徑形狀+工作流名字),不是「有沒有寫入旗標」:
# 指令裡打到實例的**每一個**網址都是白名單裡的通知型 trigger ⇒ 放行
# 只要有一個不是(含部署端點、含別的工作流)⇒ 照擋
# ⚠️ 放行的範圍刻意只有一個名字。**不放行整個 named webhook 家族**——
# 那會把部署一起放掉,等於拆了這道閘。
NOTIFY_ONLY=$(printf '%s' "$CMD" | python3 -c '
import re, sys
cmd = sys.stdin.read()
ALLOW = {"notify_leo"} # 通知型:只發訊息,不改線上任何狀態
urls = re.findall(r"https?://[^\s\"\x27<>]+", cmd)
inst = [u for u in urls if re.search(r"://[^/]*(workers\.dev|arcrun\.dev)", u)]
if not inst:
raise SystemExit(0) # 沒有實例網址 ⇒ 這裡不表態,交給下面的寫入語意判斷
PAT = re.compile(r"https?://[^/]+/webhooks/named/[^/]+/([A-Za-z0-9_-]+)/trigger/?$")
def notify(u):
m = PAT.match(u.rstrip(",;)"))
return bool(m) and m.group(1) in ALLOW
print("notify-only" if all(notify(u) for u in inst) else "")
' 2>/dev/null || printf '')
[ "$NOTIFY_ONLY" = "notify-only" ] && exit 0
# 🔴 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