Compare commits

..

1 Commits

Author SHA1 Message Date
Leo e177f6372b subagent-first-guard 補三個洞:閘不管 .sh、派過工變免死金牌、警報響一次就啞掉
leo 2026-08-27(inkstone/ISEP#66):「你禁止寫 code,為什麼你改?為什麼你可以執行?」
總管親手改了 no-ticket-no-dispatch.sh,這道閘一聲不響。

三個洞,一次補:
1. `.sh` 從沒進過受管副檔名清單——這個 repo 自己的閘全是 .sh,等於這道閘
   從第一行 case 就直接放行,路徑白名單根本沒機會判斷。這才是真正原因,
   不是路徑豁免。修法:閘本身(`.claude/hooks/*.sh`、`hooks/*.sh`)獨立收進
   受管清單;同時拿掉 `*/.claude/hooks/*` 整目錄豁免——能改閘的人等於能關掉
   所有其他閘,這句話本身就是拿掉它的理由。
2. 「這個 session 派過一次工」曾被當永久放行條件,但那混淆了「這件任務有沒有
   交出去」跟「下一次任意的手改合不合理」——整個拿掉,改成純資訊(留在被擋下
   時的訊息裡,告訴你上次派工是幾分鐘前)。
3. 「同一 session 只擋一次」讓警報響過一次就對餘生啞掉。改成寬限期(預設 15
   分鐘,可用 SUBAGENT_GUARD_NAG_TTL 覆寫做測試):寬限期內不重複鬼打牆,
   過了就再響——不選邊犧牲「有效」或「不煩人」。

放行只剩三種:CLAUDE_CODE_CHILD_SESSION=1(subagent 本人,它就是被派來寫的)/
solo-ok 非空(寫了理由,不是 touch 空檔)/檔案本身不歸這道閘管(測試檔/
system-dev 的 wiki/非受管副檔名)。

新增 scripts/test-subagent-first-guard.sh,20/20 通過:涵蓋三個洞各自的重演、
CHILD_SESSION 放行、測試檔/wiki 放行、solo-ok 空檔不算數、寬限期到期再響。

plugin.json 0.5.0 → 0.5.1;docs/hooks-inventory.md 同步更新兩支閘的說明。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:06:06 +08:00
8 changed files with 182 additions and 311 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:49 支機械閘(60 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:48 支機械閘(59 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.5.1",
"keywords": [
"inkstone",
+6 -11
View File
@@ -1,4 +1,4 @@
# 47 支閘,白話盤點表
# 46 支閘,白話盤點表
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
@@ -7,18 +7,14 @@
## 一句話結論
`hooks/` 底下有 **47 個 `.sh` 檔**`hooks.json` 實際掛上 **57 條註冊**(同一支閘常被多種情境同時掛上);
`hooks/` 底下有 **46 個 `.sh` 檔**`hooks.json` 實際掛上 **56 條註冊**(同一支閘常被多種情境同時掛上);
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
下面按「你會在什麼時候撞到它」分組,一支一行。
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
> +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)動的閘,沒有一併查證+改動,留給總管或後續票核實。
> 現在的寫法是實際數出來的:
> `ls hooks/*.sh | wc -l` 46`hooks.json` 展開後的 command 條目 56。
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
---
@@ -46,7 +42,6 @@
| `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 想用 ArcrunKBDB 的 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` 都放行。 | 🛑 擋 |
@@ -61,7 +56,7 @@
| `sdd-guard.sh` | AI 想直接動程式碼檔案,但找不到「唯一一份 active 規格文件(SDD)」對應這件事,或同時有一份以上 active 規格就擋下。 | 🛑 擋 |
| `credential-only-guard.sh` | AI 想把金鑰真身或自製佔位符(例如 `__XXX_TOKEN__`)寫進設定檔就擋下——金鑰只准放在統一的 credential 中心。 | 🛑 擋 |
| `arcrun-intent-guard.sh` | AI 寫的 Arcrun workflow 語法不對就擋下,而且**直接把正確寫法回貼給它**(不是只罵它錯,是教它怎麼改)。 | 🛑 擋(教學型) |
| `subagent-first-guard.sh` | 這個對話**從頭到尾都沒有派過任何 subagent**,AI 卻要自己動手改程式碼,就先擋一次,逼它想一想「這件事能不能交給別人做」。 | 🛑 擋 |
| `subagent-first-guard.sh` | AI(不是被派來做這件事的 subagent 本人)要親手改程式碼(含閘自己的 `.sh`),就擋下逼它想「這件事能不能交給別人做」——擋過一次後有寬限期不鬼打牆,但寬限期一過會再響,不是擋過一次就永久放行。 | 🛑 擋 |
| `mistake-needs-ticket-guard.sh` | AI 想往 `mistakes.md`(教訓紀錄)新增一條「機制可以防止」的教訓,卻沒附對應票號就擋下——沒有票號的教訓沒有人會回頭處理。 | 🛑 擋 |
| `pending-changes-retired.sh` | AI 想寫東西進已經廢除的 `pending-changes.md` 檔案就擋下——這個檔案已停用,規格變更一律改開 Gitea 票。 | 🛑 擋 |
@@ -131,7 +126,7 @@
| 閘名 | 對你意味著什麼 | 動作 |
|---|---|---|
| `kbdb-asked-stamp.sh` | AI 真的查過 KBDB 之後,留一個時間戳——給前面 `history-first-guard.sh` 判斷「這輪有沒有先查過」用。 | 📝 記錄 |
| `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳—— `subagent-first-guard.sh` 判斷用。 | 📝 記錄 |
| `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳——純資訊,給被 `subagent-first-guard.sh` 擋下時的訊息當脈絡(「上次派工是幾分鐘前」),不再是放行判準。 | 📝 記錄 |
| `issue-status-autoflip.sh` | AI 一派工出去,就自動把對應的 Gitea 票改成「進行中(s/doing)」,不必等人手動改標籤。 | 📝 記錄(自動改票) |
## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit
-9
View File
@@ -60,15 +60,6 @@
}
]
},
{
"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": [
-202
View File
@@ -1,202 +0,0 @@
#!/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_searchkbdb_get_mapwhoami),
# 沒有寫入、沒有 ingest。但唯讀也不該發生,而且它造成了三次假結論:
# ①「wiki 沒送上去」——其實上去了,在 youlin
# ②「檔案排在 1691 筆佇列後面」——查錯庫
# ③「ingest 回 200 但一個半小時還沒寫進去」——換 youlin 那條查,四塊內容全在
# 最難看的一格:總管每一份派工單都親手寫著「測試一律用 youlin,絕對不要碰 leo21c」
# ——規則寫給別人,自己一次都沒套用。
#
# 判準(封動作,不封措辭 —— 同 empty-handed-stop-guardleo21c-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 <<MSG
🚫 leo21c-mcp-guard 解析這次呼叫的 tool_name 失敗,
但原始內容裡看得到 leo21c 的座標 —— fail-closed,先擋。
要放行這一次:touch /tmp/.leo21c-mcp-ok
MSG
exit 2
fi
exit 0
fi
# 只管「mcp__<uuid>__(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 <<MSG
🚫 這個寫入打向 leo21c —— leo 本人的真庫,不是測試場(leo 2026-08-27 立,ISEP#69
leo 原話:「我從頭的指令就叫你去打 youlin,stage 環境是它你不知道嗎?
為什麼打算用我的真庫測試?」
你剛剛呼叫的是:
mcp__${LEO21C_UUID}__${SUFFIX}
→ logged_in_as: leo21c 🔴 真庫
這不是「查錯地方」——這是**把測試資料寫進 leo 自己在用的知識庫**,會汙染他的真實資料。
你要的測試場是另一條線(同樣的動作,換一個 server 前綴):
mcp__${YOULIN_UUID}__${SUFFIX}
→ logged_in_as: youlin.hsieh.dev ✅ 測試場,隨便寫
改法:換那條線再打一次。
⚠️ 若這次真的是 leo 要你動他的真庫(而不是測試):
touch /tmp/.leo21c-mcp-ok # 只放行「下一個」呼叫,15 分鐘內有效、用完即消失
會被記錄到 .claude/hooks/leo21c-mcp-guard.log。
MSG
else
cat >&2 <<MSG
🚫 這個查詢打向 leo21c —— leo 本人的真庫,不是測試場(leo 2026-08-27 立,ISEP#69
leo 原話:「我從頭的指令就叫你去打 youlin,stage 環境是它你不知道嗎?
為什麼打算用我的真庫測試?」
你剛剛呼叫的是:
mcp__${LEO21C_UUID}__${SUFFIX}
→ logged_in_as: leo21c 🔴 真庫
即使只是唯讀查詢,打錯庫依然有害——**你可能正在查錯地方**:
真庫的資料跟測試場的資料是兩份,用真庫的查詢結果去判斷測試進度,
會做出「wiki 沒送上去」「排在 1691 筆後面」這類看起來像結論、其實是查錯庫的假結論
2026-08-27 總管本人今天就連中三次)。
你要的測試場是另一條線(同樣的查詢,換一個 server 前綴):
mcp__${YOULIN_UUID}__${SUFFIX}
→ logged_in_as: youlin.hsieh.dev ✅ 測試場
改法:換那條線再查一次。
⚠️ 若這次真的是要查 leo 自己的庫(他本人有時真的需要):
touch /tmp/.leo21c-mcp-ok # 只放行「下一個」呼叫,15 分鐘內有效、用完即消失
會被記錄到 .claude/hooks/leo21c-mcp-guard.log。
MSG
fi
exit 2
fi
# ── 走到這裡:UUID 兩個都不是 —— 對照表可能過期,不要靜默放行 ─────────
# 這正是檔頭說的「大聲失敗」:某條連線被重裝、UUID 換了,我們認不出它是誰,
# 與其猜測放行、不如逼一次 arcrun_whoami 或人工更新這份表。
trace "⛔ 擋下(未知UUID,清單可能過期)"
cat >&2 <<MSG
🚫 這個 MCP server 的 id 不在 leo21c-mcp-guard.sh 的已知清單裡
已知清單(2026-08-27 由 arcrun_whoami 實測寫死,見本檔檔頭):
${LEO21C_UUID} → leo21c(真庫,預設擋)
${YOULIN_UUID} → youlin(測試場,預設放)
這次打的是:${UUID}
兩個都不是 —— 代表某條 MCP 連線被重裝過、UUID 換了,這份對照表可能已經過期。
寧可先擋、不要靜默放行到一個身份不明的目標。怎麼辦:
1. 呼叫 arcrun_whoami(這支工具本身不會被擋)確認這個 server 真正的身份
2. 如果它是新的 youlin id → 回報 isep-guard 更新 hooks/leo21c-mcp-guard.sh 的對照表
3. 現在就是要放行這一次 → touch /tmp/.leo21c-mcp-ok15 分鐘內有效、用完即消失)
MSG
exit 2
+73 -17
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# subagent-first-guard.sh — PreToolUseWrite|Edit|MultiEdit):
# 要親手改 code,卻**這個 session 一次工都沒派過** → 擋一次,逼你先回答「這件事該不該派出去」。
# 要親手改 code,卻不是被派來做這件事的人 → 擋,逼你先回答「這件事該不該派出去」。
#
# 🔴 立這道閘的來由(leo 2026-08-08):
# 「你記得**要叫 subagent 開工,你負責維護 loop**,而不是你開工後過一陣子停下來對吧?
@@ -15,8 +15,34 @@
# 這道閘負責讓「授權」真的變成「行為」——同 KBDB 那道的教訓:
# **規則被讀到 ≠ 會被執行,要有機制驗證照做**。
#
# 逃生口:真的該自己做(單行修、改 hook 自己、緊急止血、subagent 回報後的收尾)
# → `touch /tmp/.solo-ok-<session_id>` 後重送,並**在回覆裡說明理由**(留痕)。
# 🔴 2026-08-27 改版(inkstone/ISEP#66)——leo 當場撞到:「你禁止寫 code,為什麼你改?
# 為什麼你可以執行?」查出來這道閘同時有三個洞,改完的是這三個:
#
# 洞一:`.sh` 從來不在受管副檔名清單裡。這個 repo 自己的閘全部是 .sh
# ⇒ 總管改任何一支閘,這道閘從第一行 case 就直接放行,路徑白名單根本沒機會判斷。
# 這才是「改 no-ticket-no-dispatch.sh 閘一聲不響」的真正原因,不是路徑豁免。
# → 修法:閘本身(`.claude/hooks/*.sh`、`hooks/*.sh`)獨立收進受管清單,
# 不看副檔名是不是「傳統程式語言」。同時拿掉 `*/.claude/hooks/*` 整目錄豁免——
# 「能改閘的人,等於能關掉所有其他閘」,這句話本身就是拿掉它的理由;
# 真要緊急止血,走下面②的 solo-ok,不需要一扇整目錄常開的門。
# 洞二:「這個 session 派過一次工」被當永久放行條件。但「派過一次」跟
# 「有派工的習慣」是兩件事——派過工只回答了「那一件任務有沒有交出去」,
# 回答不了「這一次任意的手改合不合理」。→ 修法:這個訊號整個拿掉,
# 不再是放行條件(只留來在被擋下時的提示文字裡,讓你看到「你剛剛才派過工,
# 這次怎麼不派」,純資訊、不放行)。
# 洞三:「同一 session 只擋一次」=警報响過一次就對這個 session 的餘生啞掉,
# 等於自己承認「這道閘只在第一次有效」。但本 repo 心法第二條反過來也成立:
# 永遠在響 = 訓練人忽略它。→ 修法:改成「寬限期內不重複鬼打牆,
# 寬限期一過就再響」——兩頭都要顧,不是選一邊犧牲。
#
# 放行只剩三種:
# ① CLAUDE_CODE_CHILD_SESSION=1 —— 這是被派來做事的 subagent 本人,它就是被派來寫的
# ② /tmp/.solo-ok-<session_id> 存在且**非空**(寫了理由,不是 touch 空檔)
# ③ 檔案本身不歸這道閘管(測試檔/system-dev 的 wiki/非受管副檔名)
#
# 逃生口:真的該自己做(單行修、緊急止血、subagent 回報後的收尾)
# → 把理由寫進檔案本身,不是單純 touch:
# echo "單行修 xxx,理由 yyy" > /tmp/.solo-ok-<session_id>
set -eu
INPUT="$(cat)"
@@ -33,34 +59,62 @@ except Exception: print("nosid")
[ -z "$FILE_PATH" ] && exit 0
# 只管 code 檔——文件、SDD、wiki、測試、hook 自己都放行
# (這些本來就常是總管自己該寫的:判準、規格、落帳)
# ① 這是被派來做事的 subagent 本人(不是總管自己)→ 它本來就該寫 code,不歸這道閘管
[ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] && exit 0
# 只管「這是不是該被派工規則管的程式碼」——
# 一般受管語言,加上「閘本身」(即使是 .sh,這正是洞一要補的地方)
case "$FILE_PATH" in
*/.claude/hooks/*.sh|*/hooks/*.sh) ;;
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.go|*.py|*.rs|*.java|*.rb) ;;
*) exit 0 ;;
esac
# 就算是受管檔案,這兩類仍放行:測試檔/頂層 wiki(system-dev/
case "$FILE_PATH" in
*_test.*|*.test.*|*.spec.*|*/tests/*|*/test/*) exit 0 ;;
*/.claude/hooks/*|*system-dev/*) exit 0 ;;
*system-dev/*) exit 0 ;;
esac
[ -f "/tmp/.subagent-spawned-$SID" ] && exit 0 # 這個 session 派過工了 → 放行
[ -f "/tmp/.solo-ok-$SID" ] && exit 0 # 明示要自己做 → 放行(留痕)
# ② 明示「我自己做」且寫了理由(不是空檔)→ 放行
SOLO_OK="/tmp/.solo-ok-$SID"
[ -s "$SOLO_OK" ] && exit 0
NOW=$(date +%s)
NAG_TTL=${SUBAGENT_GUARD_NAG_TTL:-900} # 15 分鐘寬限期:同一輪不重複鬼打牆,過了才再響
WARNED="/tmp/.subagent-guard-warned-$SID"
[ -f "$WARNED" ] && exit 0 # 同一 session 只擋一次,不鬼打牆
if [ -f "$WARNED" ]; then
WARN_TS=$(cat "$WARNED" 2>/dev/null || echo 0)
case "$WARN_TS" in ''|*[!0-9]*) WARN_TS=0 ;; esac
if [ $((NOW - WARN_TS)) -lt "$NAG_TTL" ]; then
exit 0 # 剛擋過,還在寬限期內 → 不重複鬼打牆
fi
fi
date +%s > "$WARNED"
cat >&2 <<'EOF'
🧑‍🏭 派工警察:你要親手改 code,但這個 session **一次工都沒派過**。
# 派過工不是放行條件(洞二),但拿來給脈絡:讓你看到「上次派工是多久前」
DISPATCH_NOTE=""
SPAWNED="/tmp/.subagent-spawned-$SID"
if [ -f "$SPAWNED" ]; then
D_TS=$(cat "$SPAWNED" 2>/dev/null || echo 0)
case "$D_TS" in ''|*[!0-9]*) D_TS=0 ;; esac
if [ "$D_TS" -gt 0 ] 2>/dev/null; then
MIN_AGO=$(( (NOW - D_TS) / 60 ))
DISPATCH_NOTE="
【這個 session 派過工】最近一次是 ${MIN_AGO} 分鐘前——
但「派過一次」不等於「這次也不用派」,兩件事分開判斷。"
fi
fi
cat >&2 <<EOF
🧑‍🏭 派工警察:你要親手改 code。${DISPATCH_NOTE}
【leo 2026-08-08】「你記得**要叫 subagent 開工,你負責維護 loop**
而不是你開工後過一陣子停下來對吧?」
【已知的環境陷阱】Claude Desktop 這個 surface 的 system prompt 有
`Do not call the AgentTool unless the user requested it`,會壓過 CLAUDE.md 規則三點五。
**leo 已在 CLAUDE.md 給出常駐授權**(規則三點五的「常駐授權」段)=那個條件永久滿足,
不必再等他開口。
【leo 2026-08-27】「你禁止寫 code,為什麼你改?為什麼你可以執行?」
——這句話問的就是現在這一次。
先回答一句(答不出來就是該派):
這件事**為什麼不能交給 subagent**
@@ -69,6 +123,8 @@ cat >&2 <<'EOF'
✅ 該自己做:判準/規格/落帳(那些檔本來就放行)、subagent 回報後的收尾裁決、
單行修、緊急止血
決定自己做 ⇒ `touch /tmp/.solo-ok-$SID` 後重送,並**在回覆裡說明理由**。
決定自己做 ⇒ 把理由寫進檔案本身(不是 touch 空檔):
echo "理由:xxx" > /tmp/.solo-ok-$SID
再重送這次操作。
EOF
exit 2
+6
View File
@@ -4,6 +4,12 @@
# 配 subagent-first-guard.sh 使用(同 kbdb-asked-stamp.sh 的形狀)。
# 只留時戳,不擋任何東西。
#
# 🔴 2026-08-27inkstone/ISEP#66):這個時戳**不再是放行條件**。
# 「派過一次工」曾經被 subagent-first-guard.sh 當成「這個 session 永久放行」——
# 但那混淆了「這件任務有沒有交出去」跟「下一次任意的手改合不合理」,是兩件事。
# 現在這支只負責留時戳,給被擋下時的訊息當脈絡用(「上次派工是幾分鐘前」),
# 純資訊、不影響放行判斷。
#
# 🔴 為什麼要按 session 分開存(而不是像 KBDB 那道用全域+1 小時 TTL):
# 「這一輪工作有沒有先考慮派工」是**每個 session 各自要回答**的問題。
# 用全域檔的話,昨天派過一次就等於永久解鎖,這道閘會在第二天起完全失效。
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env bash
# leo21c-mcp-guard.sh 的迴歸測試(ISEP#69)。
HOOK="$1"
rm -f /tmp/.leo21c-mcp-ok 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> <說明> <tool_name> —— 除了明說放行那幾格,其餘每格開跑前先確保沒有殘留戳記
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
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
# subagent-first-guard 的測試(inkstone/ISEP#66
#
# 補的三個洞:
# 洞一:.sh 從沒進過受管副檔名清單 ⇒ 這個 repo 自己的閘(全是 .sh)從沒被這道閘管過。
# 洞二:「這個 session 派過一次工」被當永久放行——拿掉,不再是放行條件。
# 洞三:「同一 session 只擋一次」讓警報響過一次就對餘生啞掉——改成寬限期,過了再響。
#
# 判準:派過工不再免死金牌/閘本身(.sh)進受管清單/寬限期內不鬼打牆、過了要再響/
# CHILD_SESSIONsubagent 本人放行/測試檔・system-dev wiki 放行/
# solo-ok 要非空(寫理由)才放行,單純 touch 不算。
cd "$(dirname "$0")/.." || exit 1
H=hooks/subagent-first-guard.sh
PASS=0; FAIL=0
run(){ # $1=want_exit $2=file_path $3=sid $4=child(0/1)
local want="$1" fp="$2" sid="$3" child="${4:-0}"
local payload
payload=$(python3 -c 'import json,sys;print(json.dumps({"tool_name":"Edit","tool_input":{"file_path":sys.argv[1]},"session_id":sys.argv[2]}))' "$fp" "$sid")
if [ "$child" = "1" ]; then
got=$(CLAUDE_CODE_CHILD_SESSION=1 SUBAGENT_GUARD_NAG_TTL="${TTL:-900}" bash -c 'printf "%s" "$1" | bash "$2" >/dev/null 2>&1; echo $?' _ "$payload" "$H")
else
got=$(env -u CLAUDE_CODE_CHILD_SESSION SUBAGENT_GUARD_NAG_TTL="${TTL:-900}" bash -c 'printf "%s" "$1" | bash "$2" >/dev/null 2>&1; echo $?' _ "$payload" "$H")
fi
if [ "$got" = "$want" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=%s got=%s sid=%s %.70s\n' "$want" "$got" "$sid" "$fp"
}
clean(){ rm -f "/tmp/.subagent-spawned-$1" "/tmp/.solo-ok-$1" "/tmp/.subagent-guard-warned-$1"; }
R="/Users/youlinhsieh/Documents/tech_projects/InkStoneCo"
ISEP="/Users/youlinhsieh/Documents/tech_projects/ISEP"
echo "── 洞一:閘本身是 .sh,也要被這道閘管(重演 2026-08-27 那次)──"
S=t66-hole1-a; clean "$S"
run 2 "$R/.claude/hooks/no-ticket-no-dispatch.sh" "$S" 0
S=t66-hole1-b; clean "$S"
run 2 "$ISEP/hooks/subagent-first-guard.sh" "$S" 0
echo "── 洞二:派過工不是免死金牌——就算剛派、就算派了很多次,直接手改一樣要擋 ──"
S=t66-hole2-a; clean "$S"
date +%s > "/tmp/.subagent-spawned-$S" # 剛派過工(此刻)
run 2 "$R/.claude/hooks/no-ticket-no-dispatch.sh" "$S" 0
S=t66-hole2-b; clean "$S"
echo $(( $(date +%s) - 3600 )) > "/tmp/.subagent-spawned-$S" # 一小時前派過(今天真實情境的重演)
run 2 "$R/.claude/hooks/no-ticket-no-dispatch.sh" "$S" 0
echo "── 洞三:警報不能響一次就對餘生啞掉——寬限期內不鬼打牆,過了要再響 ──"
S=t66-hole3; clean "$S"; TTL=2
run 2 "$R/matrix/arcrun/foo.py" "$S" 0 # 第一次:沒見過 → 擋,順便留下 warned 時戳
run 0 "$R/matrix/arcrun/foo.py" "$S" 0 # 馬上重試:寬限期內 → 不鬼打牆,放行
sleep 3
run 2 "$R/matrix/arcrun/foo.py" "$S" 0 # 寬限期過了 → 再響
TTL=900
echo "── 不該擋:subagent 本人(CHILD_SESSION=1)自己寫它被派來寫的 code ──"
S=t66-child; clean "$S"
run 0 "$R/.claude/hooks/no-ticket-no-dispatch.sh" "$S" 1
run 0 "$R/matrix/arcrun/foo.py" "$S" 1
echo "── 不該擋:測試檔 ──"
S=t66-test; clean "$S"
run 0 "$R/matrix/arcrun/foo_test.py" "$S" 0
run 0 "$R/matrix/arcrun/foo.test.ts" "$S" 0
run 0 "$ISEP/tests/foo.spec.js" "$S" 0
echo "── 不該擋:system-dev/ 的 wiki(即使副檔名受管)──"
S=t66-wiki; clean "$S"
run 0 "$R/system-dev/wiki/status.md" "$S" 0
run 0 "$R/system-dev/docs/some_script.py" "$S" 0
echo "── 不該擋:非受管副檔名(一般 .sh 不在 hooks 目錄/.md.json)──"
S=t66-nonguard; clean "$S"
run 0 "$R/scripts/deploy.sh" "$S" 0
run 0 "$R/README.md" "$S" 0
run 0 "$R/package.json" "$S" 0
echo "── solo-ok:要非空(寫理由)才放行,單純 touch 不算 ──"
S=t66-soloempty; clean "$S"
touch "/tmp/.solo-ok-$S"
run 2 "$R/matrix/arcrun/foo.py" "$S" 0
clean "$S"
echo "理由:單行修,緊急止血" > "/tmp/.solo-ok-$S"
run 0 "$R/matrix/arcrun/foo.py" "$S" 0
clean "$S"
echo "── 真違規不能因為這次改動漏擋:全新 session 直接改一般 code ──"
S=t66-fresh; clean "$S"
run 2 "$R/matrix/arcrun/graph-executor.ts" "$S" 0
# 清理
for s in t66-hole1-a t66-hole1-b t66-hole2-a t66-hole2-b t66-hole3 t66-child t66-test t66-wiki t66-nonguard t66-soloempty t66-fresh; do clean "$s"; done
echo
echo "$PASS/$((PASS+FAIL)) 通過"
[ "$FAIL" -eq 0 ]