From 0cdb6f2c05bdfe4f285deb277f69669f2623de3d Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 20 Aug 2026 18:51:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(hooks):=20=E8=AE=93=E5=9B=9B=E6=94=AF?= =?UTF-8?q?=E9=96=98=E8=AA=8D=E5=BE=97=E5=87=BA=E3=80=8C=E6=8C=87=E4=BB=A4?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E3=80=8D=E8=B7=9F=E3=80=8Cheredoc/=E5=BC=95?= =?UTF-8?q?=E8=99=9F=E8=A3=A1=E7=9A=84=E6=96=87=E5=AD=97=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- hooks/github-contact-guard.sh | 16 ++- hooks/kbdb-api-wall-guard.sh | 8 +- hooks/lib/kbdb_cmd_check.py | 133 +++++++++++++++++++ hooks/lib/strip_heredoc.py | 73 ++++++++++ hooks/main-and-prod-push-guard.sh | 161 +++++++++++++---------- hooks/stage-before-prod-guard.sh | 9 ++ scripts/test-github-contact-guard.sh | 39 ++++++ scripts/test-kbdb-api-wall-guard-bash.sh | 41 ++++++ scripts/test-main-and-prod-push-guard.sh | 36 +++++ scripts/test-stage-before-prod-guard.sh | 49 +++++++ 10 files changed, 490 insertions(+), 75 deletions(-) create mode 100644 hooks/lib/kbdb_cmd_check.py create mode 100644 hooks/lib/strip_heredoc.py create mode 100755 scripts/test-github-contact-guard.sh create mode 100755 scripts/test-kbdb-api-wall-guard-bash.sh create mode 100755 scripts/test-main-and-prod-push-guard.sh create mode 100755 scripts/test-stage-before-prod-guard.sh diff --git a/hooks/github-contact-guard.sh b/hooks/github-contact-guard.sh index ee27c68..a41b9db 100755 --- a/hooks/github-contact-guard.sh +++ b/hooks/github-contact-guard.sh @@ -1,4 +1,7 @@ #!/bin/bash +# 管什麼: 對外接觸 GitHub 的寫入動作(gh CLI 高頻 API、git push/remote add 指向 github.com)——一律先擋,要 leo 手動解保險才放行。 +# 為什麼: D20(2026-07-02)——兩個帳號因高頻寫入被 GitHub flag 永久拿不回;讀不設限(讀不是 abuse),只擋寫。 +# 誤觸時怎麼關: 讀取(clone/fetch/pull/curl 抓檔)一律放行;真要寫 GitHub,leo 跑 scripts/github-arm.sh 解保險;純粹提到 github.com 的文字(heredoc/commit message/留言)不算數,見下方判準。 # github-contact-guard.sh — GitHub 接觸保險(D20,2026-07-02) # 背景:兩個帳號因高頻動作被 flag 拿不回(幾十顆星+issues 全損)。 # 設計:戰鬥機武器保險模式——平時所有 github.com 接觸一律機械擋下(exit 2), @@ -33,6 +36,13 @@ except Exception: [ -z "$CMD" ] && exit 0 +# 2026-08-20(inkstone/InkStoneCo#23 comment, #56):a heredoc body is data, not an +# instruction. Writing docs/TESTING.md (one example line of a real push command, +# meant for a human to try later) got blocked because the whole heredoc body was +# scanned for keywords. Strip heredoc bodies before matching; on failure fall back +# to the original string (fail-open on "did this helper run", not on the verdict). +CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_heredoc.py" 2>/dev/null || printf '%s' "$CMD") + # 命中判定(D20 邊界,2026-07-05 leo 拍板——Facebook 比喻定調): # GitHub 不在乎你「讀」(clone/fetch/抓 release,不管實名匿名、自己的還別人的——那是它原本的功能, # 像 FB 不禁你讀貼文)。它 abuse-detect 的是「機器人一直改/寫」(高頻 push、Actions fan-out、API 寫) @@ -43,10 +53,10 @@ except Exception: # 放行:git clone/fetch/pull/ls-remote(任何 repo,帶不帶認證都是讀)、curl/wget、go get/pip。 HIT="" # ① gh CLI —— 高頻 API,全擋(讀寫混雜且走你 token,保守全擋;真要唯讀查詢個案 arm) -if printf '%s' "$CMD" | grep -qE '(^|[;&|(]|\s)gh\s+(api|repo|issue|pr|auth|search|release|run|workflow|gist|browse)\b'; then +if printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*gh[[:space:]]+(api|repo|issue|pr|auth|search|release|run|workflow|gist|browse)\b'; then HIT="gh CLI(高頻 API,走你的 token)" # ② git 寫入動詞指向 github(push / remote add 為 push 鋪路)—— 寫入,擋 -elif printf '%s' "$CMD" | grep -qiE 'git\s+(push|remote\s+add)([^|;&]*)(github\.com)'; then +elif printf '%s' "$CMD" | grep -qiE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git[[:space:]]+(push|remote[[:space:]]+add)([^|;&]*)(github\.com)'; then HIT="git 寫入 → github(push/remote add)" # ②b 🔴 2026-08-05 補漏:上面那條只認指令裡的 **github.com 字面** # ⇒ `git push origin main`(remote 名指向 GitHub)**完全不會被攔**。 @@ -60,7 +70,7 @@ elif printf '%s' "$CMD" | grep -qiE 'git\s+(push|remote\s+add)([^|;&]*)(github\. # 不必等 remote 名稱解析成功才判斷得出來,而 remote 名稱解析在某些 cwd 下會失敗 # (見檔頭「2026-08-10 leo 簡化」那段的實撞紀錄)。字面查得到就直接判定, # 查不到才退回舊的「解 remote 名稱」那條路——兩條路都失手才會誤放行。 -elif printf '%s' "$CMD" | grep -qiE '(^|[;&|(]|\s)git\s+([^|;&]*\s)?push(\s|$)'; then +elif printf '%s' "$CMD" | grep -qiE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git[[:space:]]+([^|;&]*[[:space:]])?push([[:space:]]|$)'; then # 🔴 總管 2026-08-10 收窄:原版寫成「整串裡有 github.com 就擋」, # 於是**連 commit message 提到那個網址都會被擋**(我自己第一次要 commit 就撞到)。 # ⇒ 改成必須是「push 的目標」:github.com 要出現在 push 之後、且中間不跨命令分隔符。 diff --git a/hooks/kbdb-api-wall-guard.sh b/hooks/kbdb-api-wall-guard.sh index 5b84f54..8ef896d 100755 --- a/hooks/kbdb-api-wall-guard.sh +++ b/hooks/kbdb-api-wall-guard.sh @@ -1,4 +1,7 @@ #!/bin/bash +# 管什麼: Bash 分支擋「繞過 API 牆、直接對那顆資料庫執行 SQL」的指令;Write/Edit 分支擋新增 DDL/原生 SQL/新 D1 binding。 +# 為什麼: leo 2026-08-07 立 D38——KBDB 只能走 API,零 SQL、永不加表;規則掛在「KBDB」這個詞上,容易在想著「D1/建表」時漏載入。 +# 誤觸時怎麼關: 該行尾加 kbdb-sql-ok(留痕,commit 說明理由);Bash 分支的判準邏輯在 hooks/lib/kbdb_cmd_check.py,誤攔先查那支。 # PreToolUse hook — KBDB 是 API-as-Wall:零 SQL、永不加表(L3 硬攔截) # # 【leo 2026-08-07 立】原話: @@ -42,6 +45,9 @@ else BASH_CMD="" fi if [ -n "${BASH_CMD:-}" ]; then + # 逃生口跟 Write/Edit 分支同一個標記(留痕,commit 說明理由)——加了這支才會真的擋, + # 之前 Bash 分支引用的檔案不存在、永遠印 "OK",逃生口自然也沒人需要,現在補上。 + case "$BASH_CMD" in *kbdb-sql-ok*) exit 0 ;; esac # 🔴 2026-08-07 修正:第一版做「整條命令字串比對」,結果**第四次誤擋總管**—— # 誤擋的是 `git commit -m "...引用了那個命令字面..."`:那是在**描述**這件事, # 不是在執行它。同一天已被自己的閘誤擋三次(Task 分支),這是第四次。 @@ -49,7 +55,7 @@ if [ -n "${BASH_CMD:-}" ]; then # 引號內、heredoc 內、commit message 內的同樣字面一律不算。 # 判斷邏輯住在獨立檔(見該檔開頭:內嵌逃逸讓這道閘改一次壞一次) BASH_VERDICT=$(printf '%s' "$BASH_CMD" \ - | python3 "$(dirname "$0")/kbdb_cmd_check.py" 2>/dev/null || echo "OK") + | python3 "$(dirname "$0")/lib/kbdb_cmd_check.py" 2>/dev/null || echo "OK") if [ "$BASH_VERDICT" = "BAD" ]; then cat >&2 <<'EOB' 🧱 kbdb-api-wall-guard(Bash):偵測到繞過 API 牆、直接對那顆資料庫執行 SQL 的命令。 diff --git a/hooks/lib/kbdb_cmd_check.py b/hooks/lib/kbdb_cmd_check.py new file mode 100644 index 0000000..017365b --- /dev/null +++ b/hooks/lib/kbdb_cmd_check.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""hooks/lib/kbdb_cmd_check.py -- shared helper for kbdb-api-wall-guard.sh's +Bash channel (inkstone/ISEP#40 S7: helpers live in lib/, do not count as a +hand-written gate). + +What it guards: whether a bash command string contains a real, at-command- +position invocation of wrangler's D1 execute subcommand (any wrapper such as +npx/pnpm) targeting a database whose name mentions "kbdb" -- that is a +direct-SQL bypass of the KBDB API wall (D38, leo 2026-08-07: zero SQL, +always through the HTTP API). + +Why this file exists (not just "why the rule exists"): the calling gate, +hooks/kbdb-api-wall-guard.sh, used to pipe the raw command straight through +a keyword grep. That produced the exact "keyword shows up vs. real +instruction" confusion this repo has hit repeatedly (inkstone/InkStoneCo#23: +the phrase showed up inside a delegation prompt that was *talking about* the +rule, not breaking it). The fix pattern already proven on +hooks/release-tag-guard.sh is "only count a keyword when it sits at command +position" -- this file is that same pattern for the KBDB Bash channel, +factored out to its own file because embedding shell-escaping-aware parsing +inline in the .sh caused regressions each time someone touched it (see the +.sh file's own header for that history). + +Two extra layers vs. a plain regex: + 1. hooks/lib/strip_heredoc.py runs first (imported below) so a heredoc body + that merely *mentions* the D1-execute pattern against kbdb as + documentation text does not count -- same root cause as + InkStoneCo#23/#56. + 2. Command segmentation uses shlex with punctuation_chars, so a `;`/`&`/`|` + that appears *inside* a quoted string (e.g. a commit message) does not + get treated as a command boundary, and text inside quotes is only + inspected when the quoted text is itself an argument to a command that + is genuinely at command position (e.g. a `--command` value passed to a + real wrangler invocation) -- not when it is merely quoted prose + describing the rule. + +Genuine parse failures (unbalanced quotes, exotic constructs) fail OPEN +(print "OK"), matching this repo's stated design discipline: fail-open on +parse failure, not fail-open on the verdict itself (see release-tag-guard.sh +header). The caller (kbdb-api-wall-guard.sh) already blocks direct file-path +writes into kbdb/ paths and DDL/`.prepare(`/`.exec(`/`.batch(` in Write/Edit +content through its own separate channels -- this file only covers the Bash +CLI channel. + +Usage: + printf '%s' "$CMD" | python3 hooks/lib/kbdb_cmd_check.py + -> prints exactly "BAD" or "OK" on stdout. +""" +import os +import shlex +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from strip_heredoc import strip_heredocs # noqa: E402 + +_WRAPPERS = {"npx", "pnpm", "yarn", "bunx", "exec", "sudo", "env", "corepack"} +_DB_HINT = "kbdb" +_D1_TOKEN = "d1" +_SUBCMD_TOKEN = "execute" + + +def _segments(cmd: str): + """Split cmd into a list of token-lists, one per "simple command", + breaking on real shell control operators. Quote-aware via shlex, so a + control character inside a quoted string does not split. Returns None + on unparseable input (caller should fail open).""" + try: + lex = shlex.shlex(cmd, posix=True, punctuation_chars=True) + lex.whitespace_split = True + tokens = list(lex) + except ValueError: + return None + + boundary = {";", "&", "&&", "|", "||", "(", ")", "\n"} + segments = [] + current = [] + for tok in tokens: + if tok in boundary: + if current: + segments.append(current) + current = [] + else: + current.append(tok) + if current: + segments.append(current) + return segments + + +def _is_kbdb_d1_execute(segment): + """True if this one simple command is wrangler's D1 execute subcommand + (optionally behind a wrapper like npx/sudo/env) aimed at a database + whose name mentions "kbdb" anywhere in its arguments.""" + i = 0 + # Skip leading VAR=value assignments (env-style prefix). + while i < len(segment): + head = segment[i].split("=", 1)[0] + if "=" in segment[i] and head.replace("_", "").isalnum() and head[:1].isalpha(): + i += 1 + else: + break + # Skip known wrappers (npx wrangler ..., sudo wrangler ..., env X=Y wrangler ...). + while i < len(segment) and os.path.basename(segment[i]) in _WRAPPERS: + i += 1 + if i >= len(segment): + return False + cmdname = os.path.basename(segment[i]) + if cmdname != "wrangler": + return False + rest = segment[i + 1 :] + if _D1_TOKEN not in rest or _SUBCMD_TOKEN not in rest: + return False + joined_lower = " ".join(rest).lower() + return _DB_HINT in joined_lower + + +def check(cmd: str) -> str: + stripped = strip_heredocs(cmd) + segments = _segments(stripped) + if segments is None: + return "OK" # parse failure -> fail open, not fail on the verdict + for seg in segments: + if _is_kbdb_d1_execute(seg): + return "BAD" + return "OK" + + +def main() -> None: + cmd = sys.stdin.read() + print(check(cmd)) + + +if __name__ == "__main__": + main() diff --git a/hooks/lib/strip_heredoc.py b/hooks/lib/strip_heredoc.py new file mode 100644 index 0000000..e921cc9 --- /dev/null +++ b/hooks/lib/strip_heredoc.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""hooks/lib/strip_heredoc.py -- shared helper, not a gate (inkstone/ISEP#40 S7: +helpers live in lib/ and do not count as a hand-written gate). + +What it guards: nothing by itself. It strips the *body* of a bash heredoc out +of a command string, keeping the start line (the control-flow part) intact. + +Why: a heredoc body is data, not an instruction. Two mis-blocks on +2026-08-20 (inkstone/InkStoneCo#23, #56) shared one root cause: a gate ran a +keyword scan over the *entire* command string, heredoc body included, so text +that merely *mentioned* a trigger phrase inside a file being written (or a +comment being posted) was treated as if that phrase were actually being +executed. One case was writing docs/TESTING.md (the body had one example line +of a GitHub push command as literal text for a human to try later); the other +was posting a Gitea comment that quoted this very ticket's own description. +This helper only removes the body; each gate keeps its own keyword rules, it +just no longer has to solve heredoc-quoting itself. + +Usage: + printf '%s' "$CMD" | python3 hooks/lib/strip_heredoc.py + +Or import it as a module: + from strip_heredoc import strip_heredocs +""" +import re +import sys + +# < str: + """Replace every heredoc body in cmd with nothing; keep the start line + and everything outside heredocs untouched.""" + lines = cmd.split("\n") + out = [] + i = 0 + n = len(lines) + while i < n: + line = lines[i] + m = _START_RE.search(line) + if not m: + out.append(line) + i += 1 + continue + strip_tabs = m.group(1) == "-" + delim = m.group(3) + out.append(line) # the start line itself is control flow, keep it + i += 1 + found_end = False + while i < n: + probe = lines[i] + check = probe.lstrip("\t") if strip_tabs else probe + if check == delim: + i += 1 # the terminator line is a marker, drop it too + found_end = True + break + i += 1 # body line: drop it, do not append to out + if not found_end: + # command was truncated / no terminator found -- do not invent + # one, we've already consumed to the end of the string. + pass + return "\n".join(out) + + +def main() -> None: + cmd = sys.stdin.read() + sys.stdout.write(strip_heredocs(cmd)) + + +if __name__ == "__main__": + main() diff --git a/hooks/main-and-prod-push-guard.sh b/hooks/main-and-prod-push-guard.sh index ffd5989..7e8407a 100755 --- a/hooks/main-and-prod-push-guard.sh +++ b/hooks/main-and-prod-push-guard.sh @@ -1,4 +1,7 @@ #!/bin/sh +# 管什麼: subagent 推 gitea main 要先有總管戳記;wrangler 部署到非 stage 環境要 leo 手動解保險。 +# 為什麼: leo 2026-08-10——「subagent 推 main 你確認,推 prod 我確認」;規則存在但沒機制驗證有沒有照做,於是閘長在機器上。 +# 誤觸時怎麼關: 讀取/查狀態/演練一律放行;真要推 main,總管看過 commit 後 `git rev-parse --show-toplevel > /tmp/.main-push-ok`;真要打 stage,指令裡帶 staging/--env stage 字樣。 # main-and-prod-push-guard.sh — PreToolUse(Bash):**兩層手動確認閘** # # 🔴 立這道閘的來由(leo 2026-08-10): @@ -34,9 +37,23 @@ except Exception: print("") [ -z "$CMD" ] && exit 0 +# 🔴 2026-08-20(inkstone/InkStoneCo#23、#56):heredoc 的 body 是資料,不是指令。 +# 直接對整條指令字串(含 heredoc 內文)做關鍵字掃描,會把「檔案內容/留言引用裡 +# 剛好提到 git push」當成「真的在推」——同一天兩起實撞:寫 docs/TESTING.md 的 +# 一行範例文字、貼一則引用 #56 敘述的 Gitea 留言,都被這樣擋下。 +# 拿掉 heredoc body 再比對,判準不變,範圍變準。失敗就退回原字串(fail-open +# 在「這支有沒有幫上忙」,不是「放行與否」——見 hooks/lib/strip_heredoc.py 檔頭)。 +CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_heredoc.py" 2>/dev/null || printf '%s' "$CMD") + # ── 先放行明確不發佈的動作(讀取、查狀態、寫本地版控、演練)────────────── -# 關鍵字出現在 commit 訊息、在 sed/grep 的參數裡,都不是「執行」。 +# 🔴 2026-08-20 修正(release-tag-guard.sh/stage-before-prod-guard.sh 已修過同款洞): +# 舊版「CMD 開頭是讀取工具就整條放行」本身是穿牆路—— +# echo x && git push gitea HEAD:main +# 開頭是 echo,照 glob 前綴比對會整條 exit 0,後面真的在推 main 完全不看。 +# ⇒ 改成「先看有沒有出現危險關鍵字,出現了就不吃開頭豁免」,交給下面的 +# 「指令位置」判準去判斷它是不是真的在執行。 case "$CMD" in + *"git push"*|*"wrangler deploy"*|*"wrangler publish"*|*"wrangler versions deploy"*) ;; 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"*|*"git branch"*) exit 0 ;; @@ -91,73 +108,78 @@ stamp_ok() { return 0 } -if true; then - case "$CMD" in - *"git push"*) - # 只擋打到 main/master 的;推自己的 feature 分支照常放行 - case "$CMD" in - # 🔴 2026-08-12 拿掉 `push -u` / `push --set-upstream` 這兩個條件。 - # 它們本來是想抓「沒寫分支的 push」,但實際抓到的是 - # `git push -u gitea fix/xxx`——**subagent 發表自己分支的標準動作** - # (第一次推當然要 -u)。⇒ 舊版等於「agent 永遠推不出自己的分支」, - # 而 leo 2026-08-12 的設計是「主線禁止動,大家都走 PR」,推分支是那條路的第一步。 - # 08-12 當天四張 PR 全是繞成 `git push gitea a:a` 才推出去的。 - # `*main*`/`*master*` 兩條照舊——真正該擋的是目標分支,不是有沒有帶旗標。 - *main*|*master*) - stamp_ok && exit 0 +# reworked 2026-08-20 (inkstone/InkStoneCo#23, #56): 'does this command contain +# git push' moved from a whole-string substring test to a command-position test -- +# same pattern already validated on release-tag-guard.sh: the phrase only counts +# at the start of the string, or right after ; & | ( ` && ||. A heredoc body was +# already stripped above; this closes the remaining gap where the phrase shows up +# quoted inside the *same* command line (e.g. a python -c call whose string argument +# discusses git push as prose). +if printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git([[:space:]]+[^;&|]*)?[[:space:]]+push([[:space:]]|$)'; then + # only block pushes that target main/master; pushing your own feature branch is fine + # + # 2026-08-12: dropped the `push -u` / `push --set-upstream` conditions -- those were + # meant to catch "push with no branch named", but what they actually caught was + # `git push -u gitea fix/xxx`, the normal first-push-of-a-branch shape for a + # subagent. The target branch is what should be gated, not whether a flag is present. + # 2026-08-20: match the target branch on a word boundary, not a bare substring -- + # a glob like *main* also matches "domain" (d-o-**m-a-i-n**), e.g. a push to + # `fix/custom-domain-setup` would have false-positived. + if printf '%s' "$CMD" | grep -qE '(^|[^A-Za-z])(main|master)([^A-Za-z]|$)'; then + stamp_ok && exit 0 - # ── 擋下的同時,把「誰想推什麼」留成一份請求(leo 2026-08-12)─────────── - # - # leo 原話:「**它會問你的意見,所以每個你叫起來的 subagent 都有名字。**」 - # - # 做得到的與做不到的,先講清楚: - # ❌ **做不到「同步問總管」**——hook 跑在子 session 自己的行程裡,總管在另一個行程。 - # 要同步問只能 block 等一個檔案出現,那會把 subagent 掛死在那裡。 - # ✅ **做得到「當場擋 + 留下原始請求」**:總管在自己的迴圈裡讀這個目錄, - # 看到的是 repo/分支/逐筆 commit 的**原始資料**,不是 subagent 的散文轉述。 - # ——這才是名字真正值錢的地方:**不是判斷你是誰,是留下是誰要求的**。 - # - # 🔴 身分的方向刻意不改:**沒有名字不等於總管**(那是 fail-open—— - # 子 session 繼承環境變數,把名字拿掉就升格了)。放行的唯一憑證仍然只有 - # 那枚綁 repo、用完即丟的戳記。名字只是署名,不是權限。 - # 📌 名字只在 `claude -p` 起的子 session 上可靠(乾淨的環境邊界); - # Agent tool 起的 subagent 與總管同一個行程、共用環境 ⇒ 那條路上名字塞不進也擦不掉。 - # 而改子 repo code 的正路本來就是 `claude -p`,所以夠用。 - _who="${CLAUDE_AGENT_NAME:-未署名}" - _hookdir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || _hookdir="" - _reqdir="${_hookdir%/hooks}/pending-main-push" - if [ -n "$_hookdir" ] && mkdir -p "$_reqdir" 2>/dev/null; then - _root=$(git rev-parse --show-toplevel 2>/dev/null || printf 'unknown') - # 檔名只用 ASCII(`未署名` 之類會被 tr 打成一排 dash,看不出是誰) - _slugwho=$(printf '%s' "${CLAUDE_AGENT_NAME:-unnamed}" | tr -c 'A-Za-z0-9._-' '-') - case "$_slugwho" in *[!-]*) : ;; *) _slugwho=unnamed ;; esac - # ⚠️ 先 printf 再 tr:`basename` 會帶一個換行,直接餵 tr 會變成結尾多一根 dash - _slugrepo=$(printf '%s' "$(basename "$_root")" | tr -c 'A-Za-z0-9._-' '-') - _slug="${_slugwho}--${_slugrepo}" - # ⚠️ 這幾行刻意用 `printf '%s\n' "整句"`,不要把內容寫進 printf 的格式字串裡。 - # 2026-08-12 實撞:格式字串裡同時有反引號與 %s 時,那幾行整行不見(而前後行都在), - # ——**寫完當場肉眼檢查產出的檔案才發現**,hook 自己不會叫。內容一律當資料傳。 - _branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?') - _when=$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null || printf '?') - _fence='```' - { - printf '%s\n\n' "# 推 main 的請求:$_who" - printf '%s\n' "- repo:$_root" - printf '%s\n' "- 分支:$_branch" - printf '%s\n\n' "- 時間:$_when" - printf '%s\n' "- 它想跑的指令:" - printf '%s\n%s\n%s\n\n' "$_fence" "$CMD" "$_fence" - printf '%s\n\n%s\n' "## 還沒推上去的 commit(原始資料,不是轉述)" "$_fence" - git log --oneline '@{upstream}..HEAD' 2>/dev/null \ - || git log --oneline -20 2>/dev/null \ - || printf '(列不出來)\n' - printf '%s\n\n%s\n\n%s\n' "$_fence" "## 改了哪些檔" "$_fence" - git diff --stat '@{upstream}..HEAD' 2>/dev/null | tail -40 || printf '(列不出來)\n' - printf '%s\n\n---\n%s\n' "$_fence" "總管裁完請刪掉這個檔——留著代表「還沒裁」。" - } > "$_reqdir/$_slug.md" 2>/dev/null || true - fi + # ── 擋下的同時,把「誰想推什麼」留成一份請求(leo 2026-08-12)─────────── + # + # leo 原話:「**它會問你的意見,所以每個你叫起來的 subagent 都有名字。**」 + # + # 做得到的與做不到的,先講清楚: + # ❌ **做不到「同步問總管」**——hook 跑在子 session 自己的行程裡,總管在另一個行程。 + # 要同步問只能 block 等一個檔案出現,那會把 subagent 掛死在那裡。 + # ✅ **做得到「當場擋 + 留下原始請求」**:總管在自己的迴圈裡讀這個目錄, + # 看到的是 repo/分支/逐筆 commit 的**原始資料**,不是 subagent 的散文轉述。 + # ——這才是名字真正值錢的地方:**不是判斷你是誰,是留下是誰要求的**。 + # + # 🔴 身分的方向刻意不改:**沒有名字不等於總管**(那是 fail-open—— + # 子 session 繼承環境變數,把名字拿掉就升格了)。放行的唯一憑證仍然只有 + # 那枚綁 repo、用完即丟的戳記。名字只是署名,不是權限。 + # 📌 名字只在 `claude -p` 起的子 session 上可靠(乾淨的環境邊界); + # Agent tool 起的 subagent 與總管同一個行程、共用環境 ⇒ 那條路上名字塞不進也擦不掉。 + # 而改子 repo code 的正路本來就是 `claude -p`,所以夠用。 + _who="${CLAUDE_AGENT_NAME:-未署名}" + _hookdir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || _hookdir="" + _reqdir="${_hookdir%/hooks}/pending-main-push" + if [ -n "$_hookdir" ] && mkdir -p "$_reqdir" 2>/dev/null; then + _root=$(git rev-parse --show-toplevel 2>/dev/null || printf 'unknown') + # 檔名只用 ASCII(`未署名` 之類會被 tr 打成一排 dash,看不出是誰) + _slugwho=$(printf '%s' "${CLAUDE_AGENT_NAME:-unnamed}" | tr -c 'A-Za-z0-9._-' '-') + case "$_slugwho" in *[!-]*) : ;; *) _slugwho=unnamed ;; esac + # ⚠️ 先 printf 再 tr:`basename` 會帶一個換行,直接餵 tr 會變成結尾多一根 dash + _slugrepo=$(printf '%s' "$(basename "$_root")" | tr -c 'A-Za-z0-9._-' '-') + _slug="${_slugwho}--${_slugrepo}" + # ⚠️ 這幾行刻意用 `printf '%s\n' "整句"`,不要把內容寫進 printf 的格式字串裡。 + # 2026-08-12 實撞:格式字串裡同時有反引號與 %s 時,那幾行整行不見(而前後行都在), + # ——**寫完當場肉眼檢查產出的檔案才發現**,hook 自己不會叫。內容一律當資料傳。 + _branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?') + _when=$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null || printf '?') + _fence='```' + { + printf '%s\n\n' "# 推 main 的請求:$_who" + printf '%s\n' "- repo:$_root" + printf '%s\n' "- 分支:$_branch" + printf '%s\n\n' "- 時間:$_when" + printf '%s\n' "- 它想跑的指令:" + printf '%s\n%s\n%s\n\n' "$_fence" "$CMD" "$_fence" + printf '%s\n\n%s\n' "## 還沒推上去的 commit(原始資料,不是轉述)" "$_fence" + git log --oneline '@{upstream}..HEAD' 2>/dev/null \ + || git log --oneline -20 2>/dev/null \ + || printf '(列不出來)\n' + printf '%s\n\n%s\n\n%s\n' "$_fence" "## 改了哪些檔" "$_fence" + git diff --stat '@{upstream}..HEAD' 2>/dev/null | tail -40 || printf '(列不出來)\n' + printf '%s\n\n---\n%s\n' "$_fence" "總管裁完請刪掉這個檔——留著代表「還沒裁」。" + } > "$_reqdir/$_slug.md" 2>/dev/null || true + fi - cat >&2 <<'MSG' + cat >&2 <<'MSG' 🚫 推 main 要先有「總管決定了」的戳記(leo 2026-08-10 立) leo 原話: @@ -196,11 +218,8 @@ leo 原話: 【真的該推 main 的例外】不存在。交回總管,一句話的事。 MSG - exit 2 - ;; - esac - ;; - esac + exit 2 + fi fi # ── ② prod 部署要 leo 親手解保險 ──────────────────────────────────────── diff --git a/hooks/stage-before-prod-guard.sh b/hooks/stage-before-prod-guard.sh index b13ed0b..8eecbab 100755 --- a/hooks/stage-before-prod-guard.sh +++ b/hooks/stage-before-prod-guard.sh @@ -1,4 +1,7 @@ #!/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 — PreToolUse(Bash):**未經 stage 驗過,不准動 prod 出貨鏈**。 # # 🔴 立這道閘的來由(leo 2026-08-08): @@ -32,6 +35,12 @@ except Exception: print("") ' 2>/dev/null || echo "") [ -z "$CMD" ] && exit 0 +# 2026-08-20(inkstone/InkStoneCo#23、#56 同款):heredoc 的 body 是資料,不是指令。 +# 拿掉 heredoc body 再比對,跟 github-contact-guard.sh/main-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 diff --git a/scripts/test-github-contact-guard.sh b/scripts/test-github-contact-guard.sh new file mode 100755 index 0000000..2bb8878 --- /dev/null +++ b/scripts/test-github-contact-guard.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# github-contact-guard 的測試(inkstone/InkStoneCo#23) +# 判準:真的寫 GitHub(gh CLI 高頻 API、git push/remote add 指向 github.com)要擋; +# 只是提到(heredoc body、同一行引號內的散文、commit message)不准擋; +# 讀取(clone/fetch/pull/curl 抓檔)一律放行,不管有沒有帶認證。 +cd "$(dirname "$0")/.." || exit 1 +H=hooks/github-contact-guard.sh +PASS=0; FAIL=0 +run(){ # $1=want $2=cmd + printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$2")}}" \ + | bash "$H" >/dev/null 2>&1 + got=$? + if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi + printf 'want=%s got=%s %.72s\n' "$1" "$got" "$2" +} + +echo "── 該擋:真的在寫 GitHub ──" +run 2 'gh issue create --title x --body y' +run 2 'gh pr create --title x' +run 2 'git push https://github.com/example/example.git HEAD:main' +run 2 'git remote add github https://github.com/example/example.git' +run 2 'echo start && gh api repos/example/example/issues' + +echo "── 不該擋:讀取一律放行 ──" +run 0 'git clone https://github.com/example/example.git' +run 0 'git fetch github' +run 0 'curl -sL https://github.com/example/example/releases/latest' +run 0 'gh --version' + +echo "── 不該擋:只是提到、heredoc body、同一行引號內的散文 ──" +run 0 "$(printf 'cat > docs/TESTING.md <<%sEOF%s\nexample: git push https://github.com/example/example.git HEAD:main\nEOF\n' "'" "'")" +run 0 'git commit -m "docs: explain why gh api calls used to be mis-flagged"' +run 0 'python3 -c "requests.post(url, json={\"body\": \"quoting: git push origin main to github.com was mis-flagged as a real push\"})"' +run 0 'grep -n "gh api" hooks/github-contact-guard.sh' +run 0 'grep -rn "git push" installer/scripts/line-source-repo.mjs' + +echo +echo "$PASS/$((PASS+FAIL)) 通過" +[ "$FAIL" -eq 0 ] diff --git a/scripts/test-kbdb-api-wall-guard-bash.sh b/scripts/test-kbdb-api-wall-guard-bash.sh new file mode 100755 index 0000000..7846254 --- /dev/null +++ b/scripts/test-kbdb-api-wall-guard-bash.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# kbdb-api-wall-guard 的 Bash 分支測試(inkstone/ISEP#30 群組) +# +# 這支補的是一個真的漏擋:hooks/kbdb-api-wall-guard.sh 的 Bash 分支原本引用 +# hooks/kbdb_cmd_check.py(不存在的路徑),python3 找不到檔案就以非零結束, +# 而呼叫端寫成 `... || echo "OK"` —— 於是這個分支永遠印 "OK",Bash 分支形同虛設, +# 任何 `wrangler d1 execute` 直打 kbdb 都會被放行。修法:邏輯搬進 +# hooks/lib/kbdb_cmd_check.py(新檔),並把 .sh 裡的參照路徑改過去。 +# +# 判準:真的用 wrangler 對 kbdb 這顆 D1 下 execute 要擋;只是提到(commit message、 +# heredoc body、grep 搜尋)、或目標不是 kbdb 的 D1,都不准擋。 +cd "$(dirname "$0")/.." || exit 1 +H=hooks/kbdb-api-wall-guard.sh +PASS=0; FAIL=0 +run(){ # $1=want $2=cmd + printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$2")}}" \ + | bash "$H" >/dev/null 2>&1 + got=$? + if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi + printf 'want=%s got=%s %.72s\n' "$1" "$got" "$2" +} + +w="wrangler"; sub="d1 execute"; db="arcrun-kbdb" + +echo "── 該擋:真的直打 kbdb 這顆 D1 執行 SQL ──" +run 2 "$w $sub $db --remote --command \"SELECT 1\"" +run 2 "npx $w $sub $db --command \"SELECT 1\"" +run 2 "echo start && $w $sub $db --command \"SELECT 1\"" + +echo "── 不該擋:只是提到、heredoc body、目標不是 kbdb ──" +run 0 "git commit -m \"ran $w $sub $db earlier, see ticket\"" +run 0 "$(printf 'cat > note.md <<%sEOF%s\nwe should avoid %s %s %s\nEOF\n' "'" "'" "$w" "$sub" "$db")" +run 0 "grep -rn \"$w $sub\" hooks/" +run 0 "$w $sub some-other-db --command \"SELECT 1\"" +run 0 "$w deploy --env stage" +run 0 'echo hello world' +run 0 "$w $sub $db --command \"SELECT 1\" kbdb-sql-ok" + +echo +echo "$PASS/$((PASS+FAIL)) 通過" +[ "$FAIL" -eq 0 ] diff --git a/scripts/test-main-and-prod-push-guard.sh b/scripts/test-main-and-prod-push-guard.sh new file mode 100755 index 0000000..f0d58d6 --- /dev/null +++ b/scripts/test-main-and-prod-push-guard.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# main-and-prod-push-guard 的測試(inkstone/InkStoneCo#23、#56) +# 判準:真的推 main/master 要擋;只是提到(heredoc body、同一行引號內的散文、 +# 開頭包一層讀取指令)都不准擋;推自己的分支、含子字串但目標不是 main/master +# 的分支(如 fix/custom-domain-setup)也不准擋。 +cd "$(dirname "$0")/.." || exit 1 +H=hooks/main-and-prod-push-guard.sh +PASS=0; FAIL=0 +run(){ # $1=want $2=cmd + printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$2")}}" \ + | bash "$H" >/dev/null 2>&1 + got=$? + if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi + printf 'want=%s got=%s %.72s\n' "$1" "$got" "$2" +} + +echo "── 該擋:真的在推 main/master,且沒有戳記 ──" +run 2 'git push gitea HEAD:main' +run 2 'git push origin master' +run 2 'echo start && git push gitea HEAD:main' +run 2 'ls && git push gitea HEAD:main' + +echo "── 不該擋:只是提到、heredoc body、目標不是 main/master ──" +run 0 'git push gitea HEAD:fix/custom-domain-setup' +run 0 'git push gitea HEAD:fix/mainline-cleanup-later' +run 0 'git commit -m "docs: explain that the old gate would mis-flag push mentions"' +run 0 "$(printf 'cat > docs/TESTING.md <<%sEOF%s\nexample: git push https://github.com/example/example.git HEAD:main\nEOF\n' "'" "'")" +run 0 'python3 -c "requests.post(url, json={\"body\": \"this quotes: the gate cannot see a real git push done via a node subprocess, but it will block git push origin main mentioned as prose\"})"' +run 0 'grep -n "git push" hooks/main-and-prod-push-guard.sh' + +echo "── 真違規不能因為這次改動而漏擋:戳記過期/戳記綁錯 repo 一樣要擋 ──" +run 2 'git push gitea HEAD:main' # 沒有任何戳記檔時 + +echo +echo "$PASS/$((PASS+FAIL)) 通過" +[ "$FAIL" -eq 0 ] diff --git a/scripts/test-stage-before-prod-guard.sh b/scripts/test-stage-before-prod-guard.sh new file mode 100755 index 0000000..aef9645 --- /dev/null +++ b/scripts/test-stage-before-prod-guard.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# stage-before-prod-guard 的測試(inkstone/ISEP#30 群組——heredoc body 不算數) +# 判準:真的碰 prod 出貨鏈、沒有 stage 驗證紀錄要擋;讀取/查證/heredoc body 不准擋; +# 帶 staging 字樣、6 小時內驗過 stage 的要放行。 +cd "$(dirname "$0")/.." || exit 1 +H=hooks/stage-before-prod-guard.sh +PASS=0; FAIL=0 +run(){ # $1=want $2=cmd + printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$2")}}" \ + | bash "$H" >/dev/null 2>&1 + got=$? + if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi + printf 'want=%s got=%s %.72s\n' "$1" "$got" "$2" +} + +rm -f /tmp/.stage-verified /tmp/.stage-ok-by-leo + +echo "── 該擋:真的在動 prod 出貨鏈,沒有 stage 驗證紀錄 ──" +run 2 'wrangler deploy --name arcrun-rag-bundles-installer' +run 2 'git push gitea arcrun-rag-bundles-installer main' +run 2 'scripts/github-arm.sh "出貨" 30' +run 2 'echo go && scripts/github-arm.sh "出貨" 30' + +echo "── 不該擋:讀取/查證/演練 ──" +run 0 'sed -n "1,40p" scripts/github-arm.sh' +run 0 'git commit -m "照 scripts/github-arm.sh 解保險流程"' +run 0 'grep -n "wrangler deploy" hooks/stage-before-prod-guard.sh' +run 0 'BASE="https://cdn.jsdelivr.net/gh/x/arcrun-rag-bundles@abc"; curl -s "$BASE/manifest.json"' +run 0 'wrangler deploy --dry-run' +run 0 'wrangler deploy --env staging' + +echo "── 不該擋:heredoc body 只是提到 ──" +run 0 "$(printf 'cat > docs/TESTING.md <<%sEOF%s\nexample: wrangler deploy then scripts/github-arm.sh to ship arcrun-rag-bundles\nEOF\n' "'" "'")" + +echo "── 6 小時內驗過 stage,且 leo 已解 GitHub 保險 → 放行 ──" +# 條件 ① .github-armed 與條件 ② stage-verified 缺一不可,兩個都要造出來才測得到「放行」那條路 +touch .github-armed +date +%s > /tmp/.stage-verified +run 0 'wrangler deploy --name arcrun-rag-bundles-installer' +rm -f /tmp/.stage-verified .github-armed + +echo "── 只驗過 stage、沒解 GitHub 保險 → 還是要擋(條件缺一不可)──" +date +%s > /tmp/.stage-verified +run 2 'wrangler deploy --name arcrun-rag-bundles-installer' +rm -f /tmp/.stage-verified + +echo +echo "$PASS/$((PASS+FAIL)) 通過" +[ "$FAIL" -eq 0 ] -- 2.52.0