From e177f6372b01588ad34cbffc9f8921f5f0dad8d7 Mon Sep 17 00:00:00 2001 From: richblack Date: Thu, 27 Aug 2026 14:06:06 +0800 Subject: [PATCH] =?UTF-8?q?subagent-first-guard=20=E8=A3=9C=E4=B8=89?= =?UTF-8?q?=E5=80=8B=E6=B4=9E=EF=BC=9A=E9=96=98=E4=B8=8D=E7=AE=A1=20.sh?= =?UTF-8?q?=E3=80=81=E6=B4=BE=E9=81=8E=E5=B7=A5=E8=AE=8A=E5=85=8D=E6=AD=BB?= =?UTF-8?q?=E9=87=91=E7=89=8C=E3=80=81=E8=AD=A6=E5=A0=B1=E9=9F=BF=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E5=B0=B1=E5=95=9E=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude-plugin/plugin.json | 2 +- docs/hooks-inventory.md | 4 +- hooks/subagent-first-guard.sh | 90 +++++++++++++++++++++----- hooks/subagent-first-stamp.sh | 6 ++ scripts/test-subagent-first-guard.sh | 96 ++++++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 20 deletions(-) create mode 100755 scripts/test-subagent-first-guard.sh diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e4eea3a..01b9e3a 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", + "version": "0.5.1", "keywords": [ "inkstone", "guardrails", diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 23695ea..d12639f 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -56,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 票。 | 🛑 擋 | @@ -126,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) diff --git a/hooks/subagent-first-guard.sh b/hooks/subagent-first-guard.sh index 8404f55..f7ac04c 100755 --- a/hooks/subagent-first-guard.sh +++ b/hooks/subagent-first-guard.sh @@ -1,6 +1,6 @@ #!/bin/sh # subagent-first-guard.sh — PreToolUse(Write|Edit|MultiEdit): -# 要親手改 code,卻**這個 session 一次工都沒派過** → 擋一次,逼你先回答「這件事該不該派出去」。 +# 要親手改 code,卻不是被派來做這件事的人 → 擋,逼你先回答「這件事該不該派出去」。 # # 🔴 立這道閘的來由(leo 2026-08-08): # 「你記得**要叫 subagent 開工,你負責維護 loop**,而不是你開工後過一陣子停下來對吧? @@ -15,8 +15,34 @@ # 這道閘負責讓「授權」真的變成「行為」——同 KBDB 那道的教訓: # **規則被讀到 ≠ 會被執行,要有機制驗證照做**。 # -# 逃生口:真的該自己做(單行修、改 hook 自己、緊急止血、subagent 回報後的收尾) -# → `touch /tmp/.solo-ok-` 後重送,並**在回覆裡說明理由**(留痕)。 +# 🔴 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- 存在且**非空**(寫了理由,不是 touch 空檔) +# ③ 檔案本身不歸這道閘管(測試檔/system-dev 的 wiki/非受管副檔名) +# +# 逃生口:真的該自己做(單行修、緊急止血、subagent 回報後的收尾) +# → 把理由寫進檔案本身,不是單純 touch: +# echo "單行修 xxx,理由 yyy" > /tmp/.solo-ok- 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 <&2 <<'EOF' ✅ 該自己做:判準/規格/落帳(那些檔本來就放行)、subagent 回報後的收尾裁決、 單行修、緊急止血 -決定自己做 ⇒ `touch /tmp/.solo-ok-$SID` 後重送,並**在回覆裡說明理由**。 +決定自己做 ⇒ 把理由寫進檔案本身(不是 touch 空檔): + echo "理由:xxx" > /tmp/.solo-ok-$SID + 再重送這次操作。 EOF exit 2 diff --git a/hooks/subagent-first-stamp.sh b/hooks/subagent-first-stamp.sh index fbdceb7..7996554 100755 --- a/hooks/subagent-first-stamp.sh +++ b/hooks/subagent-first-stamp.sh @@ -4,6 +4,12 @@ # 配 subagent-first-guard.sh 使用(同 kbdb-asked-stamp.sh 的形狀)。 # 只留時戳,不擋任何東西。 # +# 🔴 2026-08-27(inkstone/ISEP#66):這個時戳**不再是放行條件**。 +# 「派過一次工」曾經被 subagent-first-guard.sh 當成「這個 session 永久放行」—— +# 但那混淆了「這件任務有沒有交出去」跟「下一次任意的手改合不合理」,是兩件事。 +# 現在這支只負責留時戳,給被擋下時的訊息當脈絡用(「上次派工是幾分鐘前」), +# 純資訊、不影響放行判斷。 +# # 🔴 為什麼要按 session 分開存(而不是像 KBDB 那道用全域+1 小時 TTL): # 「這一輪工作有沒有先考慮派工」是**每個 session 各自要回答**的問題。 # 用全域檔的話,昨天派過一次就等於永久解鎖,這道閘會在第二天起完全失效。 diff --git a/scripts/test-subagent-first-guard.sh b/scripts/test-subagent-first-guard.sh new file mode 100755 index 0000000..d795bbe --- /dev/null +++ b/scripts/test-subagent-first-guard.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# subagent-first-guard 的測試(inkstone/ISEP#66) +# +# 補的三個洞: +# 洞一:.sh 從沒進過受管副檔名清單 ⇒ 這個 repo 自己的閘(全是 .sh)從沒被這道閘管過。 +# 洞二:「這個 session 派過一次工」被當永久放行——拿掉,不再是放行條件。 +# 洞三:「同一 session 只擋一次」讓警報響過一次就對餘生啞掉——改成寬限期,過了再響。 +# +# 判準:派過工不再免死金牌/閘本身(.sh)進受管清單/寬限期內不鬼打牆、過了要再響/ +# CHILD_SESSION=subagent 本人放行/測試檔・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 ]