#!/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 <