Files
ISEP/hooks/subagent-first-guard.sh
T
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

131 lines
6.5 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/sh
# subagent-first-guard.sh — PreToolUseWrite|Edit|MultiEdit):
# 要親手改 code,卻不是被派來做這件事的人 → 擋,逼你先回答「這件事該不該派出去」。
#
# 🔴 立這道閘的來由(leo 2026-08-08):
# 「你記得**要叫 subagent 開工,你負責維護 loop**,而不是你開工後過一陣子停下來對吧?
# 問題是這個規定**如何不要我說就做到**?因為在 vscode 裡你自然會這麼做,
# 但現在在 claude desktop 你就會自己做問個問題停下來。」
#
# 查出來的根因(所以這道閘才有意義,不是加強語氣):
# Claude Desktop 這個 surface 的 system prompt 有
# `Do not call the AgentTool unless the user requested it`
# 它**壓過** CLAUDE.md 規則三點五 ⇒ 同一份規則在兩個 surface 行為不同。
# leo 已於 2026-08-08 在 CLAUDE.md 給出常駐授權(=永久滿足「user requested」),
# 這道閘負責讓「授權」真的變成「行為」——同 KBDB 那道的教訓:
# **規則被讀到 ≠ 會被執行,要有機制驗證照做**。
#
# 🔴 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)"
FILE_PATH=$(printf '%s' "$INPUT" | python3 -c '
import sys, json
try: print(json.load(sys.stdin).get("tool_input", {}).get("file_path", "") or "")
except Exception: print("")
' 2>/dev/null || echo "")
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)
[ -z "$FILE_PATH" ] && exit 0
# ① 這是被派來做事的 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 ;;
*system-dev/*) exit 0 ;;
esac
# ② 明示「我自己做」且寫了理由(不是空檔)→ 放行
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"
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"
# 派過工不是放行條件(洞二),但拿來給脈絡:讓你看到「上次派工是多久前」
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**
而不是你開工後過一陣子停下來對吧?」
【leo 2026-08-27】「你禁止寫 code,為什麼你改?為什麼你可以執行?」
——這句話問的就是現在這一次。
先回答一句(答不出來就是該派):
這件事**為什麼不能交給 subagent**
✅ 該派:實作一個 task、跨 repo 施工、偵察盤點、批次修改、寫測試
✅ 該自己做:判準/規格/落帳(那些檔本來就放行)、subagent 回報後的收尾裁決、
單行修、緊急止血
決定自己做 ⇒ 把理由寫進檔案本身(不是 touch 空檔):
echo "理由:xxx" > /tmp/.solo-ok-$SID
再重送這次操作。
EOF
exit 2