diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e4eea3a..fede5f5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "isep", - "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:48 支機械閘(59 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", - "version": "0.5.0", + "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:49 支機械閘(60 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", + "version": "0.5.1", "keywords": [ "inkstone", "guardrails", diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 23695ea..baf5ec5 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -1,4 +1,4 @@ -# 46 支閘,白話盤點表 +# 47 支閘,白話盤點表 > 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」 > 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。 @@ -7,14 +7,18 @@ ## 一句話結論 -`hooks/` 底下有 **46 個 `.sh` 檔**,`hooks.json` 實際掛上 **56 條註冊**(同一支閘常被多種情境同時掛上); +`hooks/` 底下有 **47 個 `.sh` 檔**,`hooks.json` 實際掛上 **57 條註冊**(同一支閘常被多種情境同時掛上); 其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。 下面按「你會在什麼時候撞到它」分組,一支一行。 > 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」, > 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。 -> 現在的寫法是實際數出來的: -> `ls hooks/*.sh | wc -l` = 46;`hooks.json` 展開後的 command 條目 = 56。 +> +1/+1 是本次(ISEP#69)新增的 `leo21c-mcp-guard.sh`。 +> +> ⚠️ **這份表本身可能又已經漂移**:`ls hooks/*.sh | wc -l` 在本分支實測是 49 +> (比這裡寫的 47 多 2),初步比對疑似是 `isep-presence-beacon.sh`/ +> `milestone-due-guard.sh` 兩支已經掛進 `hooks.json` 卻沒被收進這張表—— +> 但那兩支不是本票(ISEP#69)動的閘,沒有一併查證+改動,留給總管或後續票核實。 > **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。 --- @@ -42,6 +46,7 @@ | `prod-write-guard.sh` | AI 想做任何「會真的改到線上正式帳號(leo21c)」的操作(不只是 push,也包含直接呼叫會寫入的工具)就擋下,交回總管處理。 | 🛑 擋 | | `not-my-branch-guard.sh` | AI 想 commit 到「別的 subagent 正在施工中」的分支(登記在 `branch-holds.md`)就擋下,避免兩個 AI 互踩同一條分支。 | 🛑 擋 | | `leo21c-write-guard.sh` | AI 想寫東西到你私人帳號 `leo21c` 就擋下——這個帳號只准你自己用,AI 的實驗一律走別的測試帳號。 | 🛑 擋 | +| `leo21c-mcp-guard.sh` | AI 想用 Arcrun/KBDB 的 MCP 工具查或寫**你的真庫(leo21c)**就擋下(不管讀寫)——測試場是另一條 MCP 連線,沒指定就該用那條。`arcrun_whoami` 本身不擋(用來確認身份);真的要碰真庫要明說(`touch /tmp/.leo21c-mcp-ok`),會留痕。連線的 server id 若不在已知清單裡也擋(清單可能過期,寧可先擋不要靜默放行)。 | 🛑 擋 | | `release-tag-guard.sh` | AI 想打版本 tag,但 `plugin.json` 裡寫的版本號跟 tag 對不上就擋下——避免「這裡說 0.2.0、那裡說 0.1.0」各說各話。 | 🛑 擋 | | `ticket-api-bypass-guard.sh` | AI 想繞過 `scripts/ticket` 工具、直接打 API 開新 Gitea 票(會漏掉「開票前先搜過」這道檢查)就擋下。 | 🛑 擋 | | `reply-identity-guard.sh` | 直接打 Gitea API 貼留言到票上時,內文第一行沒有 `【身份】`(`總管`/`subagent`/`leo`)就擋下——票上多條線並行時,看不出哪一則是誰寫的。純讀取、走 `scripts/ticket` 正門、指令裡加 `reply-identity-ok` 都放行。 | 🛑 擋 | diff --git a/hooks/hooks.json b/hooks/hooks.json index feded4a..6f048f7 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -60,6 +60,15 @@ } ] }, + { + "matcher": ".*mcp__[0-9a-fA-F-]+__(kbdb_|arcrun_).*", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/leo21c-mcp-guard.sh" + } + ] + }, { "matcher": "Write|Edit|MultiEdit", "hooks": [ diff --git a/hooks/leo21c-mcp-guard.sh b/hooks/leo21c-mcp-guard.sh new file mode 100755 index 0000000..07262e0 --- /dev/null +++ b/hooks/leo21c-mcp-guard.sh @@ -0,0 +1,202 @@ +#!/bin/sh +# leo21c-mcp-guard.sh — PreToolUse:擋下打向 leo21c 真庫的 Arcrun/KBDB MCP 呼叫 +# (leo 2026-08-27 立,ISEP#69) +# +# leo 原話:「我從頭的指令就叫你去打 youlin,stage 環境是它你不知道嗎? +# 為什麼打算用我的真庫測試?」 +# +# 🔴 立這道閘的來由(2026-08-27 總管實查): +# 這台機器同時掛著兩條 Arcrun MCP 連線,工具名是兩串亂碼 UUID: +# mcp__00402d88-5e2f-4125-b971-8bdac50d7456__* → logged_in_as: leo21c 🔴 leo 的真庫 +# mcp__d0eaf65b-583e-464e-aacc-00527ac05c4b__* → logged_in_as: youlin ✅ 測試場 +# 總管一整天用的是第一條,所有測試資料卻寫在 youlin。範圍講清楚: +# 打在 leo21c 上的**全是唯讀查詢**(kbdb_search/kbdb_get_map/whoami), +# 沒有寫入、沒有 ingest。但唯讀也不該發生,而且它造成了三次假結論: +# ①「wiki 沒送上去」——其實上去了,在 youlin +# ②「檔案排在 1691 筆佇列後面」——查錯庫 +# ③「ingest 回 200 但一個半小時還沒寫進去」——換 youlin 那條查,四塊內容全在 +# 最難看的一格:總管每一份派工單都親手寫著「測試一律用 youlin,絕對不要碰 leo21c」 +# ——規則寫給別人,自己一次都沒套用。 +# +# 判準(封動作,不封措辭 —— 同 empty-handed-stop-guard/leo21c-write-guard 的哲學): +# 任何打向 leo21c 座標的 Arcrun/KBDB MCP 呼叫 ⇒ 擋,除非明說要打它(見下方戳記)。 +# 讀寫都擋,但訊息不同:唯讀提示「你可能查錯地方」,寫入提示「這會汙染真資料」。 +# `arcrun_whoami` 本身永遠放行——那是唯一能確認身份的工具,擋它會造成死結。 +# +# 🔴 已知限制(紅線要求「查不到就明講」,這裡就是查不到的那部分): +# MCP server 的 UUID 是這台機器這次連線分配的,**不是穩定不變的識別碼** +# ——查過 ~/.claude.json/本機常見設定檔,這兩條 Arcrun 連線都不是本地 +# mcpServers 設定出來的(找不到來源設定檔),判斷是雲端 connector 這層 +# 動態配的,UUID 會在重裝/換 session 時换掉。沒有找到官方文件證實這點, +# 這裡寫的是「查過但查不到穩定識別法」,不是猜測的答案。 +# ⇒ 下面兩個 UUID 是寫死的(2026-08-27 由 leo 實測 arcrun_whoami 得到), +# **這份對照表會過期**。因此設計上不是「表格以外的一律放行」, +# 而是「不在表格裡的 UUID 一樣擋,且訊息明講『清單可能過期』」—— +# 大聲失敗,不要靜默放行到一個身份不明的目標(fail-closed)。 +set -eu + +LEO21C_UUID="00402d88-5e2f-4125-b971-8bdac50d7456" +YOULIN_UUID="d0eaf65b-583e-464e-aacc-00527ac05c4b" +STAMP="/tmp/.leo21c-mcp-ok" +PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}" +LOGDIR="$PROJ/.claude/hooks" + +trace() { + # 留痕:擋下與放行都記,"明說放行" 也要留下痕跡讓 leo 事後查得到 + # (inkstone/InkStoneCo#48 同一個理由:閘只記「擋下」,分母未知答不出「它有沒有在動」) + T=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?") + if [ -d "$LOGDIR" ]; then + printf '| %s | %s | %s | %s |\n' "$T" "$1" "${UUID:-?}" "${TOOL:-?}" \ + >> "$LOGDIR/leo21c-mcp-guard.log" 2>/dev/null || true + fi +} + +stamp_ok() { + # 單次、15 分鐘失效 —— 同 prod-write-guard.sh 的戳記設計,用完即丟 + # (多條 subagent 並行時「時間窗」本身就是漏洞,單次消耗把窗口壓到最小) + [ -f "$STAMP" ] || return 1 + NOW=$(date +%s 2>/dev/null || echo 0) + MT=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0) + case "$NOW$MT" in *[!0-9]*) return 1 ;; esac + [ "$NOW" -gt 0 ] && [ "$MT" -gt 0 ] || return 1 + [ $((NOW - MT)) -lt 900 ] || return 1 + rm -f "$STAMP" 2>/dev/null || true + return 0 +} + +INPUT="$(cat)" + +TOOL=$(printf '%s' "$INPUT" | python3 -c ' +import json,sys +try: print(json.load(sys.stdin).get("tool_name","") or "") +except Exception: print("__PYFAIL__") +' 2>/dev/null || printf '__PYFAIL__') + +# ── fail-closed:解析壞了不准變成靜默放行 ──────────────────────────── +# 這裡拿不到 tool_name,就退回對原始 payload 做字串比對; +# 命中真庫座標就照「未知/不確定」等級擋,而不是直接 exit 0。 +if [ "$TOOL" = "__PYFAIL__" ] || [ -z "$TOOL" ]; then + if printf '%s' "$INPUT" | grep -q "$LEO21C_UUID"; then + UUID="$LEO21C_UUID"; TOOL="(解析失敗,字串比對命中真庫座標)" + stamp_ok && { trace "✅ 明說放行(解析失敗)"; exit 0; } + trace "⛔ 擋下(解析失敗仍命中真庫)" + cat >&2 <__(kbdb_|arcrun_)*」這個形狀,其餘一律放行 +case "$TOOL" in + mcp__*__kbdb_*|mcp__*__arcrun_*) ;; + *) exit 0 ;; +esac + +UUID=$(printf '%s' "$TOOL" | sed -E 's/^mcp__([0-9a-fA-F-]+)__.*/\1/') +SUFFIX=$(printf '%s' "$TOOL" | sed -E 's/^mcp__[0-9a-fA-F-]+__//') + +# 🔴 紅線:arcrun_whoami 本身永遠不擋——它是唯一能確認身份的工具, +# 擋它會造成死結(想查是誰、卻連查都查不了)。 +case "$SUFFIX" in + arcrun_whoami) exit 0 ;; +esac + +# youlin 測試場:預設放行,不留痕(每次都記會把 log 灌爆,而這是預期的正常路徑) +[ "$UUID" = "$YOULIN_UUID" ] && exit 0 + +# 明說要打(leo 有時真的需要查自己的庫):單次戳記放行,留痕 +if stamp_ok; then + trace "✅ 明說放行" + exit 0 +fi + +# 判斷讀寫,訊息不同(兩者都擋,但傷害的說法不一樣) +case "$SUFFIX" in + *create_record*|*create_template*|*push_workflow*|*delete_workflow*|\ + *recipe_push*|*recipe_delete*|*create_tag*|*delete_tag*|\ + *tag_resource*|*untag_resource*|*run_workflow*|*report_feedback*|*recipe_submit*) + KIND="write" ;; + *) + KIND="read" ;; +esac + +if [ "$UUID" = "$LEO21C_UUID" ]; then + trace "⛔ 擋下(真庫/$KIND)" + if [ "$KIND" = "write" ]; then + cat >&2 <&2 <&2 </dev/null # 起手清乾淨,避免上一輪殘留戳記污染這輪 +mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':sys.argv[1],'tool_input':{}}))" "$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 +} + +LEO="00402d88-5e2f-4125-b971-8bdac50d7456" +YOULIN="d0eaf65b-583e-464e-aacc-00527ac05c4b" +UNKNOWN="11111111-2222-3333-4444-555555555555" + +echo "── 該擋(打向 leo21c 真庫)──" +t block "唯讀查詢打 leo21c" "mcp__${LEO}__kbdb_search" +t block "get_map 打 leo21c" "mcp__${LEO}__kbdb_get_map" +t block "寫入打 leo21c" "mcp__${LEO}__kbdb_create_record" +t block "推工作流打 leo21c" "mcp__${LEO}__arcrun_push_workflow" + +echo "── 該放行(打向 youlin 測試場)──" +t pass "唯讀查詢打 youlin" "mcp__${YOULIN}__kbdb_search" +t pass "寫入打 youlin" "mcp__${YOULIN}__kbdb_create_record" +t pass "查 template 打 youlin" "mcp__${YOULIN}__kbdb_list_templates" +t pass "arcrun 讀清單打 youlin" "mcp__${YOULIN}__arcrun_list_workflows" + +echo "── 不該擋:arcrun_whoami 本身(不管打哪條線)──" +t pass "whoami 打 leo21c" "mcp__${LEO}__arcrun_whoami" +t pass "whoami 打 youlin" "mcp__${YOULIN}__arcrun_whoami" +t pass "whoami 打未知 UUID" "mcp__${UNKNOWN}__arcrun_whoami" + +echo "── 不該擋:非 kbdb/arcrun 的 MCP 工具、非 MCP 工具 ──" +t pass "無關的 MCP 工具" "mcp__${LEO}__some_other_tool" +t pass "一般工具名" "Bash" +t pass "另一個一般工具" "kbdb_search" + +echo "── 該擋(清單以外的未知 UUID —— 對照表可能過期,大聲失敗)──" +t block "未知 UUID 的 kbdb_search" "mcp__${UNKNOWN}__kbdb_search" +t block "未知 UUID 的寫入" "mcp__${UNKNOWN}__kbdb_create_record" + +echo "── 明說要打真庫:戳記放行(單次)──" +rm -f /tmp/.leo21c-mcp-ok +touch /tmp/.leo21c-mcp-ok +t pass "戳記在,打 leo21c 放行一次" "mcp__${LEO}__kbdb_search" +# 上面 t() 已經在下一次呼叫前 rm -f 戳記,這裡驗證戳記確實是單次 +touch /tmp/.leo21c-mcp-ok +mk "mcp__${LEO}__kbdb_search" | "$HOOK" >/dev/null 2>&1 +mk "mcp__${LEO}__kbdb_search" | "$HOOK" >/dev/null 2>&1; rc2=$? +if [ $rc2 -eq 2 ]; then echo " ✅ 戳記用完即消失(第二次仍被擋)"; PASS=$((PASS+1)) +else echo " ❌ 戳記用完即消失 —— 期望第二次被擋,實得 pass"; FAIL=$((FAIL+1)); fi +rm -f /tmp/.leo21c-mcp-ok 2>/dev/null + +echo "── 明說放行要留痕 ──" +LOGDIR="${CLAUDE_PROJECT_DIR:-$(pwd)}/.claude/hooks" +mkdir -p "$LOGDIR" +rm -f "$LOGDIR/leo21c-mcp-guard.log" +touch /tmp/.leo21c-mcp-ok +mk "mcp__${LEO}__kbdb_search" | "$HOOK" >/dev/null 2>&1 +if [ -f "$LOGDIR/leo21c-mcp-guard.log" ] && grep -q "明說放行" "$LOGDIR/leo21c-mcp-guard.log"; then + echo " ✅ 明說放行有寫進 log"; PASS=$((PASS+1)) +else + echo " ❌ 明說放行沒有留痕"; FAIL=$((FAIL+1)) +fi +rm -f "$LOGDIR/leo21c-mcp-guard.log" /tmp/.leo21c-mcp-ok 2>/dev/null + +echo +echo "結果:通過 $PASS / 失敗 $FAIL" +[ $FAIL -eq 0 ] || exit 1