#!/bin/sh # subagent-first-guard.sh — PreToolUse(Write|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- 存在且**非空**(寫了理由,不是 touch 空檔) # ③ 檔案本身不歸這道閘管(測試檔/system-dev 的 wiki/非受管副檔名) # # 逃生口:真的該自己做(單行修、緊急止血、subagent 回報後的收尾) # → 把理由寫進檔案本身,不是單純 touch: # echo "單行修 xxx,理由 yyy" > /tmp/.solo-ok- 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 < /tmp/.solo-ok-$SID 再重送這次操作。 EOF exit 2