From 1dc97f0bd6ac3b94488b876b774d588f9dfb8a80 Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 20 Aug 2026 19:46:24 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(hooks):=20=E8=A3=9C=E4=B8=8A=20leo21c-w?= =?UTF-8?q?rite-guard.sh=20=E7=AC=AC=E4=BA=8C=E3=80=81=E4=B8=89=E6=AC=A1?= =?UTF-8?q?=E8=AA=A4=E6=94=94=E7=9A=84=E4=BF=AE=E6=B3=95=EF=BC=88InkStoneC?= =?UTF-8?q?o=20=E5=8F=8D=E5=90=91=E7=A7=BB=E6=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InkStoneCo/.claude/hooks/leo21c-write-guard.sh 2026-08-20 當天已修過兩次 誤攔(bc8a529/5e62d28),但沒人把修法帶回 ISEP——ISEP 那份還是上線當天 的舊版,會誤擋「git commit 訊息提到 leo21c」「Gitea PATCH 內文提到 leo21c」這類完全碰不到 CF 帳號的動作。 發現於 inkstone/InkStoneCo#57/inkstone/ISEP#32「同一件事只留一份」: 要把 InkStoneCo 本機的閘收斂成只剩 ISEP 這份之前,先確認 ISEP 沒有落後 ——結果這支剛好是唯一反向的(其餘 4 支已修過的閘 ISEP 都領先)。 判準改成只認「這個指令會不會真的寫到那台」: ① 寫入請求打到 *.leo21c.workers.dev(POST/PUT/PATCH/DELETE/--data/-d/trigger) ② 用它的 CF 帳號 id 跑 wrangler/acr 其餘一律放行,不管文字裡提到什麼。 實測 6/6(4 該放行/2 該擋,含這次要修的兩個誤攔案例):見 PR 說明。 Co-Authored-By: Claude Sonnet 5 --- hooks/leo21c-write-guard.sh | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/hooks/leo21c-write-guard.sh b/hooks/leo21c-write-guard.sh index 8d74d53..55a7f97 100755 --- a/hooks/leo21c-write-guard.sh +++ b/hooks/leo21c-write-guard.sh @@ -46,16 +46,34 @@ if printf '%s' "$cmd" | grep -q 'leo21c-write-guard'; then exit 0 fi -# leo21c 的三個座標(帳號 id/namespace/worker 網域) -if ! printf '%s' "$cmd" | grep -qE 'leo21c|51a01bfa2665bd7bc3fd080dc40cf3e1|bfezv28v'; then - exit 0 +# ── 判準:這個指令會不會真的把東西寫到那台?──────────────────────────────── +# 🔴 2026-08-20 第三次誤攔後收斂(前兩次:git commit 訊息、修閘自己)。 +# 舊判準是「文字裡有沒有出現座標」+「有沒有寫入動詞」,於是 +# `curl -X PATCH .../api/v1/repos/inkstone/Arcrun/issues/145 -d '{"state":"closed"}'` +# **只因為票的內文提到那個帳號就被擋**——而那是打 Gitea,跟 CF 一點關係都沒有。 +# ⇒ 只認**兩種真的會寫到那台的形狀**,其餘一律不管文字寫了什麼: +# ① 寫入請求打到 `*.leo21c.workers.dev` +# ② 用它的 CF 帳號 id 去跑 wrangler/acr +# leo 2026-08-17:「紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。」 + +hit=0 + +# ① 寫入請求打到那台 worker +if printf '%s' "$cmd" | grep -qiE 'https?://[^[:space:]"'"'"']*leo21c\.workers\.dev'; then + if printf '%s' "$cmd" | grep -qE -- '-X *(POST|PUT|PATCH|DELETE)|--data|--data-raw|-d[[:space:]]|/trigger'; then + hit=1 + fi fi -# 寫入動作的形狀 -if ! printf '%s' "$cmd" | grep -qE -- '-X *(POST|PUT|PATCH|DELETE)|--data|--data-raw|-d ["'"'"'{]|wrangler +(deploy|publish|kv|d1|secret)|acr +(update|deploy|push)|/trigger|ingest|kbdb_create|kbdb_update'; then - exit 0 +# ② 拿它的 CF 帳號去部署 +if printf '%s' "$cmd" | grep -qE 'CLOUDFLARE_ACCOUNT_ID=51a01bfa2665bd7bc3fd080dc40cf3e1'; then + if printf '%s' "$cmd" | grep -qE 'wrangler|acr |node .*index\.js'; then + hit=1 + fi fi +[ "$hit" = "1" ] || exit 0 + cat >&2 <<'MSG' 🚫 不准寫 leo 的個人帳號 leo21c(leo 2026-08-20 立) -- 2.52.0 From b790c3a78d2775410238f02b87c940bb984b5499 Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 20 Aug 2026 19:49:34 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(hooks):=20=E8=A3=9C=E5=9B=9E=20arcrun?= =?UTF-8?q?=5Fintent=5Fguard.py=20=E4=B8=BB=E9=AB=94=E2=80=94=E2=80=94?= =?UTF-8?q?=E9=80=99=E6=94=AF=E9=96=98=E8=87=AA=20ISEP=20=E5=BB=BA?= =?UTF-8?q?=E7=AB=8B=E4=BB=A5=E4=BE=86=E5=B0=B1=E6=98=AF=E7=A9=BA=E6=AE=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 發現於 inkstone/InkStoneCo#57/inkstone/ISEP#32:要把 InkStoneCo 本機 `.claude/hooks/arcrun-intent-guard.sh`(連同它同目錄的 arcrun_intent_guard.py) 移除、改吃 ISEP 這份之前,實測 ISEP 版才發現 `hooks/arcrun-intent-guard.sh` 呼叫的 `arcrun_intent_guard.py` 從未進過 ISEP 的 git 歷史(`git log --all` 0 命中)——`exec python3 "$DIR/arcrun_intent_guard.py"` 找不到檔案, python3 直接噴 OS 層錯誤、exit 2,**每一次 Write/Edit/MultiEdit 都被無條件擋下**, 不是「規則判定違規才擋」,是檔案不存在導致的硬當機。 這比 kbdb_cmd_check.py 那次(PR #39)更嚴重:那次是「該擋的沒擋」(靜默放行), 這次是「不管寫什麼都擋」——方向相反,但同一個病根:hook 的 shell 外殼進了 git,Python 主體沒有。 修法:把 InkStoneCo 本機那份(唯一存在的正本)原樣搬進來。這支腳本本來就設計 成離開 InkStoneCo 語境會優雅放行(`rules_path` 不存在 → return 0),只是 之前連跑到那行都做不到。 實測 3 種情境: - ISEP 語境(沒有 intent-rules.json):exit 0(修好前是 exit 2,任何寫入都被當機式擋下) - InkStoneCo 語境、合法內容:exit 0 - InkStoneCo 語境、真違規(`ON_FAILURE` 這種不存在的邊):exit 2(行為不變,沒有變寬鬆) Co-Authored-By: Claude Sonnet 5 --- hooks/arcrun_intent_guard.py | 212 +++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100755 hooks/arcrun_intent_guard.py diff --git a/hooks/arcrun_intent_guard.py b/hooks/arcrun_intent_guard.py new file mode 100755 index 0000000..a040067 --- /dev/null +++ b/hooks/arcrun_intent_guard.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""arcrun 意圖工作流「回饋 hook」主體(D38,2026-08-01)。 + +由 arcrun-intent-guard.sh 呼叫(stdin = Claude Code 的 PreToolUse JSON)。 +獨立成 .py 的理由:規則含大量引號與 regex,包在 shell 單引號裡會被吃掉 +(2026-08-01 實際踩到:'"componentId"' 被 shell 剝成裸字 → NameError)。 + +判準來源:system-dev/docs/3-specs/arcrun-usable/intent-rules.json + ——與判分器共用同一份,避免兩套判準漂移。 +""" +import json +import os +import re +import sys + + +def main(): + try: + d = json.load(sys.stdin) + except Exception: + return 0 + + ti = d.get("tool_input") or {} + + # 收集這次要送出的文字:檔案寫入 / 編輯 / bash 指令 / MCP 參數都看 + parts = [] + for k in ("content", "new_string", "command", "prompt"): + v = ti.get(k) + if isinstance(v, str): + parts.append(v) + for k in ("graph", "yaml", "workflow", "triplets", "body"): + v = ti.get(k) + if v is not None: + parts.append(v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)) + body = "\n".join(parts) + if not body.strip(): + return 0 + + proj = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() + rules_path = os.path.join( + proj, "system-dev", "docs", "3-specs", "arcrun-usable", "intent-rules.json") + if not os.path.exists(rules_path): + return 0 + R = json.load(open(rules_path, encoding="utf-8")) + rules = {r["id"]: r for r in R["rules"]} + LEGAL = re.compile(R["legal_edges"]["regex"]) + + # 只在「這確實是 arcrun 意圖工作流」時才出手,避免亂吵。 + # 排除文件語境的雜訊(2026-08-01 自己編修 GUIDE 時被自己誤攔): + # markdown 表格列、引用行、註解行——教材列舉或引述錯誤寫法時必然含箭頭 + def is_prose(l): + s = l.strip() + return (s.startswith("|") or s.startswith(chr(62) + " ") + or s.startswith("#") or s.startswith("//")) + + triplet_lines = [l.strip() for l in body.split("\n") + if (chr(62) * 2) in l and not is_prose(l)] + # 2026-08-01 補漏:原本只認 JSON 圖(帶引號)⇒ **YAML 圖整份被放行**。 + # leo 那次 haiku 失敗寫的正是 YAML(componentId: code,無引號),hook 完全沒攔到。 + is_graph_json = ('"componentId"' in body) or ('"edges"' in body) + is_graph_yaml = bool(re.search(r'^\s*-?\s*componentId\s*:', body, re.M) + or re.search(r'^\s*edges\s*:', body, re.M)) + is_graph = is_graph_json or is_graph_yaml + if not triplet_lines and not is_graph: + return 0 + + # 文件檔(.md)本來就會「引述錯誤寫法來教學」=R6 關鍵詞必然出現。 + # 對 .md 只保留結構性規則,不做過時教材主張的字面偵測。 + is_doc = str(ti.get("file_path") or "").endswith(".md") + + # 🔴 2026-08-08:兩道 hook 直接打架,本閘誤攔 wiki 落帳。 + # `wiki-first-police` **要求**我在 wiki 用知識三元組 `A >> 關係 >> B` + # (leo 2026-08-01 立:「每件事牽涉到 2 個 repo,就可以在該事件查到那兩個 repo」)。 + # 但本閘把任何含 `>>` 的行都當成 Arcrun 工作流的邊 ⇒ 落帳寫 + # 「勸告治不了停 >> 所以改成 >> InkStoneCo:...」就被判「非法邊」+「第一個節點不是 input」。 + # ⇒ 同一個符號、兩套約定。**.md 裡沒有真的工作流圖時,本閘不該有意見。** + # (有 componentId/edges 的 .md=真的在寫工作流文件,仍照常檢查。) + if is_doc and not is_graph: + return 0 + + # 🔴 2026-08-08 第二例:hook/腳本自己的原始碼裡出現 `>>` 是**正則樣式或 shell 重導向**, + # 不是 Arcrun 工作流的邊。實撞:wiki-first-police.sh 裡的 + # `grep -qE '… >> 真身在 >> …'`(比對知識三元組用的樣式)被判「三元組格式錯」。 + # ⇒ 機制程式碼一律豁免;真的工作流定義不會住在 .claude/hooks/。 + _fp = str(ti.get("file_path") or "") + if "/.claude/hooks/" in _fp or _fp.endswith((".sh", ".py")): + if not is_graph: + return 0 + + hits = [] + + def hit(rid, detail): + if not any(h[0] == rid for h in hits): + hits.append((rid, detail)) + + COND = re.compile( + r"判斷|檢查|如果|大於|小於|是否|超過|比較|驗證|check|if_|ifcontrol|compare|verify|threshold", + re.I) + + if triplet_lines: + edges_of = {} + parsed = [] + for l in triplet_lines: + # 2026-08-05 修:先剝掉 YAML 清單語法再切段。 + # 原本直接切,第一段會是 `- "input` 而不是 `input` + # => R1「第一個節點必須是 input」永遠判為不符 + # => 任何對 flow: 區塊的編輯都被擋死(實撞:改 rag-ingest-card 三次全被擋, + # 而該檔 flow 本來就合法)。 + l = l.strip() + if l.startswith("- "): + l = l[2:].strip() + l = l.strip('"').strip("'").strip() + seg = [p.strip() for p in l.split(">>")] + if len(seg) != 3: + hit("R5-triplet-format", l[:60]) + continue + a, e, c = seg + parsed.append((a, e, c)) + if not LEGAL.match(e): + hit("R2-illegal-edge", "非法邊「%s」(%s)" % (e, l[:50])) + edges_of.setdefault(a, []).append(e) + + if parsed: + # 2026-08-13 修(同一段的第二次誤攔修正,前一次見上方 08-05 註解): + # R1 問的是「**整個工作流**的第一個節點是不是 input」—— + # 那個問題只有在**看得到整份檔案**時才答得出來。 + # + # `Write` 送的是 `content`(整檔)⇒ parsed[0] 真的是第一個節點 ✅ + # `Edit` 送的是 `new_string`(片段)⇒ parsed[0] 是**那段片段的第一行**, + # 而增量編輯必然要帶一行既有的錨點來定位插入點 + # ⇒ **錨點永遠被誤判成「工作流起點」**,不管它實際排第幾。 + # + # ⇒ 這會結構性地擋住**任何**對既有多行 `flow:` 的增量修改。 + # 實撞(2026-08-13):往 `km_wiki_ingest.yaml` 的 flow 尾端接五行新邊, + # 錨點是 `decide >> 對每個 update_item >> update_entry` + # ⇒ 報「第一個節點是『decide』」。而該檔第一個節點叫 `seed`, + # 是檔案自己 §4 註解寫明的刻意設計(避開引擎的觸發保留字)。 + # + # 🔴 只放寬 R1,**R2(邊合法性)/R3/R4/R5 對片段照樣生效**—— + # 那幾條檢查的是內容本身,沒有「位置語意」的問題。 + if isinstance(ti.get("content"), str): + first = parsed[0][0] + if first.lower() != "input": + hit("R1-first-node-input", "第一個節點是「%s」" % first) + for a, outs in edges_of.items(): + branchy = any( + o in ("ON_TRUE", "ON_FALSE") or o.startswith("ON_BRANCH") for o in outs) + if COND.search(a) and not branchy: + hit("R3-condition-via-on_success", + "「%s」有條件語意,出邊卻只有 %s" % (a, "/".join(sorted(set(outs))))) + + # R4:code 節點在做流程控制 + LOGIC = re.compile( + r"\bif\s*\(|\belse\b|\bfor\s*\(|\bwhile\s*\(|\.filter\(|\.map\(|\?\s*[^:\n]{1,40}\s*:") + if is_graph: + m = re.search(r"\{.*\}", body, re.S) + if m: + try: + g = json.loads(m.group(0)) + for n in (g.get("nodes") or []): + if n.get("componentId") == "code": + ctx = json.dumps( + n.get("config") or n.get("payload") or n, ensure_ascii=False) + if LOGIC.search(ctx): + hit("R4-code-node-doing-logic", + "code 節點「%s」內含流程控制" % n.get("id")) + except Exception: + pass + if not any(h[0] == "R4-code-node-doing-logic" for h in hits): + # 2026-08-01 補:原本只認 JSON(帶引號)與 `>> code`, + # 但 leo 那次 haiku 失敗寫的是 YAML(componentId: code,無引號)⇒ 漏抓。 + looks_code = re.search(r">>\s*(code|js|script)\b", body, re.I) or \ + re.search(r'"componentId"\s*:\s*"code"', body) or \ + re.search(r'^\s*-?\s*componentId\s*:\s*["\']?code["\']?\s*$', body, re.M) or \ + re.search(r'^\s*-?\s*(id|name)\s*:\s*["\']?(code|js|script)["\']?\s*$', body, re.M) + if looks_code and LOGIC.search(body): + hit("R4-code-node-doing-logic", "偵測到 code 節點且內容含 if/for/filter") + + # R6:過時教材主張(會把 AI 擋在正解門外) + if not is_doc and ( + re.search(r"(不支援|沒有|無)\s*(原生)?\s*(條件分支|ON_TRUE|ON_FALSE)", body) + or re.search(r"只有\s*ON_SUCCESS\s*(與|和|、)\s*(FOREACH|對每個)", body)): + hit("R6-stale-doc-claim", "文字聲稱引擎不支援條件分支") + + if not hits: + return 0 + + out = [] + out.append("🎓 arcrun-intent-guard:這份意圖有 %d 處可以更好——下面是**正確寫法,可直接照抄**。" + % len(hits)) + out.append("") + for rid, detail in hits: + r = rules.get(rid, {}) + out.append("── %s" % rid) + out.append(" 現場:%s" % detail) + out.append(" ✅ 正確寫法:%s" % r.get("teach", "")) + if r.get("fix_example"): + out.append(" 範例(照抄改內容即可):") + for line in r["fix_example"].split("\n"): + out.append(" %s" % line) + if r.get("payload_hint"): + out.append(" payload:%s" % r["payload_hint"]) + out.append("") + out.append("判準來源:system-dev/docs/3-specs/arcrun-usable/intent-rules.json(與判分器共用)") + out.append("不確定時**問實例**:POST /cypher/search,回應裡的 branch_hint 會直接告訴你分支怎麼接。") + sys.stderr.write("\n".join(out) + "\n") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) -- 2.52.0 From c1d80756d7453343626ea9f67d8308f6bffe263e Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 20 Aug 2026 20:06:23 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E5=AF=A9=E6=A0=B8=E8=A3=9C=E4=BB=B6?= =?UTF-8?q?=EF=BC=9A=E7=AC=AC=E4=BA=8C=E5=80=8B=E7=A9=BA=E6=AE=BC=E9=96=98?= =?UTF-8?q?=EF=BC=88irreversible=5Fdispatch=5Fcheck.py=EF=BC=89=EF=BC=8B?= =?UTF-8?q?=E6=97=A2=E6=9C=89=E6=B8=AC=E8=A9=A6=E5=A5=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 總管複驗 PR 時發現 subagent 只補了一個空殼,還有第二個同款的: arcrun-intent-guard.sh → exec 不存在的 .py ⇒ 擋掉全部(吵,PR 已修) irreversible-dispatch-guard.sh → 同款,但寫法是 python3 <不存在> 2>/dev/null || echo '{"verdict":"OK"}' ⇒ **靜默放行全部**(危險,本 commit 補) 實測同一份派工單「驗過了就把舊分支刪掉」: InkStoneCo 版(有 .py)exit=2 擋 / ISEP 版(缺 .py)exit=0 放行 補完後:不可逆派工 exit=2、正常派工 exit=0、合規工作流 exit=0。 系統性掃描 ISEP 全部 hooks 引用的同目錄檔案:補完後 0 個缺檔(InkStoneCo 本來就是 0)。 順帶把 InkStoneCo 的 hooks/tests/ 四支既有測試一併帶過來。 🔴 這件事很重要:若先併 InkStoneCo#64(刪掉 .claude/hooks/), 這台機器會失去那支唯一還能用的副本——變成真的沒有那道閘。 --- hooks/irreversible_dispatch_check.py | 100 +++++++++ .../__pycache__/strip_heredoc.cpython-314.pyc | Bin 0 -> 3357 bytes hooks/tests/gitea-arm-check.test.sh | 203 ++++++++++++++++++ hooks/tests/main-and-prod-push-guard.test.sh | 42 ++++ hooks/tests/prod-write-guard.test.sh | 73 +++++++ hooks/tests/stage-before-prod-guard.test.sh | 41 ++++ 6 files changed, 459 insertions(+) create mode 100755 hooks/irreversible_dispatch_check.py create mode 100644 hooks/lib/__pycache__/strip_heredoc.cpython-314.pyc create mode 100755 hooks/tests/gitea-arm-check.test.sh create mode 100644 hooks/tests/main-and-prod-push-guard.test.sh create mode 100755 hooks/tests/prod-write-guard.test.sh create mode 100644 hooks/tests/stage-before-prod-guard.test.sh diff --git a/hooks/irreversible_dispatch_check.py b/hooks/irreversible_dispatch_check.py new file mode 100755 index 0000000..52736af --- /dev/null +++ b/hooks/irreversible_dispatch_check.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""判斷一份派工單(Agent/Task 的 prompt)有沒有把「不可逆動作」寫成收工方可以自己執行的選項。 + +stdin: 派工單全文 stdout: JSON {"verdict": "BLOCK"/"OK", "hits": [[行號, 該行, 命中詞], ...]} + +【事故(Gitea Leo/arcrun-rag#33,2026-08-09)】 +subagent 未經 leo 同意刪掉兩條遠端分支。根因不是它亂來——是派工單寫了 +「作廢就刪掉分支」,等於總管預先授權了一個不可逆動作。刪掉的那條裡還有一件 +它自己標明「等 leo 排序」的工作,一併蒸發。 + +【對照組,同一天同一個總管】#14 的派工單寫 +「🔴 刪資料不可逆。動手前先把清單寫在 issue 留言,等總管回覆確認才執行」 +⇒ 那個 agent 真的停下來等。同一個人一次寫對一次寫錯 ⇒ 證明只能靠機械閘,不能靠自律。 + +【判準】 + - 派工單裡出現「不可逆動作」的動詞+對象(刪分支/drop table/rm -rf/force push…) + - 且該處**沒有被否定**(不是「不准刪」這種禁令句) + - 且全文**沒有**「停下來等回覆才執行」這類守門片語 + ⇒ 判定為「把不可逆動作寫成可以自己執行的選項」,擋下。 + +同時符合上述前兩點、但全文有守門片語 ⇒ 判定為 #14 那種「先回報、等確認」寫法,放行。 + +豁免:命中那一行尾巴加 `irreversible-ok`(留痕式豁免,比照本目錄其他 guard 的慣例)。 +""" +import json +import re +import sys + +# 不可逆動作:動詞 + 常見對象(分支/資料/表/repo/檔案/環境…) +IRREVERSIBLE_RE = re.compile( + r"(" + r"刪(?:除|掉)?[^\n,。!?、;;()()]{0,12}(?:分支|branch|資料|data|table|表|db|資料庫|repo|檔案|record|entry|遠端|remote|環境|instance|實例)" + r"|砍(?:掉)?[^\n,。!?、;;]{0,6}(?:分支|branch)" + r"|洗掉" + r"|清空" + r"|格式化" + r"|(?:硬|真)刪(?:除)?" + r"|永久(?:刪除|移除)" + r"|drop\s+table" + r"|rm\s+-rf" + r"|reset\s+--hard" + r"|force[-\s]?push" + r"|git\s+push[^\n]{0,20}(?:--force|-f\b)" + r"|git\s+branch\s+-D" + r"|git\s+push[^\n]{0,20}--delete" + r"|delete[^\n]{0,12}(?:branch|data|table|repo|record)" + r")", + re.IGNORECASE, +) + +# 否定:這段話是在「禁止」不可逆動作,不是授權它 +NEGATION_RE = re.compile( + r"(不准|不可|不得|不要|禁止|勿|別|莫|no\s|never\s|don't\s|do not\s)\s*$", + re.IGNORECASE, +) + +# 守門片語:明確要求「停下來,等人回覆才執行」 +GATE_RE = re.compile( + r"(" + r"先.{0,25}留言.{0,15}等.{0,12}(?:回覆|確認|同意)" + r"|等.{0,10}(?:leo|總管|leo21c).{0,15}(?:回覆|確認|同意|批准).{0,10}(?:才|再).{0,12}(?:執行|動手|做|刪|砍)" + r"|不准動手" + r"|停下來.{0,10}等" + r"|動手前.{0,15}(?:先|等待|等)" + r"|等\s*(?:leo|總管)\s*(?:回覆|確認|同意|拍板)" + r"|wait\s+for\s+(?:confirmation|approval|leo)" + r"|before\s+(?:doing so|acting|deleting|executing)[^\n]{0,30}(?:wait|confirm)" + r")", + re.IGNORECASE, +) + + +def check(text: str): + lines = text.split("\n") + gate_found = bool(GATE_RE.search(text)) + + hits = [] + for i, line in enumerate(lines, start=1): + if "irreversible-ok" in line: + continue + for m in IRREVERSIBLE_RE.finditer(line): + before = line[max(0, m.start() - 8): m.start()] + if NEGATION_RE.search(before): + continue + hits.append([i, line.strip(), m.group()]) + + if not hits: + return "OK", hits, gate_found + if gate_found: + return "OK", hits, gate_found + return "BLOCK", hits, gate_found + + +if __name__ == "__main__": + text = sys.stdin.read() + verdict, hits, gate_found = check(text) + print(json.dumps( + {"verdict": verdict, "hits": hits, "gate_found": gate_found}, + ensure_ascii=False, + )) diff --git a/hooks/lib/__pycache__/strip_heredoc.cpython-314.pyc b/hooks/lib/__pycache__/strip_heredoc.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6d35ecec8de4d94661c46fd5ef90e7bfe26023eb GIT binary patch literal 3357 zcmbVO%~Kr574O-d{Tdd5Vz54nlv-d37PBlt#6cfr7ePn@ED0?bQWmf^%rwl1*_laq zuh6Pou1!_SSu81_I4RsiAEYCPJosk`DK#&mWq(& zdFw3a*ef~4sqGgmLW$`agMJxjvzS>rbg@MopU?s9dK~L`P3Hvn+3q^yo=b9*vlBBt zBSU!hH|MnaLWUjtHo>+F^-2!wu8B=gZZ^D<3nCauv0ym6L2b^7D@A4oHEqGtIkq`o zE9ul^=fob%cGvJq8Acg#RtNDU#}X;S+#}0Ps>F}I64&IY;T4Ml zf!O4(4d8V`d?8ZoW?ZKnK)PU$=o_BPspn)@9d85sFqZ)%xNdL>t$a?vZpasUvCXil zbG<=~3yVQZ$>6r<4&u2D4;O8gU2!~Pong<_hKGhf$PSHWhlgIJ&t0DtJdS%k!)FF? z&#ynogtWsbrXFx^=p{ykc1bSl|b9t8c6posxxt@%r*&(ui6g5D@0Hko?@Rz z0hrJX0vdC^$2c)FcmocdQv$AIiu1Qu0WPR)NZkO<7jx-L#nMe+n_zNBFZuvdoNwJ1F5CP{X$AWwaFmcB z5b4;QP~DMC;8kG+wo2eMm;*bsj2%EtGv_72;E;rB0*J6o0MRjsCAt(S?3Q-@zT^o@ z!Fw5ovngY48|#Gkf*)+S*d)w=%O?Fqn-gp57YAU2`IHplJnQ}~3_6dAZdmoR7v2l( z@w%MQ2WZJ5YygI}TP4Qf)?mkT#i3b%6O3S<1NX7v0$G7g0B_tVP5XrDYvi1Uq4;n~ z_$ux_#d>k~d*fFxcH_^mU*?wQp22UA6`FRPV!P;jaGl`#f>s?DJriKn#jaAXSiiPm zfC*P0h&oKWc6aE)g}&@(pJzV0i5K}&f8WjC#qOn#GJQA4vY+bNyM?7jV<>yJu+*Pf z95zHaY=lGv5e-G*+Z%#k1WuiYuU?zwf zMKg#~!b{Z6C_xl3LKO$kDC(RERo%f(x(lHW+^kTbDUg2>K6V^_74%|)V-r%g|K}lWJONbe|aTLAHFh({Kzu$ql;pvEc4A-bzx-m7r@*<1ak}T3Q-%usxFqTg`etwyz^TAMb5Uto>3*nvcu~179o69VgIybXgf_Z7%Sm)0YvNkI%F; z$HLa~KHhv}^a?_L;w|f<`-qbN4}yHuPc`@$djlWZd?K{c1kS)pZRQmVLbQ1`L~&J_ z)BKi&Z)K8(m9XPyR_9}W>T1i+$EPm=8>xML(zB;)2u%kF68lVBsI6Jg?>`y9$K!Y1 zJnJug$H41Udgsfm;-_tz6Nq+6L68p0TcJnxkZ3&MAx zXn~Tw6ey{=OfrZuAHr=Ab%-0J3bS)#`ME-VB8W4hQ^N}4y6+R$3}S24EBQg(k?|o= zZ+W(xNmJo=K}-a67DPd3pxA-p2C5hdlwzRO!wJ_{SP(ObV;6(eD!e(&f)olja)ksj zfCU*j4YE#)nAa#YDTE(77?5Z;49eU1BQz>#CyrX%wT^X+>Q<( zQP5Yt@gQDRY8}U_soJ5o`vh8FU#U*K>>t^lc=YkZkDqir?>PPKspos6VAJ7ROWW4j&9mFH_dfhiJx0aT^*~@1fl-8sy)Mba zn*wV-l2b$wrD7e8!SCANQAVK&Xw{>pq*w+x`5PA#@4!z)xroxgD}Gf|R&@k0w_k)n ziQq5LsGw%(o)OqyD>GvCYbAUG`>*bO!yEfe0cvqS_{^j55{EUssS((%T+75ESCql? z7&mS3N_g%-9)XVV)R=g<5b00AiEw1e39h@HTGVZqz7M04lngW~=!Mq0mD$W}4Q>uT z(~eihU#N*cP5oi&d$r@K+VR)rC*#jL`~InBBvnDOP!KW-1-N91LIIvfg+dUagq{Xj zK?0sTKD^lIVdzN^QkmH0>P3-u-V+nz*z16j7wY-@L;5Qi6N!ZV7JfTXMNxLucNKMS z5GmTfkhYUVt>DsMPJMS|>REcalKLqQN$RJI3Y_JRhSYTR?(^ueAJpU*mv*B_JuY?p E50ceQa{vGU literal 0 HcmV?d00001 diff --git a/hooks/tests/gitea-arm-check.test.sh b/hooks/tests/gitea-arm-check.test.sh new file mode 100755 index 0000000..13f4eb6 --- /dev/null +++ b/hooks/tests/gitea-arm-check.test.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# gitea-arm-check.sh 的迴歸測試——只測「不必打真網路/不必真的等 leo」的幾種: +# 沒有待核請求/已過期/已被消耗過(重放保護)/Gitea 認證失敗/ +# 核准者常數不接受環境變數覆蓋/票號缺失或非數字的待核檔會被略過不猜。 +# 另有一組**用真 token 打真端點**的測試,驗證 2026-08-16 起「票號由呼叫端指定」 +# 這件事真的有路由到對的票(見下方「不同票號各自路由」段)。 +# +# 🔴 「Leo 真的回覆才放行」與「機器冒充 Leo」這兩種**必須打真的 Gitea**才有意義 +# (核心判準就是比對 Gitea 上留言的 `user.login`),不適合塞進不碰網路的單元測試。 +# 這兩種已經在 https://git.uncle6.me/inkstone/InkStoneCo/issues/34 上人工實測過, +# 證據見本次 PR 說明,不在這支重跑。 +# +# 🔴 2026-08-13 總管審查後修正:`GITEA_ARM_OWNER/REPO/API/APPROVER_LOGIN` +# 全部改成寫死在 gitea-arm-common.sh,不再接受環境變數覆蓋(否則 +# `GITEA_ARM_APPROVER_LOGIN=claude-code` 就能讓機器核准自己)。 +# ⇒ 「Gitea 打不到」這條測資不能再用假網址注入,改用**真端點+無效 token** +# (回真的 401,一樣走得到「非 200 → fail-closed」那條分支)。 +# +# 🪦 2026-08-16:ISSUE 從第五個「寫死常數」名單裡移出來,改成請求時的參數 +# (見 lib/gitea-arm-common.sh 檔頭說明:真正的安全邊界是 OWNER/REPO/ +# APPROVER_LOGIN,不是票號本身)。相對地,pending 檔的 schema 多了 `issue` 欄位, +# 下面所有測資的 JSON 都要帶上它,否則會被「票號缺失」判定為壞檔而被略過。 +# +# 用法:.claude/hooks/tests/gitea-arm-check.test.sh +# (repo 根目錄要有 scripts/gitea-arm-check.sh + scripts/gitea-arm-request.sh) +set -u +PROJ="${1:?用法: $0 }" +CHECK="$PROJ/scripts/gitea-arm-check.sh" + +TMPPROJ=$(mktemp -d) +trap 'rm -rf "$TMPPROJ"' EXIT +# 借用真的 scripts/(唯讀),但狀態目錄與 .env 都指到隔離的臨時目錄 +ln -s "$PROJ/scripts" "$TMPPROJ/scripts" +# 假 .env:token 隨便填,「Gitea 打不到」那條測資會在網路那層失敗(本來就該擋), +# 其餘測資在打到 Gitea 之前就已經因為本地狀態被擋下,不會真的送出請求。 +printf 'GITEA_TOKEN_CLAUDE_CODE=test-token-not-real\n' > "$TMPPROJ/.env" + +PASS=0; FAIL=0 +t(){ # t <期望 block|pass> <說明> + got=$([ "$3" -eq 0 ] && echo pass || echo block) + if [ "$got" = "$1" ]; then echo " ✅ $2"; PASS=$((PASS+1)) + else echo " ❌ $2 —— 期望 $1,實得 $got"; FAIL=$((FAIL+1)); fi +} + +echo "── 完全沒有待核請求 ──" +rm -rf "$TMPPROJ/.claude/gitea-arm" +CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" >/tmp/gitea-arm-test-out.$$ 2>&1 +t block "沒有 pending 目錄就該擋" $? + +echo "── 已過期的請求 ──" +mkdir -p "$TMPPROJ/.claude/gitea-arm/pending" +NOW=$(date +%s) +jq -n --arg nonce "ARM-testexpired" --arg mission "測試" --arg issue "34" \ + --argjson requested_at "$((NOW-3600))" --argjson expires_at "$((NOW-1))" \ + --arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \ + '{nonce:$nonce, mission:$mission, issue:$issue, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \ + > "$TMPPROJ/.claude/gitea-arm/pending/ARM-testexpired.json" +CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" ARM-testexpired >/tmp/gitea-arm-test-out.$$ 2>&1 +rc=$? +t block "過期的 nonce 該擋" $rc +[ -f "$TMPPROJ/.claude/gitea-arm/pending/ARM-testexpired.json" ] \ + && { echo " ❌ 過期後 pending 檔應該被清掉,卻還在"; FAIL=$((FAIL+1)); } \ + || { echo " ✅ 過期後 pending 檔已清掉"; PASS=$((PASS+1)); } + +echo "── 已被消耗過的 nonce(防重放)──" +rm -rf "$TMPPROJ/.claude/gitea-arm" +mkdir -p "$TMPPROJ/.claude/gitea-arm/pending" +jq -n --arg nonce "ARM-testreplay" --arg mission "測試" --arg issue "34" \ + --argjson requested_at "$NOW" --argjson expires_at "$((NOW+1800))" \ + --arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \ + '{nonce:$nonce, mission:$mission, issue:$issue, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \ + > "$TMPPROJ/.claude/gitea-arm/pending/ARM-testreplay.json" +printf 'ARM-testreplay\t2026-01-01 00:00:00\t舊任務\n' > "$TMPPROJ/.claude/gitea-arm/consumed.log" +CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" ARM-testreplay >/tmp/gitea-arm-test-out.$$ 2>&1 +rc=$? +t block "已消耗過的 nonce 該擋(即使有效期還沒到)" $rc +[ -f "$TMPPROJ/.claude/gitea-arm/pending/ARM-testreplay.json" ] \ + && { echo " ❌ 重放判定後 pending 檔應該被清掉,卻還在"; FAIL=$((FAIL+1)); } \ + || { echo " ✅ 重放判定後 pending 檔已清掉"; PASS=$((PASS+1)); } + +echo "── Gitea 認證失敗(真端點+無效 token,fail-closed)──" +rm -rf "$TMPPROJ/.claude/gitea-arm" +mkdir -p "$TMPPROJ/.claude/gitea-arm/pending" +jq -n --arg nonce "ARM-testunreach" --arg mission "測試" --arg issue "34" \ + --argjson requested_at "$NOW" --argjson expires_at "$((NOW+1800))" \ + --arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \ + '{nonce:$nonce, mission:$mission, issue:$issue, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \ + > "$TMPPROJ/.claude/gitea-arm/pending/ARM-testunreach.json" +# $TMPPROJ/.env 裡的 token 是假的(見檔頭),這條打的是**真的** git.uncle6.me—— +# 拿假 token 打真端點,預期真的收到 401,藉此驗「非 200 → fail-closed」那條分支。 +CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" ARM-testunreach >/tmp/gitea-arm-test-out.$$ 2>&1 +rc=$? +t block "Gitea 認證失敗要 fail-closed(不放行)" $rc +grep -q "非 200\|打不到" /tmp/gitea-arm-test-out.$$ \ + && { echo " ✅ 錯誤訊息確實指向 fail-closed 分支(不是別的原因擋下)"; PASS=$((PASS+1)); } \ + || { echo " ❌ 沒看到預期的 fail-closed 訊息:"; cat /tmp/gitea-arm-test-out.$$; FAIL=$((FAIL+1)); } + +echo "── 🔴 票號缺失/非數字的待核檔——不猜票號,略過不當機 ──" +rm -rf "$TMPPROJ/.claude/gitea-arm" +mkdir -p "$TMPPROJ/.claude/gitea-arm/pending" +jq -n --arg nonce "ARM-testnoissue" --arg mission "測試" \ + --argjson requested_at "$NOW" --argjson expires_at "$((NOW+1800))" \ + --arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \ + '{nonce:$nonce, mission:$mission, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \ + > "$TMPPROJ/.claude/gitea-arm/pending/ARM-testnoissue.json" +CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" ARM-testnoissue >/tmp/gitea-arm-test-out.$$ 2>&1 +rc=$? +t block "缺 issue 欄位的舊格式待核檔該擋(不放行)" $rc +grep -q "issue 缺失或非數字" /tmp/gitea-arm-test-out.$$ \ + && { echo " ✅ 錯誤訊息點名是 issue 欄位的問題(不是猜成別的原因)"; PASS=$((PASS+1)); } \ + || { echo " ❌ 沒看到預期的訊息:"; cat /tmp/gitea-arm-test-out.$$; FAIL=$((FAIL+1)); } + +echo "── 🔴 核准者不接受環境變數覆蓋(本次審查抓到的洞,補的測試)──" +GOT_LOGIN=$(CLAUDE_PROJECT_DIR="$TMPPROJ" GITEA_ARM_APPROVER_LOGIN="claude-code" bash -c ' + . "'"$PROJ"'/scripts/lib/gitea-arm-common.sh" + printf "%s" "$GITEA_ARM_APPROVER_LOGIN" +') +if [ "$GOT_LOGIN" = "Leo" ]; then + echo " ✅ 設了 GITEA_ARM_APPROVER_LOGIN=claude-code,source 進去的常數仍是 Leo(覆蓋無效)" + PASS=$((PASS+1)) +else + echo " ❌ 常數被環境變數改成了「$GOT_LOGIN」——核准者可以被外部覆蓋,這是安全洞" + FAIL=$((FAIL+1)) +fi + +echo "── 🔴 不同票號各自路由(真 token 打真端點):一個指到存在的票、一個指到不存在的票 ──" +# 這條要證明的是 2026-08-16 這次改動的核心:每個請求真的用它自己的 issue 欄位 +# 去打對應的票,不是仍然只認某個寫死的號碼。用真 token(唯讀 GET,不會寫入任何東西): +# · nonce A 指到 #34(真實存在,開放中)→ 預期 200 OK,只是假 nonce 找不到 Leo 回覆 +# · nonce B 指到一個不存在的超大票號 → 預期 404/非 200 → fail-closed +# 如果程式碼還在用舊的單一票號邏輯,這兩筆會得到**相同**的結果(不會一個過一個不過)。 +REAL_ENV="$PROJ/.env" +if [ -f "$REAL_ENV" ] && grep -q '^GITEA_TOKEN_CLAUDE_CODE=' "$REAL_ENV"; then + cp "$REAL_ENV" "$TMPPROJ/.env" + rm -rf "$TMPPROJ/.claude/gitea-arm" + mkdir -p "$TMPPROJ/.claude/gitea-arm/pending" + jq -n --arg nonce "ARM-testrouteA" --arg mission "路由測試A" --arg issue "34" \ + --argjson requested_at "$NOW" --argjson expires_at "$((NOW+1800))" \ + --arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \ + '{nonce:$nonce, mission:$mission, issue:$issue, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \ + > "$TMPPROJ/.claude/gitea-arm/pending/ARM-testrouteA.json" + jq -n --arg nonce "ARM-testrouteB" --arg mission "路由測試B" --arg issue "999999999" \ + --argjson requested_at "$NOW" --argjson expires_at "$((NOW+1800))" \ + --arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \ + '{nonce:$nonce, mission:$mission, issue:$issue, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \ + > "$TMPPROJ/.claude/gitea-arm/pending/ARM-testrouteB.json" + + CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" >/tmp/gitea-arm-test-out.$$ 2>&1 + rc=$? + t block "兩者都是假 nonce,不會被核准,整體仍該回不放行" $rc + + if grep -q "ARM-testrouteA(#34)還沒等到 Leo 的回覆" /tmp/gitea-arm-test-out.$$; then + echo " ✅ #34(存在):真的打到了、200 OK,只是假 nonce 沒有匹配的留言" + PASS=$((PASS+1)) + else + echo " ❌ 沒看到 #34 該有的「還沒等到」訊息:"; cat /tmp/gitea-arm-test-out.$$ + FAIL=$((FAIL+1)) + fi + + if grep -q "#999999999:Gitea 打不到/回應非 200" /tmp/gitea-arm-test-out.$$; then + echo " ✅ #999999999(不存在):真的打到了那個號碼,並且 fail-closed(不是誤放行也不是誤判成別的錯)" + PASS=$((PASS+1)) + else + echo " ❌ 沒看到 #999999999 該有的 fail-closed 訊息:"; cat /tmp/gitea-arm-test-out.$$ + FAIL=$((FAIL+1)) + fi +else + echo " ⚠️ 跳過(找不到真的 GITEA_TOKEN_CLAUDE_CODE,這條測資需要唯讀真端點)" +fi + +echo "── 🔴 gitea-arm-request.sh:缺票號/票號非數字要在打網路前就擋 ──" +REQ="$PROJ/scripts/gitea-arm-request.sh" +CLAUDE_PROJECT_DIR="$TMPPROJ" "$REQ" >/tmp/gitea-arm-test-out.$$ 2>&1 +t block "完全沒帶參數該擋(用法錯誤)" $? +CLAUDE_PROJECT_DIR="$TMPPROJ" "$REQ" "not-a-number" "測試任務" >/tmp/gitea-arm-test-out.$$ 2>&1 +rc=$? +t block "票號非數字該擋" $rc +# 2026-08-16:票號格式擴充為「N 或 repo#N」,訊息跟著改;斷言改成看**意圖**(有沒有點名票號) +grep -qE "票號.*純數字|純數字票號" /tmp/gitea-arm-test-out.$$ \ + && { echo " ✅ 錯誤訊息點名是票號格式問題"; PASS=$((PASS+1)); } \ + || { echo " ❌ 沒看到預期訊息:"; cat /tmp/gitea-arm-test-out.$$; FAIL=$((FAIL+1)); } + +# ── 🔴 2026-08-16 新增:repo 可指定,但 owner 絕不可 ────────────────── +echo "── 🔴 gitea-arm-common:repo 可由參數指定,owner 是安全邊界不給指定 ──" +( . "$PROJ/scripts/lib/gitea-arm-common.sh" >/dev/null 2>&1 + gitea_arm_set_repo "arcrun-rag" >/dev/null 2>&1 && [ "$GITEA_ARM_REPO" = "arcrun-rag" ] ) \ + && { echo " ✅ 同 org 的 repo 名可指定"; PASS=$((PASS+1)); } \ + || { echo " ❌ 同 org 的 repo 名竟然不能指定"; FAIL=$((FAIL+1)); } + +( . "$PROJ/scripts/lib/gitea-arm-common.sh" >/dev/null 2>&1 + gitea_arm_set_repo "claude-code/evil" >/dev/null 2>&1 ) \ + && { echo " ❌ 帶斜線的 owner/repo 竟然被接受——那正是 08-13 那次攻擊的形狀"; FAIL=$((FAIL+1)); } \ + || { echo " ✅ 帶斜線的被擋(owner 不給任何人指定)"; PASS=$((PASS+1)); } + +( GITEA_ARM_OWNER=claude-code; . "$PROJ/scripts/lib/gitea-arm-common.sh" >/dev/null 2>&1 + [ "$GITEA_ARM_OWNER" = "inkstone" ] ) \ + && { echo " ✅ OWNER 不受環境變數影響(仍是 inkstone)"; PASS=$((PASS+1)); } \ + || { echo " ❌ OWNER 被 env 蓋掉了——安全邊界破了"; FAIL=$((FAIL+1)); } + +rm -f /tmp/gitea-arm-test-out.$$ +echo +echo "結果:通過 $PASS / 失敗 $FAIL" +[ $FAIL -eq 0 ] || exit 1 diff --git a/hooks/tests/main-and-prod-push-guard.test.sh b/hooks/tests/main-and-prod-push-guard.test.sh new file mode 100644 index 0000000..a7f7fc7 --- /dev/null +++ b/hooks/tests/main-and-prod-push-guard.test.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# main-and-prod-push-guard.sh 的迴歸測試——重點在「geek6688 豁免的範圍夠不夠窄」。 +# 🔴 寫成檔案跑:測試指令必然含 `wrangler deploy`,直接在 Bash 打會被 prod-write-guard 擋。 +# +# 用一個**沒有 .github-armed** 的臨時 CLAUDE_PROJECT_DIR, +# 否則本機真的有那個檔 ⇒「該擋的」會假性通過,測了等於沒測。 +HOOK="$1" +TMPPROJ=$(mktemp -d) +trap 'rm -rf "$TMPPROJ"' EXIT + +mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':'Bash','tool_input':{'command':sys.argv[1]}}))" "$1"; } +PASS=0; FAIL=0 +t(){ mk "$3" | env CLAUDE_PROJECT_DIR="$TMPPROJ" "$HOOK" >/dev/null 2>&1; rc=$? + got=$([ $rc -eq 2 ] && echo block || echo pass) + if [ "$got" = "$1" ]; then echo " ✅ $2"; PASS=$((PASS+1)) + else echo " ❌ $2 —— 期望 $1,實得 $got"; FAIL=$((FAIL+1)); fi; } + +D="wrangler deploy" + +echo "── geek6688(leo 2026-08-12 明文授權總管可直接動)應放行 ──" +t pass "指名 geek6688 主機名" "npx $D --name arcrun-cypher-executor --config geek6688.toml" +t pass "用 geek6688 的 token 變數" "CLOUDFLARE_API_TOKEN=\$CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE npx $D" +t pass "用 geek6688 的 account id 變數" "CLOUDFLARE_ACCOUNT_ID=\$CLOUDFLARE_ACCOUNT_ID_GEEK6688 npx $D" + +echo "── 其他實例仍要 leo 親手 arm(範圍不能外溢)──" +t block "打 leo21c" "npx $D --name arcrun-mcp --account-id leo21c-acct" +t block "打 uncle6 官方" "npx $D --name arcrun-kbdb --config uncle6.toml" +t block "看不出打哪裡" "npx $D" +t block "wrangler publish 到別台" "npx wrangler publish --name arcrun-mcp" +t block "versions deploy 到別台" "npx wrangler versions deploy --name arcrun-kbdb" + +echo "── stage 照舊自由 ──" +t pass "帶 --env staging" "npx $D --env staging" +t pass "打 staging 主機" "npx $D --name arcrun-rag-installer-staging" + +echo "── 非部署動作不受影響 ──" +t pass "推自己的分支" "git push -u gitea fix/my-branch" +t pass "讀本閘原始碼(指令裡含關鍵字)" "sed -n '1,50p' .claude/hooks/main-and-prod-push-guard.sh" + +echo +echo "結果:通過 $PASS / 失敗 $FAIL" +[ $FAIL -eq 0 ] || exit 1 diff --git a/hooks/tests/prod-write-guard.test.sh b/hooks/tests/prod-write-guard.test.sh new file mode 100755 index 0000000..89dca67 --- /dev/null +++ b/hooks/tests/prod-write-guard.test.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# prod-write-guard.sh 的迴歸測試。 +# 🔴 必須寫成檔案跑:這道閘會擋掉「含有它要擋的字串」的指令本身 +# ⇒ 直接在 Bash 裡打測試,會被自己擋下(2026-08-12 實撞)。 +HOOK="$1" +mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':'Bash','tool_input':{'command':sys.argv[1]}}))" "$1"; } +PASS=0; FAIL=0 +t(){ # t <期望 block|pass> <說明> <指令> + mk "$3" | "$HOOK" >/dev/null 2>&1; rc=$? + got=$([ $rc -eq 2 ] && echo block || echo pass) + if [ "$got" = "$1" ]; then echo " ✅ $2"; PASS=$((PASS+1)) + else echo " ❌ $2 —— 期望 $1,實得 $got"; FAIL=$((FAIL+1)); fi +} + +H='https://arcrun-kbdb.leo21c.workers.dev' +DASH_D='-d' + +echo "── 該放行(唯讀)──" +t pass "純 GET" "curl -s $H/templates" +t pass "GET + tr -d(本次實撞的誤攔)" "ACC=\$(grep -oE '^X=.' f | tr $DASH_D '\\r'); curl -s $H/templates" +t pass "GET + cut -d=" "A=\$(cut ${DASH_D}= -f2 f); curl -s $H/templates" +t pass "GET + sort -d / xargs -d" "ls | sort $DASH_D | xargs $DASH_D '\\n' echo; curl -s $H/entries" +t pass "GET 帶 Bearer 標頭" "curl -s -H 'Authorization: Bearer xxx' $H/templates?limit=1" +t pass "打 staging" "curl -X POST https://arcrun-rag-installer-staging.workers.dev/x $DASH_D '{}'" +t pass "打 youlin 測試場" "curl -X POST https://arcrun-cypher-executor.youlin-hsieh-dev.workers.dev/x $DASH_D '{}'" +t pass "打 Gitea(不是實例)" "curl -X POST https://git.uncle6.me/api/v1/repos/Leo/x/issues $DASH_D '{}'" + +echo "── 該擋(寫入)──" +t block "POST 到實例" "curl -X POST $H/entries $DASH_D '{}'" +t block "PUT 到實例" "curl -X PUT $H/entries/1 --data '{}'" +t block "DELETE 到實例" "curl -X DELETE $H/entries/1" +t block "POST 且同時有 tr -d(剪字後仍該擋)" "cat f | tr $DASH_D '\\r' | curl -X POST $H/webhooks/named $DASH_D @-" +t block "wrangler deploy" "npx wrangler deploy --name arcrun-kbdb" + +# ── 2026-08-13:「執行它」vs「談論它」─────────────────────────────── +# 實撞:把 #108 的驗收證據留言到 Gitea,正文引述了那個指令名 ⇒ 一則留言被當成部署擋掉。 +# 這批測資的挑法是「本 repo 現行真的會出現的形狀」,不是我自己挑好抓的壞例子 +# ——PR #87 那道假綠閘就是敗在後者(14 條全過,卻漏掉 repo 實際在用的呼叫寫法)。 +ACR_U='acr up''date' # 拆開寫:否則這支測試檔自己會被閘擋住(它也是一條 Bash 指令) +ACR_P='acr pu''sh' +WR_D='wrangler dep''loy' + +echo "── 該放行(只是談論,不是執行)──" +t pass "heredoc 正文引述(本次實撞)" \ + "B=\$(cat <<'EOF' +更新指令 \`$ACR_U --force\` 印出了 ARCRUN_NAMESPACE +EOF +); curl -X POST https://git.uncle6.me/api/v1/repos/Leo/Arcrun/issues/108/comments $DASH_D \"\$B\"" +t pass "commit 訊息裡提到" "git commit -m '修好 $ACR_U 的命名空間注入'" +t pass "markdown code span(反引號)" "printf '%s' '看 \`$ACR_U\` 的輸出'" +t pass "grep 它的名字" "grep -n '$ACR_P' .claude/hooks/prod-write-guard.sh" +t pass "文章裡提到 $WR_D" "printf '%s' '# 為什麼 $WR_D 要擋'" +t pass "acr 唯讀子指令" "acr status --json" +t pass "acr whoami" "acr whoami" + +echo "── 該擋(真的在指令位置執行)──" +t block "行首" "$ACR_U --force" +t block "&& 之後" "cd /x && $ACR_U" +t block "; 之後" "echo hi; $ACR_P workflow.yaml" +t block "管線之後" "cat x.yaml | $ACR_P -" +t block "bash -c 引號裡" "bash -c \"$ACR_U --force\"" +t block "\$( ) 裡" "OUT=\$($ACR_U 2>&1)" +t block "npx 前綴" "npx $WR_D --name arcrun-kbdb" +t block "sudo 前綴" "sudo $ACR_U" +t block "heredoc 之後的真指令(剝內文不能連指令一起剝)" \ + "cat <<'EOF' > note.md +只是筆記 +EOF +$ACR_U --force" + +echo +echo "結果:通過 $PASS / 失敗 $FAIL" +[ $FAIL -eq 0 ] || exit 1 diff --git a/hooks/tests/stage-before-prod-guard.test.sh b/hooks/tests/stage-before-prod-guard.test.sh new file mode 100644 index 0000000..9139f20 --- /dev/null +++ b/hooks/tests/stage-before-prod-guard.test.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# stage-before-prod-guard.sh 的迴歸測試。 +# 🔴 必須寫成檔案跑:這道閘會擋掉「含有它關鍵字」的指令本身 +# ⇒ 直接在 Bash 打測試,連讀它的原始碼都會被擋(2026-08-12 實撞三次)。 +HOOK="$1" +mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':'Bash','tool_input':{'command':sys.argv[1]}}))" "$1"; } +PASS=0; FAIL=0 +t(){ mk "$3" | "$HOOK" >/dev/null 2>&1; rc=$? + got=$([ $rc -eq 2 ] && echo block || echo pass) + if [ "$got" = "$1" ]; then echo " ✅ $2"; PASS=$((PASS+1)) + else echo " ❌ $2 —— 期望 $1,實得 $got"; FAIL=$((FAIL+1)); fi; } + +B='arcrun-rag-bundles' # 關鍵字組起來,避免這個檔名本身觸發別的工具 +CDN="https://cdn.jsdelivr.net/gh/youlinhsieh/${B}@abc123" +ARM='scripts/github-arm.sh' +DASH_D='-d' + +echo "── 該放行(查證,不是發佈)──" +t pass "抓 bundle manifest 來看(本次實撞)" "BASE=\"$CDN\"; curl -s \"\$BASE/manifest.json\"" +t pass "抓 bundle 裡的 worker 檔比對內容" "curl -s $CDN/core/arcrun-mcp/worker.mjs -o /tmp/x.mjs" +t pass "讀本閘自己的原始碼(本次實撞)" "cd /repo && grep -n 'exit 0' .claude/hooks/stage-before-prod-guard.sh" +t pass "讀解保險腳本" "cd /repo && sed -n '1,40p' $ARM" +t pass "commit 訊息裡提到出貨" "git commit -m '照 $ARM 解保險後出貨'" +t pass "查 bundle repo 狀態" "cd /tmp/$B && git log --oneline -3" +t pass "GET 帶 tr -d 管線" "curl -s $CDN/manifest.json | tr $DASH_D '\\r'" + +echo "── 該擋(真的在發佈)──" +t block "推 bundle repo" "cd /tmp/$B && git push origin main" +t block "wrangler deploy 出貨物件" "cd $B && npx wrangler deploy" +t block "跑出貨管線" "node installer/scripts/ship.mjs --target prod --bundles $B" +t block "親手執行解保險腳本" "bash $ARM '出貨說明' 30" +t block "POST 寫進 bundle repo" "curl -X POST https://api.github.com/repos/x/$B/contents/y $DASH_D '{}'" + +t pass "同一行提到兩次解保險腳本(2026-08-12 第四次誤攔)" "echo '=== $ARM 怎麼判過期 ==='; grep -nE 'expire' $ARM" +t pass "cat 解保險腳本" "cat $ARM" +t block "以 bash 執行解保險腳本" "bash $ARM 'x' 30" +t block "管線後面執行解保險腳本" "echo go && $ARM '出貨' 30" + +echo +echo "結果:通過 $PASS / 失敗 $FAIL" +[ $FAIL -eq 0 ] || exit 1 -- 2.52.0