Files
ISEP/hooks/stage-before-prod-guard.sh
T
Leo 0cdb6f2c05 fix(hooks): 讓四支閘認得出「指令位置」跟「heredoc/引號裡的文字」
inkstone/InkStoneCo#23、#56 同一個病:閘對整條指令字串做關鍵字掃描,
把「檔案內容/留言引用裡剛好提到某個關鍵字」當成「真的在執行」,
同時放過包一層讀取指令、或藏在 heredoc body 裡的真動作。

- 新增共用輔助 hooks/lib/strip_heredoc.py:heredoc body 是資料不是指令,
  四支閘(github-contact / main-and-prod-push / stage-before-prod /
  kbdb-api-wall 的 Bash 分支)呼叫前一律先拿掉 body 再比對。
- main-and-prod-push-guard.sh:修掉跟 release-tag-guard.sh 同款的
  「開頭是讀取工具就整條放行」前綴繞過洞;git push 的偵測改成指令位置比對;
  main/master 目標改用單字邊界,不再誤中 "domain" 這種子字串。
- github-contact-guard.sh:拿掉 gh CLI/git push 判準裡「前面隨便一個空白
  就算數」的鬆散邊界,只認真正的指令分隔符。
- kbdb-api-wall-guard.sh:Bash 分支原本引用不存在的 kbdb_cmd_check.py,
  python3 找不到檔案就吃掉錯誤印 "OK",該分支形同虛設——任何
  `wrangler d1 execute` 直打 kbdb 都會被放行。邏輯搬進新檔
  hooks/lib/kbdb_cmd_check.py(shlex 分詞、quote-aware),把 .sh 的
  參照路徑改過去,補回 Bash 分支的 kbdb-sql-ok 逃生口。

四支各補 InkStoneCo#40 §1 要求的三行中文檔頭。

新增四支可重跑測試(scripts/test-*.sh),共 69 條斷言全過,
含 #23/#56 票上實撞的原始形狀(寫 docs/TESTING.md 的 heredoc、
貼引用 #56 敘述的留言、`grep git push`)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 18:51:52 +08:00

203 lines
13 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 出貨鏈的動作(打 prod bundle、開 GitHub 保險、prod 安裝器部署)——沒有 6 小時內的 stage 驗證紀錄不放行。
# 為什麼: leo 2026-08-08——開始封測後推 prod 就是發佈給人看,要先在 stage 走過一次真實路徑再上。
# 誤觸時怎麼關: 讀取/查證(讀 manifest、讀腳本、curl 公開 CDN)一律放行;真的驗過 stage,貼實測輸出後 `touch /tmp/.stage-verified` 重送。
# stage-before-prod-guard.sh — PreToolUseBash):**未經 stage 驗過,不准動 prod 出貨鏈**。
#
# 🔴 立這道閘的來由(leo 2026-08-08):
# 「現在因為**開始封測**,不直接打到 prod,而是先打到昨天建的 stage,你知道嗎?
# 或是**有在哪裏寫了讓你不會直接推 prod**?因為**推 prod 就發佈了**
# 雖然現在人不多,但**要謹慎**。」
#
# 查證結果(總管 2026-08-08 實查,這道閘因此才有必要):
# ① wiki 有 stage 的記載(08-07 建的:staging bundle repo 在 Gitea
# `arcrun-rag-bundles-staging`、安裝器 `arcrun-rag-installer-staging.uncle6-me.workers.dev`
# ② **但 `ship-check` skill 的出貨步驟從頭到尾寫的是 prod**
# github-arm → GitHub `arcrun-rag-bundles` → jsDelivr/raw 驗證),
# **沒有任何一步是「先上 stage 驗過再上 prod」**
# ③ **沒有任何 hook 擋 prod**。D20 那道擋的是「寫 GitHub」,不是「未經 stage 就發佈」——
# arm 一開,照樣可以直達 prod
# ⇒ 也就是「知道 stage 存在」對行為**零作用**。這是同一天第四次同款:
# 規則/知識存在,但沒有牙齒(前三次:history-first、KBDB-first、派工-first)。
#
# 兩道條件,**缺一不可**leo 2026-08-08 把層級拉高後的形狀):
# ① `.github-armed` 存在=leo 親手解過保險。**這道我造不出來,也不准去造。**
# leo:「現在到 github 就是同 arm,推到 prod 也應該視同 arm。」
# ② `/tmp/.stage-verified` 6 小時內=stage 驗過(要在回覆貼實測輸出,
# 「我測過了」不算)。這道是我的責任,但它**不能替代 ①**。
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 || echo "")
[ -z "$CMD" ] && exit 0
# 2026-08-20inkstone/InkStoneCo#23、#56 同款):heredoc 的 body 是資料,不是指令。
# 拿掉 heredoc body 再比對,跟 github-contact-guard.shmain-and-prod-push-guard.sh
# 用同一支共用輔助程式;失敗就退回原字串(fail-open 在「這支有沒有幫上忙」,
# 不是「放行與否」——見 hooks/lib/strip_heredoc.py 檔頭)。
CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_heredoc.py" 2>/dev/null || printf '%s' "$CMD")
# 只攔「會讓封測者拿到東西」的動作:prod bundle repo、開 GitHub 保險、prod 安裝器部署。
# staging 的同名動作要放行——所以先排除帶 staging 字樣的命令。
case "$CMD" in
*staging*|*-staging*) exit 0 ;;
esac
# 🔴 2026-08-08:本閘上線後**連續誤攔自己兩次**,兩次都是同一個病——
# 只比對「命令字串裡有沒有出現關鍵字」,把**談論**當成**執行**:
# ① `sed -n '1,40p' scripts/github-arm.sh` =我只是要**讀**那支腳本
# ② `git commit -m "...照 scripts/github-arm.sh 解保險..."` =關鍵字在**commit 訊息**裡
# ⇒ **誤攔比漏攔更容易殺死一道閘**:被擋得莫名其妙,人就會想辦法繞過它。
# ⇒ 改成「先排除明確不發佈的動作,再比對關鍵字」。
#
# 一律不發佈的動作(讀取、寫本地版控、查狀態)——先放行,免得關鍵字出現在訊息裡就中槍
# 🔴 2026-08-12:這組「開頭是唯讀工具就放行」的豁免**本身是穿牆路**——與
# prod-write-guard 同款(那支當天已修,這支漏了)。實測:
# echo go && scripts/github-arm.sh "出貨" 30
# 開頭是 `echo ` ⇒ 整條 exit 0 ⇒ **真的執行解保險腳本卻一聲不吭**。
# 前面接一個 echo/cat 就能繞過任何閘。
# ⇒ 命令裡只要出現投送工具,就不吃「開頭豁免」,交給下面的
# HAS_PUBLISH_ACTION 逐條判(它看的是「在不在命令位置」,不是「有沒有出現」)。
case "$CMD" in
*"git push"*|*wrangler*|*ship.mjs*|*github-arm.sh*|*publish-*|*curl*|*wget*|*httpie*|*" acr "*) ;;
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"*|*" --dry-run"*) exit 0 ;;
esac
# 🔴 2026-08-12 第三次誤攔(同一晚第三道閘犯同一個病):
# 上面那組豁免只認「指令**開頭**是唯讀工具」,所以
# `BASE="https://cdn.jsdelivr.net/gh/.../arcrun-rag-bundles@<sha>"; curl -s "$BASE/manifest.json"`
# ——一個**抓公開 CDN 檔案來查證**的純 GET——因為開頭是變數指派而被當成出貨擋下。
# 更糟:連 `grep ... stage-before-prod-guard.sh`**讀本閘自己的原始碼**)都被擋,
# 因為那行指令裡出現了關鍵字 ⇒ **這道閘無法被檢查、也無法被測試**。
# ⇒ 本閘第 44 行自己早就寫了「誤攔比漏攔更容易殺死一道閘」。這次攔到自己頭上三回。
#
# 真正的判準不是「有沒有提到出貨物件」,是「**這條指令有沒有真的在發佈**」。
# 讀 manifest、讀 bundle 內容、讀腳本原始碼 —— 全是查證,那正是出貨前**該**做的事。
HAS_PUBLISH_ACTION=false
# 剪掉唯讀工具的 -d 旗標(tr -d/cut -d=…),免得被當成 curl 的 --dataprod-write-guard 同款教訓)
CMD_W=$(printf '%s' "$CMD" | sed -E 's/(^|[|;&( ])(tr|cut|sort|uniq|date|xargs|paste|join|du|split|comm)[[:space:]]+-d/\1\2 __RO_D__/g')
case "$CMD_W" in
*"wrangler deploy"*|*"wrangler versions deploy"*|*"wrangler publish"*) HAS_PUBLISH_ACTION=true ;;
# 🔴 2026-08-12 第六次誤攔(又是我自己的改動造成的):原本「命令含 git push
# 就算發佈」,加上我新加的「有投送工具就不吃開頭豁免」之後,
# **一個推 Gitea 的普通 commit,只因為 commit 訊息裡提到 github-arm,就被擋**
# ——那正是本檔第 43 行說「已經修好」的情境(關鍵字在 commit 訊息裡)復發。
# ⇒ `git push` 只有在**推 bundle repo** 時才是出貨;推自己的 repo 不是。
*"git push"*)
case "$CMD_W" in *bundles*) HAS_PUBLISH_ACTION=true ;; esac
;;
*"acr push"*|*"acr recipe push"*|*"acr update"*) HAS_PUBLISH_ACTION=true ;;
*"ship.mjs"*|*"publish-mirror"*|*"publish-bundles"*) HAS_PUBLISH_ACTION=true ;;
# 直接「執行」解保險腳本(不是讀它、不是在訊息裡提到它)
#
# 🔴 2026-08-12 第四次誤攔——**這條是我自己當天稍早寫的,pattern 太鬆**:
# 原本寫 `*sh\ *github-arm.sh*`,而 glob 的 `*` 跨越整條命令
# ⇒ 一條「讀取」指令只要**在別處出現過 "sh " 再出現 github-arm.sh** 就中:
# echo "=== github-arm.sh 怎麼判過期 ==="; grep -nE '…' scripts/github-arm.sh
# (前半的 `github-arm.sh ` 提供了 "sh ",後半提供了檔名)⇒ 純讀取被當成發佈。
# ⇒ 修別人的閘時自己製造了第四次誤攔。教訓與前三次同款:
# **判準要看「它在不在命令位置」,不是「字串有沒有出現過」。**
*github-arm.sh*)
# 只有出現在**命令起始位置**(行首/`;``|``&&` 之後,可帶 bash/sh 前綴)才算執行
if printf '%s' "$CMD_W" | grep -qE '(^|[;&|]|&&|\|\|)[[:space:]]*((bash|sh|zsh)[[:space:]]+)?(\./)?([A-Za-z0-9_./-]*/)?github-arm\.sh([[:space:]]|$)'; then
HAS_PUBLISH_ACTION=true
fi
;;
# HTTP 寫入動詞
*"-X POST"*|*"-X PUT"*|*"-X PATCH"*|*"-X DELETE"*|*"--data"*|*" -d "*) HAS_PUBLISH_ACTION=true ;;
esac
[ "$HAS_PUBLISH_ACTION" = true ] || exit 0
# 剩下的才比對「會讓封測者拿到東西」的關鍵字
case "$CMD" in
*arcrun-rag-bundles*|*github-arm*|*publish-github*) ;;
*) exit 0 ;;
esac
# ── 條件 ①:leo 親自解保險(人閘,我無法自造)─────────────────────────────
#
# 🔴 leo 2026-08-08:「**現在到 github 就是同 arm,推到 prod 也應該視同 arm。**」
# ⇒ 「推 prod」與「寫 GitHub」是**同一級的動作**,都要 leo 親手解保險。
# 先前 D20 只把 arm 綁在「寫 GitHub」上,於是「推 prod」這件事本身沒有人閘——
# 而真正該被守住的是**發佈**,不是「用了哪個 host」。
# 🔴 2026-08-08 差點出事:原本寫 `ARMED="$CLAUDE_PROJECT_DIR/.github-armed"`
# 在 CLAUDE_PROJECT_DIR 未設的環境下被 `set -u` 當場打死 ⇒ 腳本 **exit 1**。
# 而 exit 1 **不擋**(只有 exit 2 才擋)⇒ 這道閘會**靜默放行**。
# ⇒ 一道「失敗時自動失效」的安全閘比沒有更危險:它讓人以為有守。
# **安全閘的預設必須是 fail-closed。**
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
ARMED="$PROJ/.github-armed"
if [ ! -f "$ARMED" ]; then
cat >&2 <<'AEOF'
🔫 出貨人閘:推 prod = 發佈,**視同 arm,要 leo 親自解保險**。
【leo 2026-08-08】「現在到 github 就是同 arm**推到 prod 也應該視同 arm**。」
⇒ 該守的是「**發佈**」這個動作本身,不是「用了哪個 host」。
這一步**不是我能自己過的**。請 leo 整行貼:
~/Documents/tech_projects/InkStoneCo/scripts/github-arm.sh "<出貨說明>" 30
📌 在請他解保險之前,先確認 stage 已經驗過(下面那道閘),
否則等於請他為一個沒驗過的東西按發射鈕。
AEOF
exit 2
fi
# ── 條件 ②:stage 先驗過(我的責任,但不能替代條件 ①)───────────────────
#
# 🔴 2026-08-08 出貨當下實撞:本段**訊息與實作互相矛盾**——
# 下面那段說明叫人 `touch /tmp/.stage-verified`
# 但這裡讀的是**檔案內容**當時間戳 ⇒ `touch` 產生空檔 ⇒ `cat` 空 ⇒ 退回 0
# ⇒ `now - 0` 恆大於 6 小時 ⇒ **照它自己的說明做,永遠過不了**。
# 這正是那天燒掉一整天的同一個病:**宣稱的做法 ≠ 真正的做法**。
# ⇒ 改成兩者都認:內容有數字就用內容,沒有就用 mtime(`touch` 因此真的有效)。
#
# 另外認 leo 親手蓋的章(scripts/stage-ok.sh 產生),那是比我自評更強的訊號。
for STAMP in /tmp/.stage-ok-by-leo /tmp/.stage-verified; do
[ -f "$STAMP" ] || continue
now=$(date +%s)
# 🔴 只有「整行就是一個時間戳」才採信內容。
# 別用 tr 抽數字——JSON 章 `{"sha":"abc123","release":"1.4.24",…}` 抽出來會變成
# 一個**看似合理但錯得離譜**的舊時間戳(1970 年),比 fail-open 更難察覺。
t=$(head -1 "$STAMP" 2>/dev/null | tr -d '[:space:]')
# 內容不是時間戳(例:被 touch 成空檔)⇒ 退回檔案修改時間,別誤判成「沒驗過」
case "$t" in ''|*[!0-9]*) t=0;; esac
# 🔴 同日再撞一次 fail-open:ship.mjs 把這個章從「純時間戳」升級成 JSON 之後,
# 抽數字會得到天文數字 ⇒ `now - t` 是**負數** ⇒ `< 21600` 成立 ⇒ **永遠放行**。
# ⇒ 只有「不晚於現在」的時間戳才算數;不合理就退回 mtime,別當成通過。
if [ "$t" -eq 0 ] || [ "$t" -gt "$now" ]; then
t=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
fi
[ "$t" -gt 0 ] && [ "$((now - t))" -lt 21600 ] && exit 0 # 6 小時內驗過 stage → 放行
done
cat >&2 <<'EOF'
🚦 出貨閘:你正要動 **prod 出貨鏈**,但這 6 小時內沒有 stage 驗證紀錄。
【leo 2026-08-08】「現在因為**開始封測**,不直接打到 prod,而是先打到昨天建的 stage⋯⋯
因為**推 prod 就發佈了**,雖然現在人不多,但**要謹慎**。」
【為什麼有這道閘】`ship-check` 的步驟本身就寫成直達 prod,D20 那道只擋「寫 GitHub」、
不擋「未經 stage 就發佈」⇒ 光「知道有 stage」對行為零作用。
正確順序(stage 先行):
1. 打 staging bundle → 推 Gitea `arcrun-rag-bundles-staging`(不碰 GitHub,無 D20 閘)
2. 用 staging 安裝器實裝到測試實例
https://arcrun-rag-installer-staging.uncle6-me.workers.dev
3. 走一次**封測者真的會走的那條路**,貼出實測輸出
4. 過了才動 prod
⚠️ 身分:leo 2026-07-25 令「測試一律用 youlin,別拿 leo21c 當探針(會製造假信號)」
⇒ 部署前先 `acr whoami` 確認身分。
驗過了 ⇒ `touch /tmp/.stage-verified` 後重送,並在回覆裡**貼 stage 的實測輸出**
(「我測過了」不算——貼指令與它吐出來的東西)。
EOF
exit 2