e177f6372b
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>
131 lines
6.5 KiB
Bash
Executable File
131 lines
6.5 KiB
Bash
Executable File
#!/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-<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
|