From 7de1ad6be68c183f1148c31d5c94becb52c82005 Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 27 Aug 2026 21:33:32 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A3=9C=E4=B8=8A=E3=80=8C=E7=94=A8=E9=8C=AF?= =?UTF-8?q?=E7=9A=84=E8=B7=AF=E5=8E=BB=E8=AD=89=E6=98=8E=E4=B8=80=E4=BB=B6?= =?UTF-8?q?=E4=BA=8B=E3=80=8D=E9=82=A3=E4=B8=80=E6=A0=BC=E7=9A=84=E9=96=98?= =?UTF-8?q?=EF=BC=88inkstone/ISEP#30=20=E2=86=92=20comment=204879=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 擋的是**證據的出處**,不是措辭。 判準(兩個條件同時成立才響): ① 要送出去的那份東西(票上的留言/派工單)裡,貼了一個值 ——entry id 或 `kb://` 來源位址——而它這個 session 只在 `kbdb_search` 的回應裡出現過 ② 這個 session 從沒用產品檢索路徑(graph/wiki 內容/query)拿過那筆 為什麼不比對措辭:驗收條件第 4 條寫死不准,而 leo 2026-08-17 已經證偽過 文字層——那天 8 次誤攔、0 次正確攔截,且方向穩定:紅線寫得越細,命中 關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。值跟動作一樣有限且可枚舉,措辭不是。 🔴 `kbdb_search` 一點都沒有變難用:查 wiki、找 record_id、看某筆在不在, 全部照放。它只在「把搜尋輸出貼出去當產品檢索壞掉的證據」那一刻才響。 實測 31 條,A 群(不該擋)11 條、B 群(該擋)5 條、C 群訊息 6 條、 D 群登記處 9 條。真跡重演=inkstone/Arcrun#167 comment 4865 那則退回, 原文照貼會被擋;走過一次真路徑之後同一份留言就放行。 Co-Authored-By: Claude Opus 5 --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 4 +- docs/hooks-inventory.md | 9 +- hooks/hooks.json | 21 ++ hooks/kbdb-evidence-stamp.sh | 143 ++++++++++ hooks/search-is-not-proof-guard.sh | 198 ++++++++++++++ hooks/tests/search-is-not-proof-guard.test.sh | 254 ++++++++++++++++++ 7 files changed, 625 insertions(+), 6 deletions(-) create mode 100755 hooks/kbdb-evidence-stamp.sh create mode 100755 hooks/search-is-not-proof-guard.sh create mode 100755 hooks/tests/search-is-not-proof-guard.test.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c294200..300c5df 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "plugins": [ { "name": "isep", - "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:46 支機械閘(56 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", + "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:51 支機械閘(64 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", "author": { "name": "Leo", "url": "https://uncle6.me" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 319e0c5..0b7a220 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "isep", - "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:49 支機械閘(60 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", - "version": "0.8.0", + "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:51 支機械閘(64 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", + "version": "0.9.0", "keywords": [ "inkstone", "guardrails", diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 7e28048..198d891 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -1,4 +1,4 @@ -# 49 支閘,白話盤點表 +# 51 支閘,白話盤點表 > 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」 > 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。 @@ -7,14 +7,14 @@ ## 一句話結論 -`hooks/` 底下有 **49 個 `.sh` 檔**,`hooks.json` 實際掛上 **60 條註冊**(同一支閘常被多種情境同時掛上); +`hooks/` 底下有 **51 個 `.sh` 檔**,`hooks.json` 實際掛上 **64 條註冊**(同一支閘常被多種情境同時掛上); 其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。 下面按「你會在什麼時候撞到它」分組,一支一行。 > 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」, > 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。 > 現在的寫法是實際數出來的: -> `ls hooks/*.sh | wc -l` = 49;`grep -c '"command":' hooks/hooks.json` = 60。 +> `ls hooks/*.sh | wc -l` = 51;`grep -c '"command":' hooks/hooks.json` = 64。 > ⚠️ **冒號不能省**:`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **120**。 > 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。 > **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。 @@ -63,6 +63,7 @@ | `milestone-due-guard.sh` | AI 想開一個新的里程碑(milestone)卻沒填期限就擋下,**填 `9999-01-01` 一樣擋**——那是「沒有期限」穿了一件期限的衣服,盤點時每一格看起來都有值,其實一格都沒有。只管「建里程碑」這個動作,查詢/改別的欄位都放行。 | 🛑 擋 | | `reply-identity-guard.sh` | 直接打 Gitea API 貼留言到票上時,內文第一行沒有 `【身份】`(`總管`/`subagent`/`leo`)就擋下——票上多條線並行時,看不出哪一則是誰寫的。純讀取、走 `scripts/ticket` 正門、指令裡加 `reply-identity-ok` 都放行。 | 🛑 擋 | | `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。 | 🛑 擋(同一輪只擋一次) | +| `search-is-not-proof-guard.sh` | AI 用知識庫的**搜尋**找到一筆東西,然後把那筆東西的原始編號直接貼進票裡或派工單裡,當成「某個功能壞了」的證據——而它從沒用正規的檢索路徑去查過那筆——擋一次。你關心的是:不會再有人拿錯的方法量出一個錯的結論,然後退回一張其實已經做完的票。 | 🛑 擋(同一輪只擋一次) | ## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit) @@ -151,6 +152,7 @@ | `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳——給 `subagent-first-guard.sh` 判斷用。 | 📝 記錄 | | `issue-status-autoflip.sh` | AI 一派工出去,就自動把對應的 Gitea 票改成「進行中(s/doing)」,不必等人手動改標籤。 | 📝 記錄(自動改票) | | `baton-handback-guard.sh` | 一條派工線做完了,就去看它那張票有沒有**指派給人/標籤有沒有說它卡在哪/有沒有寫下一步**,缺哪一格就當場說出來。你關心的是:不會再有票做完了卻沒人接手,躺在那裡沒人發現。 | 📝 記錄(提醒,不擋) | +| `kbdb-evidence-stamp.sh` | AI 每次去知識庫拿資料,就記一筆「這是**搜尋**搜到的,還是**走正規檢索路徑**拿到的」。你關心的是:它等一下要拿這筆資料下結論時,有東西可以對照它走的是哪條路。 | 📝 記錄 | ## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit) @@ -263,6 +265,7 @@ A1 缺人話、A2 表格有幽靈、C 全頁有幽靈。 | `arcrun-intent-guard.sh` | 2 次 | 一次守「所有寫檔案」,一次專門加強守「呼叫 Arcrun 部署/驗證工具」這個更精準的情境 | | `micromanage-guard.sh` / `irreversible-dispatch-guard.sh` / `no-ticket-no-dispatch.sh` | 各 2 次 | 各自同時掛在 `Task` 與 `Agent` 兩個矩比對名稱上——這兩個名稱應該是同一種派工動作的新舊叫法,兩個都掛保證不漏接 | | `worklist-guard.sh` / `self-drive-police.sh` / `self-drive-judge.sh` / `delivery-police.sh` / `wiki-first-police.sh` / `unpushed-police.sh` | 各 2 次 | 一次守「總管自己想收工」(Stop),一次守「subagent 交回工作」(SubagentStop)——同一套判準用在兩種角色身上 | +| `search-is-not-proof-guard.sh` | 3 次 | 拿搜尋結果當證據的那個動作,可能是「貼進票裡」(Bash),也可能是「寫進派工單發包出去」(`Task`/`Agent` 兩個名稱各掛一次),三種都要接住 | ❓ **這裡有一件我看不出來是刻意還是遺留、需要人判斷**:`micromanage-guard.sh`/`irreversible-dispatch-guard.sh`/`no-ticket-no-dispatch.sh` 三支都**同時**掛在 `Task` 跟 `Agent` 這兩個矩比對名稱上。如果這兩個名稱在目前版本的 Claude Code 裡指的是「同一種派工工具呼叫」, diff --git a/hooks/hooks.json b/hooks/hooks.json index 4d39582..d4e41d3 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -52,6 +52,10 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/reply-identity-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/search-is-not-proof-guard.sh" } ] }, @@ -144,6 +148,10 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/search-is-not-proof-guard.sh" } ] }, @@ -183,6 +191,10 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/search-is-not-proof-guard.sh" } ] }, @@ -325,6 +337,15 @@ } ] }, + { + "matcher": ".*kbdb_(search|get_record|query|graph_neighbors|get_map).*", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/kbdb-evidence-stamp.sh" + } + ] + }, { "matcher": "Agent|Task", "hooks": [ diff --git a/hooks/kbdb-evidence-stamp.sh b/hooks/kbdb-evidence-stamp.sh new file mode 100755 index 0000000..4ef7c3b --- /dev/null +++ b/hooks/kbdb-evidence-stamp.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# 管什麼: 記下「這個 session 看到的每一筆 KBDB 資料,是從哪一條路拿到的」——搜尋出來的,還是走產品檢索路徑拿到的。 +# 為什麼: 2026-08-27 inkstone/Arcrun#167 comment 4865,總管把 kbdb_search 的輸出當成「產品檢索壞了」的證據,退回一張已經做完的票、重派一條線。錯的不是那句話,是那條路。 +# 誤觸時怎麼關: 這支不擋任何東西,只留檔。要停用就從 hooks.json 拿掉它(配套的 search-is-not-proof-guard 會跟著失效,見該檔)。 +# +# kbdb-evidence-stamp.sh — 證據的出處登記處(PostToolUse: 各 kbdb_* 工具) +# +# ── 為什麼是「出處」而不是「措辭」─────────────────────────────────── +# leo 2026-08-17 證偽過文字層:那天在文字層封路的閘 **8 次誤攔、0 次正確攔截**, +# 且方向穩定——紅線寫得越細,命中關鍵字的機率越高 ⇒ **那些閘在懲罰謹慎**。 +# leo 原話:「自然語言的變體是無限的,blacklist 永遠追不完。 +# 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉。」 +# +# ⇒ 所以本閘不看你怎麼說,看你**引用的那筆資料是從哪支工具掉出來的**。 +# 資料值跟動作一樣是有限且可枚舉的:一個 entry id、一個 `kb://` 來源位址, +# 它要嘛出現在 kbdb_search 的回應裡,要嘛沒有。**這是事實,不是判讀。** +# +# ── 兩條路的分別(D81,leo 2026-08-13 親自裁定)──────────────────── +# `system-dev/docs/2-architecture/decisions/D81-vector-only-for-entity-normalize.md` +# Graph(三元組/entities) → **找到該找的東西** ← 產品檢索路徑 +# LLM Wiki(卡片文本) → **拉到對的內容** ← 產品檢索路徑 +# 向量/關鍵字搜尋 → **協助 entities normalize**,**輔助,不是檢索主路徑** +# +# 🔴 **`kbdb_search` 本身完全合法**,這支不會讓它變難用—— +# subagent-wiki-guard.sh 還明文要求「查任何東西之前先用它查 wiki」。 +# leo 的話是「不能拿它當**產品檢索路徑**」,不是「不准呼叫」。 +# ⇒ 本檔只登記出處,判斷留給 search-is-not-proof-guard.sh,而那支的觸發條件 +# 窄到「你把搜尋結果的原始值貼進票或派工單裡當證據」才會響。 +# +# ── 平台依據(查證過,不是推測)───────────────────────────────────── +# PostToolUse 的 stdin payload 帶得到工具的**輸出**: +# `@anthropic-ai/claude-code/sdk.d.ts:54-59` +# export type PostToolUseHookInput = BaseHookInput & { +# hook_event_name: 'PostToolUse'; tool_name: string; +# tool_input: unknown; tool_response: unknown; }; +# 實跑的那顆(Caskroom 2.1.220)內建說明同樣寫著 +# `"tool_response": { "success": true } // PostToolUse only`。 +# +# ── fail-closed:這支失效的時候要出聲,不准靜默 ────────────────────── +# `kbdb-asked-stamp.sh` 檔頭記著 leo 2026-08-08 的原話: +# 「你自己寫了 hook,結果**一次次改都沒產生用途**,問題太大」。 +# ⇒ 所以 payload 裡**根本沒有 `tool_response` 這個欄位**時(=平台換了介面), +# 本檔走 exit 2 把話講到 stderr 上(PostToolUse 的 exit 2 不會擋工具, +# 只會把訊息交給 Claude 看)。**寧可吵一次,也不要變成一支活著卻沒作用的閘。** +# 「回應讀得到但裡面沒有指紋」是正常情況(搜尋 0 筆),不出聲。 +set -uo pipefail + +INPUT=$(cat) + +OUT=$(printf '%s' "$INPUT" | python3 -c ' +import json, os, re, sys + +try: + d = json.load(sys.stdin) +except Exception: + print("SKIP"); raise SystemExit + +tool = d.get("tool_name") or "" +sid = d.get("session_id") or "nosid" + +# 這一族才登記;別的工具直接走人 +if "kbdb_" not in tool: + print("SKIP"); raise SystemExit + +# 🔴 D81 的兩條產品檢索路徑 vs 輔助工具。判斷只看工具名,不看內容。 +WALKED = ("kbdb_get_record", "kbdb_graph_neighbors", "kbdb_query", "kbdb_get_map") +if any(w in tool for w in WALKED): + kind = "walked" +elif "kbdb_search" in tool: + kind = "search" +else: + print("SKIP"); raise SystemExit # 寫入類工具(create_*)不是證據來源 + +if "tool_response" not in d: # ← 平台換介面了,本閘等於死了 + print("NOFIELD"); raise SystemExit + +# 回應可能是 dict、list、字串、或 MCP 的 content blocks——一律壓成字串再找指紋。 +# 這樣就不必猜它今天是哪一種包裝。 +def flatten(x, acc): + if isinstance(x, str): + acc.append(x) + elif isinstance(x, dict): + for v in x.values(): flatten(v, acc) + elif isinstance(x, (list, tuple)): + for v in x: flatten(v, acc) + elif x is not None: + acc.append(str(x)) + +parts = [] +flatten(d.get("tool_response"), parts) +try: + parts.append(json.dumps(d.get("tool_response"), ensure_ascii=False)) +except Exception: + pass +blob = "\n".join(parts) + +# ── 指紋只取兩類,兩類都是「值」不是「措辭」─────────────────────── +# ① entry id:`e_`。36 字元的 UUID,實質上不會誤撞。 +# ② 來源位址:`kb://…`。它在 metadata_json 那串裡,而**跳脫前後長得一樣** +# (貼進 markdown 會變成 \"…\",但 kb:// 那段本身不變)⇒ 貼過去也認得出來。 +# 兩類都是可枚舉的資料值,正好對上 leo 8-17「動作有限且可枚舉,文字不是」。 +ids = set(re.findall(r"\be_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", blob)) +srcs = set(re.findall(r"kb://[^\"\\\s,}\]]{6,}", blob)) +fps = sorted(ids | srcs) + +path = "/tmp/.kbdb-evidence-%s-%s" % (kind, sid) +if fps: + try: + with open(path, "a", encoding="utf-8") as f: + for x in fps: + f.write(x + "\n") + os.chmod(path, 0o600) + except Exception: + print("WRITEFAIL"); raise SystemExit +print("OK %s %d" % (kind, len(fps))) +' 2>/dev/null) || OUT="PYFAIL" + +case "${OUT:-}" in + NOFIELD) + cat >&2 <<'EOF' +⚠️ kbdb-evidence-stamp 失效了:這次的 PostToolUse payload **沒有 `tool_response` 欄位**。 + +它靠這個欄位登記「這筆資料是從哪條路拿到的」,欄位不見 = +`search-is-not-proof-guard.sh` 從現在起**永遠不會響**(而且不會有人發現)。 + +依據(本閘寫的時候查證過): + @anthropic-ai/claude-code/sdk.d.ts:54-59 PostToolUseHookInput 帶 `tool_response` + +⇒ 平台介面變了。開一張票去修這兩支,不要當作沒看到—— + 一支活著卻沒作用的閘,比沒有閘更糟:它會讓人以為這一格有人守。 +EOF + exit 2 ;; + PYFAIL|WRITEFAIL) + cat >&2 <<'EOF' +⚠️ kbdb-evidence-stamp 這次沒登記成功(python3 或 /tmp 寫入失敗)。 + +後果:這一次的 kbdb 呼叫不會被 `search-is-not-proof-guard.sh` 看見。 +不影響你手上的工作,但**這一格暫時沒人守**——請自己確認一次 +「我等一下要下的結論,走的是不是我實際查過的那條路」。 +EOF + exit 2 ;; +esac +exit 0 diff --git a/hooks/search-is-not-proof-guard.sh b/hooks/search-is-not-proof-guard.sh new file mode 100755 index 0000000..b914b2b --- /dev/null +++ b/hooks/search-is-not-proof-guard.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# 管什麼: 把 kbdb_search 掉出來的原始值當證據,貼進票或派工單裡,而那筆資料從沒走過產品檢索路徑時,擋一次。 +# 為什麼: 2026-08-27 inkstone/Arcrun#167 comment 4865——總管用 kbdb_search 的輸出判定「兩個缺陷都還在」,退回一張已經做完、有測試、東西在 main 的票,還重派一條線。半小時後自己更正(comment 4877):那條路本來就不會有那個欄位。 +# 誤觸時怎麼關: 指令或派工單裡加 `search-is-evidence: <一句理由>` 重送(留痕);同一 session 只擋一次。 +# +# search-is-not-proof-guard.sh — 你判斷的那條路,和你走的那條路,是同一條嗎? +# (PreToolUse: Bash / Agent / Task) +# +# ── 這一格為什麼原本沒有閘(inkstone/ISEP#30 comment 4879 的原話)────── +# 「所有現有的閘防的是**危險動作**:推 main、推 prod、寫金鑰、打真庫、開重複票。 +# 而『用 kbdb_search 去證明某條路壞了』——不危險、不寫檔、不推 main。 +# **它只是錯的方法。** ⇒ 閘的座標系裡沒有這一維。」 +# +# ── 判準:出處,不是措辭 ──────────────────────────────────────────── +# 🔴 驗收條件第 4 條寫死:**不要用關鍵字比對我的措辭**。 +# leo 2026-08-17 已經證偽過文字層——那天 **8 次誤攔、0 次正確攔截**, +# 而且方向穩定:紅線寫得越細,命中關鍵字的機率越高 ⇒ **在懲罰謹慎**。 +# +# 所以這支一個字都不看。它只問一件機械問題: +# +# 你正要送出去的這份東西裡,有沒有一個**只在 kbdb_search 的回應裡出現過** +# 的值(entry id / `kb://` 來源位址)?而那筆資料,這個 session 從來沒有 +# 用產品檢索路徑(graph / wiki 內容)拿過? +# +# ⇒ 兩個條件都成立,才代表「我在拿輔助工具的輸出,當產品檢索路徑的證據」。 +# 這是**資料的出處**,不是我的用詞。值跟動作一樣有限且可枚舉。 +# +# ── D81:哪條才是產品檢索路徑(leo 2026-08-13 親自裁定)────────────── +# `system-dev/docs/2-architecture/decisions/D81-vector-only-for-entity-normalize.md` +# 「你會用 **graph 找到該找的東西**,用 **LLM wiki 去拉到對的內容**, +# 所以全量向量化沒意義⋯⋯**向量的目的是用來協助把 entities normalize**。」 +# ⇒ 主路徑=graph + wiki 內容;向量/關鍵字搜尋=**輔助**。 +# +# 🔴 **這支不會讓 `kbdb_search` 變難用。** 它是合法的基本盤工具, +# subagent-wiki-guard.sh 還明文要求「查任何東西之前先用它查 wiki」。 +# 查一筆資料在不在、找 record_id、翻 wiki 找判例——**全部照放**, +# 因為那些用途不會把 entry id 貼進票裡當證據。 +# +# ── 真跡(本閘的規格,Arcrun#167 comment 4865 原文照貼)─────────────── +# $ kbdb_search(q="小果 AFTEE 詐貸") → count 5 +# { "id": "e_b77f23a1-b467-4aa4-883c-58a051ab8e66", … } +# "metadata_json": "{\"source\":\"kb://小果被AFTEE詐貸.pdf#8\", … }" +# ⇒ 那則退回裡**同時**有 entry id 和 `kb://` 位址,兩類指紋都命中, +# 而整個 session 沒有任何一次 graph/wiki 內容的呼叫。 +# ⇒ 半小時後 comment 4877:「上一則退回是錯的;真正的缺口是那支工具 MCP 沒有」。 +# **那半小時裡,一條線被停掉、一張做完的票被退回、一份工單被重寫。** +# +# ── 誠實邊界(不補,補了就變成文字層)──────────────────────────────── +# 1. **改寫過的證據抓不到**——「我看到出處還是 `../` 開頭」這種轉述沒有指紋。 +# 但房規本來就要求貼原始輸出(規則四之一、派工鐵律之三), +# 而貼了原始輸出就會被抓到。**不貼的那條路由別的閘管,不歸這支。** +# 2. **只認 KBDB 這一族**。別的「錯的驗證路徑」(拿 portal 畫面當 MCP 的證據—— +# 08-27 同一天的另一半)這支看不到,因為那不經過工具呼叫。 +# 3. 想不出乾淨判準的地方就留白,不硬做。 +# `.claude/branch-holds.md` 檔頭那句:**永遠在響的警報,等於訓練人忽略這個警報。** +set -uo pipefail + +INPUT=$(cat) + +SID=$(printf '%s' "$INPUT" | python3 -c " +import sys, json +try: print(json.load(sys.stdin).get('session_id','') or 'nosid') +except Exception: print('nosid') +" 2>/dev/null || echo nosid) + +SEARCH="/tmp/.kbdb-evidence-search-$SID" +# 這個 session 從來沒用搜尋拿過任何東西 ⇒ 這支沒有立場說話,最便宜的出口放在最前面 +[ -s "$SEARCH" ] || exit 0 + +WARNED="/tmp/.search-is-not-proof-warned-$SID" +[ -f "$WARNED" ] && exit 0 # 同一 session 只擋一次,不鬼打牆 + +# 🔴 env 要 export,不能掛在 `printf` 前面——那樣只餵到 printf,python3 收不到 +export SEARCH_FILE="$SEARCH" +export WALKED_FILE="/tmp/.kbdb-evidence-walked-$SID" + +HIT=$(printf '%s' "$INPUT" | python3 -c ' +import json, os, re, sys + +try: + d = json.load(sys.stdin) +except Exception: + raise SystemExit # 讀不到 payload 就不擋(那不是使用者的錯) + +tool = d.get("tool_name") or "" +ti = d.get("tool_input") or {} +if not isinstance(ti, dict): + raise SystemExit + +# ── ① 這是不是一個「往外送」的動作?────────────────────────────── +# 往外送 = 這份文字會變成別人的前提:貼進票、關票、記裁決、或發包給另一條線。 +# 只在自己腦袋裡看搜尋結果,永遠不會走到這裡。 +body, kind = "", "" + +if tool in ("Agent", "Task"): + body = ti.get("prompt") or "" + kind = "派工單" +elif tool == "Bash": + cmd = ti.get("command") or "" + if not cmd: + raise SystemExit + outward = False + if re.search(r"\bticket\s+(say|close|decide|new|subtask|handback)\b", cmd): + outward = True + if re.search(r"issues/\d+(/comments)?\b", cmd) and re.search(r"\b(POST|PATCH)\b", cmd): + outward = True + if not outward: + raise SystemExit + body, kind = cmd, "票上的留言" + # 內文常常放在 -F <檔> 裡,指令本身看不到 ⇒ 跟 comment-carries-task-guard 同一招 + m = re.search(r"[\s](?:-F|--file)[\s]+(\S+)", cmd) + if m and os.path.isfile(m.group(1)): + try: + body += "\n" + open(m.group(1), encoding="utf-8", errors="replace").read() + except Exception: + pass +else: + raise SystemExit + +if not body: + raise SystemExit + +# 逃生口(留痕:它會留在指令歷史/派工單上) +if "search-is-evidence:" in body: + raise SystemExit + +def load(p): + try: + with open(p, encoding="utf-8") as f: + return {ln.strip() for ln in f if ln.strip()} + except Exception: + return set() + +searched = load(os.environ["SEARCH_FILE"]) +walked = load(os.environ["WALKED_FILE"]) + +# ── ② 只在搜尋裡出現過、真路徑沒走過的那些值 ───────────────────── +# 走過真路徑的(graph / wiki 內容 / query)從候選裡扣掉—— +# **搜完再去走一次真路徑,本來就是對的做法,不該被罰。** +only_search = searched - walked +if not only_search: + raise SystemExit + +# ── ③ 這些值有沒有真的被貼進要送出去的那份東西裡 ────────────────── +hits = sorted(v for v in only_search if v in body) +if not hits: + raise SystemExit + +print(kind + "\t" + "\t".join(hits[:3])) +' 2>/dev/null) || exit 0 + +[ -n "${HIT:-}" ] || exit 0 + +KIND=$(printf '%s' "$HIT" | cut -f1) +VALS=$(printf '%s' "$HIT" | cut -f2- | tr '\t' '\n' | sed 's/^/ /') + +date +%s > "$WARNED" + +cat >&2 <\` 重送(留痕)。 + +同一 session 只擋這一次。 +EOF +exit 2 diff --git a/hooks/tests/search-is-not-proof-guard.test.sh b/hooks/tests/search-is-not-proof-guard.test.sh new file mode 100755 index 0000000..4eed194 --- /dev/null +++ b/hooks/tests/search-is-not-proof-guard.test.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +# search-is-not-proof-guard.sh + kbdb-evidence-stamp.sh 的迴歸測試 +# (inkstone/ISEP#30 → comment 4879) +# +# **離線、不打網路、不碰真庫、每次結果一樣。** 兩支都是純結構判斷,沒有語意判官。 +# +# 🔴 這支測試的重點在 A 群,不在 B 群。 +# ISEP 的第一級缺陷是**誤攔**——leo 2026-08-17 實證:在文字層封路的閘 +# 8 次誤攔、0 次正確攔截。所以「該擋有擋到」只是及格, +# **「不該擋的一個都沒擋」才是這支閘能不能活下來的條件。** +# +# 用法:hooks/tests/search-is-not-proof-guard.test.sh [guard 路徑] [stamp 路徑] + +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOKS="$(cd "$HERE/.." && pwd)" +GUARD="${1:-$HOOKS/search-is-not-proof-guard.sh}" +STAMP="${2:-$HOOKS/kbdb-evidence-stamp.sh}" + +SID="S-SINP-TEST" +SEARCH="/tmp/.kbdb-evidence-search-$SID" +WALKED="/tmp/.kbdb-evidence-walked-$SID" +WARNED="/tmp/.search-is-not-proof-warned-$SID" +TMPD="$(mktemp -d)" +trap 'rm -rf "$TMPD"; rm -f "$SEARCH" "$WALKED" "$WARNED"' EXIT + +PASS=0; FAIL=0; N=0 + +# 真跡:Arcrun#167 comment 4865 裡真的被貼出去的那兩個值 +REAL_ID="e_b77f23a1-b467-4aa4-883c-58a051ab8e66" +REAL_SRC="kb://小果被AFTEE詐貸.pdf#8" + +clean() { rm -f "$SEARCH" "$WALKED" "$WARNED"; } + +# seen <值...> —— 直接寫登記檔(模擬 stamp 已經跑過) +seen() { + f="/tmp/.kbdb-evidence-$1-$SID"; shift + for v in "$@"; do printf '%s\n' "$v" >> "$f"; done +} + +# bash_payload +bash_payload() { + python3 - "$SID" "$1" <<'PY' +import json, sys +print(json.dumps({"session_id": sys.argv[1], "hook_event_name": "PreToolUse", + "tool_name": "Bash", "tool_input": {"command": sys.argv[2]}}, + ensure_ascii=False)) +PY +} + +# agent_payload +agent_payload() { + python3 - "$SID" "$1" <<'PY' +import json, sys +print(json.dumps({"session_id": sys.argv[1], "hook_event_name": "PreToolUse", + "tool_name": "Agent", "tool_input": {"prompt": sys.argv[2]}}, + ensure_ascii=False)) +PY +} + +# post_payload [有沒有 tool_response 欄位:yes|no] +post_payload() { + python3 - "$SID" "$1" "$2" "${3:-yes}" <<'PY' +import json, sys +sid, tool, resp, has = sys.argv[1:5] +d = {"session_id": sid, "hook_event_name": "PostToolUse", + "tool_name": tool, "tool_input": {}} +if has == "yes": + d["tool_response"] = json.loads(resp) +print(json.dumps(d, ensure_ascii=False)) +PY +} + +t() { + want="$1"; desc="$2"; body="$3"; hook="${4:-$GUARD}" + N=$((N+1)) + err=$(printf '%s' "$body" | bash "$hook" 2>&1 >/dev/null); rc=$? + if [ "$rc" -eq "$want" ]; then + printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1)) + else + printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc" + printf '%s\n' "$err" | sed -n '1,6p' | sed 's/^/ /' + FAIL=$((FAIL+1)) + fi +} + +echo "── A 群:🔴 不該擋(誤攔是這個 repo 的第一級缺陷)────────────────" + +clean +t 0 "① 這個 session 從沒用過 kbdb_search → 完全沒有立場說話" \ + "$(bash_payload "scripts/ticket say inkstone/Arcrun#167 -F /tmp/x.md")" + +clean; seen search "$REAL_ID" "$REAL_SRC" +t 0 "② 搜過,但只是自己看——沒有任何往外送的動作(git status)" \ + "$(bash_payload "git status --short")" + +clean; seen search "$REAL_ID" "$REAL_SRC" +printf '總管:這一輪的三張票都關了,版本 1.4.59 已上 stage。\n' > "$TMPD/plain.md" +t 0 "③ 搜過,也貼了票,但**沒有引用任何搜尋出來的值**(最常見的正常情況)" \ + "$(bash_payload "scripts/ticket say inkstone/ISEP#30 -F $TMPD/plain.md")" + +clean; seen search "$REAL_ID"; seen walked "$REAL_ID" +printf '總管:這筆我用 kbdb_get_record 拉過了,id 是 %s\n' "$REAL_ID" > "$TMPD/walked.md" +t 0 "④ 搜完**又走了真路徑**才引用 → 這是對的做法,不准罰" \ + "$(bash_payload "scripts/ticket say inkstone/Arcrun#167 -F $TMPD/walked.md")" + +clean; seen search "$REAL_ID" +t 0 "⑤ 逃生口:指令裡寫了 search-is-evidence: 的理由" \ + "$(bash_payload "scripts/ticket say inkstone/Arcrun#167 --body '$REAL_ID' search-is-evidence: 只是引用一筆資料的編號")" + +clean; seen search "$REAL_ID" +t 0 "⑥ 讀那支閘的原始碼(值只是出現在指令裡,不是往外送)" \ + "$(bash_payload "cat hooks/search-is-not-proof-guard.sh | grep $REAL_ID")" + +clean; seen search "$REAL_ID" +t 0 "⑦ 撈票/搜票這些讀取動作,不是往外送" \ + "$(bash_payload "curl -s -H 'Authorization: token X' 'https://git.uncle6.me/api/v1/repos/inkstone/Arcrun/issues/167'")" + +clean; seen search "$REAL_SRC" +t 0 "⑧ 只在 wiki 裡查東西、然後改自己 repo 的檔(Write 不歸這支管)" \ + "$(bash_payload "sed -i '' 's/a/b/' system-dev/wiki/status.md")" + +clean; seen search "$REAL_ID" +printf '【工單】inkstone/ISEP#30\n' > "$TMPD/dispatch-ok.md" +t 0 "⑨ 合規派工單(只有票號,沒有引用搜尋值)" \ + "$(agent_payload "$(cat "$TMPD/dispatch-ok.md")")" + +clean; seen search "$REAL_ID" +t 2 "⑩ 第一次命中 → 擋(這一發是下一條的前置)" \ + "$(bash_payload "scripts/ticket say inkstone/Arcrun#167 --body '$REAL_ID'")" +t 0 "⑩' 同一 session 第二次同樣的動作 → 放行,不鬼打牆" \ + "$(bash_payload "scripts/ticket say inkstone/Arcrun#167 --body '$REAL_ID'")" + +echo "── B 群:該擋(真跡重演)──────────────────────────────────────" + +clean; seen search "$REAL_ID" "$REAL_SRC" +cat > "$TMPD/4865.md" <> 提及 >> 小果被AFTEE詐貸" +} +\`\`\` + +"metadata_json": "{\"source\":\"$REAL_SRC\",\"library\":\"youlinhsieh-test1\"}" +EOF +t 2 "⑪ 真跡重演:Arcrun#167 comment 4865 那則退回,原文照貼" \ + "$(bash_payload "scripts/ticket say inkstone/Arcrun#167 -F $TMPD/4865.md")" + +clean; seen search "$REAL_ID" +t 2 "⑫ 同一件事走派工單(重派一條線)" \ + "$(agent_payload "【工單】inkstone/Arcrun#167 +上一版沒修好,實測 $REAL_ID 那塊的出處還是 ../ 開頭")" + +clean; seen search "$REAL_SRC" +t 2 "⑬ 只貼了 kb:// 來源位址(沒貼 id)也認得出來" \ + "$(bash_payload "scripts/ticket close inkstone/Arcrun#167 --deliverable '$REAL_SRC'")" + +clean; seen search "$REAL_ID" +t 2 "⑭ 直接打 Gitea API 貼留言(繞開 scripts/ticket)" \ + "$(bash_payload "curl -X POST -d '{\"body\":\"$REAL_ID 還是壞的\"}' 'https://git.uncle6.me/api/v1/repos/inkstone/Arcrun/issues/167/comments'")" + +clean; seen search "$REAL_ID"; seen walked "e_00000000-0000-0000-0000-000000000000" +t 2 "⑮ 走過真路徑,但走的是**別筆**資料 → 這一筆仍然只有搜尋當來源" \ + "$(bash_payload "scripts/ticket say inkstone/Arcrun#167 --body '$REAL_ID'")" + +echo "── C 群:訊息本身講不講得出出路 ──────────────────────────────" + +clean; seen search "$REAL_ID" +MSG=$(printf '%s' "$(bash_payload "scripts/ticket say inkstone/Arcrun#167 --body '$REAL_ID'")" | bash "$GUARD" 2>&1 >/dev/null) +for want in "D81" "kbdb_graph_neighbors" "search-is-evidence:" "4877" "同一條嗎"; do + N=$((N+1)) + if printf '%s' "$MSG" | grep -q -- "$want"; then + printf ' ✅ 訊息裡有「%s」\n' "$want"; PASS=$((PASS+1)) + else + printf ' ❌ 訊息裡找不到「%s」\n' "$want"; FAIL=$((FAIL+1)) + fi +done +N=$((N+1)) +if printf '%s' "$MSG" | grep -q '`kbdb_get_record`'; then + printf ' ✅ 反引號沒有被 shell 吃掉(heredoc 有跳脫)\n'; PASS=$((PASS+1)) +else + printf ' ❌ 反引號被 shell 展開了\n'; FAIL=$((FAIL+1)) +fi + +echo "── D 群:登記處(stamp)──────────────────────────────────────" + +clean +RESP=$(python3 -c " +import json +print(json.dumps({'ok':True,'data':{'entries':[{'id':'$REAL_ID','metadata_json':'{\"source\":\"$REAL_SRC\"}'}]}}, ensure_ascii=False)) +") +t 0 "⑯ kbdb_search 的回應 → 登記進 search 檔" \ + "$(post_payload "mcp__abc__kbdb_search" "$RESP")" "$STAMP" +N=$((N+1)) +if grep -q "$REAL_ID" "$SEARCH" 2>/dev/null && grep -q "$REAL_SRC" "$SEARCH" 2>/dev/null; then + printf ' ✅ 兩類指紋(entry id + kb:// 位址)都抓到了\n'; PASS=$((PASS+1)) +else + printf ' ❌ 指紋沒抓到:%s\n' "$(cat "$SEARCH" 2>/dev/null)"; FAIL=$((FAIL+1)) +fi + +clean +# 🔴 MCP 的回應**不保證**是拆好的物件——常見的是包成 content blocks, +# 整份 JSON 縮成一個字串塞在 text 裡(連引號都被跳脫過)。 +# stamp 對兩種包裝都要認得,否則它在真的環境裡會靜默失效。 +INNER=$(python3 -c " +import json +print(json.dumps({'ok':True,'data':{'entries':[{'id':'$REAL_ID','metadata_json':'{\"source\":\"$REAL_SRC\"}'}]}}, ensure_ascii=False)) +") +WRAPPED=$(python3 -c " +import json,sys +print(json.dumps({'content':[{'type':'text','text':sys.argv[1]}]}, ensure_ascii=False)) +" "$INNER") +t 0 "⑯' 同一份回應包成 MCP content blocks(字串裡的 JSON)→ 一樣認得出來" \ + "$(post_payload "mcp__abc__kbdb_search" "$WRAPPED")" "$STAMP" +N=$((N+1)) +if grep -q "$REAL_ID" "$SEARCH" 2>/dev/null && grep -q "$REAL_SRC" "$SEARCH" 2>/dev/null; then + printf ' ✅ content blocks 包裝下兩類指紋都抓到了\n'; PASS=$((PASS+1)) +else + printf ' ❌ 被包裝擋住了:%s\n' "$(cat "$SEARCH" 2>/dev/null)"; FAIL=$((FAIL+1)) +fi + +clean +t 0 "⑰ kbdb_get_record 的回應 → 登記進 walked 檔" \ + "$(post_payload "mcp__abc__kbdb_get_record" "$RESP")" "$STAMP" +N=$((N+1)) +if grep -q "$REAL_ID" "$WALKED" 2>/dev/null && [ ! -s "$SEARCH" ]; then + printf ' ✅ 走過真路徑的記在 walked,沒有污染 search\n'; PASS=$((PASS+1)) +else + printf ' ❌ 記錯檔了\n'; FAIL=$((FAIL+1)) +fi + +clean +t 0 "⑱ 不是 kbdb 的工具 → 什麼都不做" \ + "$(post_payload "Bash" "$RESP")" "$STAMP" + +clean +t 0 "⑲ 搜尋 0 筆(回應讀得到但沒有指紋)→ 正常,不出聲" \ + "$(post_payload "mcp__abc__kbdb_search" '{"ok":true,"data":{"entries":[],"count":0}}')" "$STAMP" + +clean +t 2 "⑳ fail-closed:payload 裡沒有 tool_response 欄位 → 出聲說自己失效了" \ + "$(post_payload "mcp__abc__kbdb_search" "$RESP" "no")" "$STAMP" + +clean +echo +printf '共 %d 條:✅ %d ❌ %d\n' "$N" "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] || exit 1