分層原則
Hook 是強制層,CLAUDE.md 的常駐文字是判斷層。兩者並存,不互相取代。
Hook 只看得到 Bash 指令的字面。Claude 走 MCP 工具、走 Edit、或在還沒下指令的判斷
階段就決定要做某件事時,hook 完全看不到。把一條安全禁令只寫進 hook,等於只擋住了
Bash 那一條路徑。
因此:安全禁令的文字不因為有了 hook 就從 CLAUDE.md 移出。 新增 hook 是加上一層
強制,不是把規則搬家。
入選判準
一句話:漏做一次就會出事嗎?
| 情況 | 該進 hook 嗎 |
|---|---|
| 零例外必須被擋,且代價不可逆 | 是 |
| 壞掉時無聲——不報錯、不警告 | 是。沒有 hook 就沒有人會發現 |
| 需要看上下文才知道對不對 | 否,留在 CLAUDE.md |
| 只是「最好這樣做」 | 否 |
第二列是 worktree-hookspath-fix.sh 的存在理由:git 找不到 core.hooksPath 指向的目錄
就直接不跑 hook,不報錯也不警告。除非有人主動去 git config --get core.hooksPath,
否則沒人會發現防護已經沒了。
deny 與 ask
| 決定 | 什麼時候用 | 例子 |
|---|---|---|
deny |
零例外,且有明確的替代路徑可以講給呼叫者聽 | 直推預設分支 → 走 feature 分支開 PR |
ask |
動作本身合法,對不對取決於只有呼叫者知道的上下文 | rm -rf 的目標路徑是不是真的那一個 |
deny 的理由必須包含替代路徑。只說「不准」會讓呼叫者原地重試或繞路,兩種都比
放行更糟。
匹配紀律
三條都有實戰來源,不是預防性的潔癖。
錨定真實的指令位置,不比對裸字串。
git-guard.sh早期用裸子字串比對跳過 hook 的旗標,結果echo "... <旗標> ..."被 擋,連 hook 檔本身都無法透過 Bash 編輯。現在錨定在行首,或;&|(&&||之後。殘留誤判:引號內看起來像 git 指令的字串仍可能命中——要根治需要真正的 shell parser,不值得那個複雜度,所以寫進 catalog 的「不攔什麼」欄位而不是假裝沒有。列舉選項,不用開放式 pattern。
git[^;&|]*push會命中git log --grep push,而它不 push 任何東西。git-guard.sh改為列舉 git 的 global option(-C、-c、--git-dir=、--work-tree=、--no-pager、-P)再接push。解析失敗一律退回最保守的答案,絕不因此放行。
git-guard.sh判斷「這個指令推去哪個 repo」的順序是git -C <path>→ push 之前 最後一個cd→ session cwd。路徑含變數、subshell、glob,或根本不是工作樹時,退回 session cwd 再判一次,不會因為解析不出來就放行。 這條的來源是一次誤擋:早期版本問的是「session repo 在不在預設分支」,而不是「這個 指令推去哪個 repo 的哪個分支」,於是在 repo B 推 feature 分支,被 repo A 的 checkout 狀態擋下。
失敗紀律
- 任何錯誤
exit 0,絕不阻斷 session。 hook 自己壞掉不該讓使用者無法工作。 - 冪等。 同一支腳本可能掛在多個事件上(例如
SessionStart加PostToolUse), 重複執行不得產生第二次副作用。 - 修復型 hook 必須提供停用開關,用 env var,例如
CLAUDE_HOOKSPATH_FIX_DISABLE=1。 它會改寫狀態,壞掉時要有一條不改檔案就能關掉的路徑。純deny/ask的守衛不強制 ——移除註冊就等於關掉,而守衛多一個開關等於多一條繞過路徑。三支現行守衛裡只有worktree-hookspath-fix.sh有開關,這是刻意的分界。 - 外部工具不可用時的行為必須是明確的選擇。 三支守衛都用
jq解析 stdin;jq不在 時取到空字串、比對不中、放行——那是 fail-open。這個取捨可以接受,但要寫進references/hook-catalog.md的「不攔什麼」,不能讓它變成沒人知道的預設。 - 環境類修正不動全域設定。 優先用 env var、臨時設定檔、單次指令參數。動全域設定 留下的副作用不會出現在任何一次 review 的 diff 裡。
輸出格式
輸入從 stdin 進來。Bash 指令在 .tool_input.command。用 jq 取值時一律給 // ""
預設值——jq 不存在或欄位缺席都不能讓 hook 崩掉。
PreToolUse 用 stdout 回一個 JSON 決定:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "為什麼被擋,以及替代路徑"
}
}
permissionDecision 取 deny 或 ask。不輸出 JSON 就是放行,所以「什麼都不做」
必須是安全的預設。
SessionStart 用 additionalContext 注入文字:
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "要注入的內容"
}
}
注入前先 strip:shell 的 herestring 會補一個換行,不 strip 的話「無事發生」也會注入
一個 \n。
註解義務
每支 hook 的檔頭必須回答三個問題:
- 它擋什麼、修什麼
- 為什麼這是 hook,而不是
CLAUDE.md的一行字 - 踩過的坑:曾經誤擋什麼、為什麼改成現在的形狀
第 3 點不是註解禮儀。誤擋修好之後留下的形狀通常看起來很繞,下一個人會想把它「簡化」 掉——除非檔案裡寫著上次為什麼不能那樣寫。
scripts/hook-standards-policy.test.mjs 會檢查每支腳本的檔頭有沒有回答第 2 點。
安裝與更新
本 plugin 內的腳本是真身,但 Claude Code 不會自動註冊它們。安裝分兩步:複製檔案, 再自己寫註冊。
一、複製腳本
版本目錄用解析的,不要手填——快取裡通常留著多個版本,填錯會複製到舊版而且不會報錯:
SRC=$(ls -d "$HOME"/.claude/plugins/cache/jurislm-tools/hook-standards/*/skills/hook-standards/scripts | sort -V | tail -1)
mkdir -p "$HOME/.claude/hooks" && cp "$SRC"/*.sh "$HOME/.claude/hooks/" && chmod +x "$HOME"/.claude/hooks/*.sh
二、註冊
寫進 ~/.claude/settings.json 的 hooks 區塊。三支腳本對應的事件、matcher 與 timeout
如下,這是完整內容,照抄即可:
{
"hooks": {
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/worktree-hookspath-fix.sh"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/git-guard.sh",
"if": "Bash(git *)",
"timeout": 10,
"statusMessage": "檢查 git 安全禁令"
},
{
"type": "command",
"command": "$HOME/.claude/hooks/destructive-guard.sh",
"timeout": 10,
"statusMessage": "檢查破壞性指令"
}
]
}
],
"PostToolUse": [
{
"matcher": "EnterWorktree",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/worktree-hookspath-fix.sh"
}
]
}
]
}
}
git-guard.sh 帶 if 條件只跑在 git 指令上;destructive-guard.sh 不帶條件,所有 Bash
指令都過一遍。worktree-hookspath-fix.sh 掛兩個事件是刻意的——開新 worktree 當下就要
修,不能等到下次 session 才修。
若 settings.json 已有 hooks 區塊,是合併進去而不是整段覆蓋。手改 JSON 容易改壞,
有 update-config Skill 的環境用它操作;沒有的話改完至少驗一次:
python3 -m json.tool "$HOME/.claude/settings.json" > /dev/null && echo "settings.json 格式正確"
註冊後開一個新 session 才會生效。驗證方式是餵一條該被擋的指令給腳本,看它印出決策:
echo '{"tool_input":{"command":"git push origin main"}}' | bash "$HOME/.claude/hooks/git-guard.sh"
腳本只印 JSON 決策,不會執行那條指令,所以這個探針是安全的。沒有輸出代表放行——
若預期該被擋卻沒有輸出,先確認 jq 是否存在。
三、更新
plugin 更新之後要重新跑第一步。兩個刻意的設計,改動前先讀懂:
- 不要讓
settings.json直接指向 plugin 內的路徑。 快取路徑帶版號 (~/.claude/plugins/cache/jurislm-tools/<plugin>/<version>/),每次 release 就換一個 目錄。舊目錄不會自動清掉,所以指過去的實際結果通常不是「路徑斷掉」,而是繼續跑 舊版腳本;目錄真的被清掉時才變成 hook 靜默不執行。兩種都不報錯。 - 本 plugin 不提供
hooks/hooks.json,plugin.json也不帶hooks欄位。 兩者都會讓 Claude Code 自動掛上事件——manifest 的hooks欄位可以指向任意路徑,而且與預設位置 是合併而不是取代。任一存在都會與上面那份手寫註冊雙重觸發,同一個 Bash 指令跑 兩次 guard。scripts/hook-standards-policy.test.mjs兩條路徑都擋。
現行守衛
逐支規格見 references/hook-catalog.md,含每支「明確不攔什麼」的已知盲點。
| 腳本 | 事件 | 做什麼 |
|---|---|---|
git-guard.sh |
PreToolUse / Bash |
擋跳過 hook 的旗標、擋 push 到預設分支 |
destructive-guard.sh |
PreToolUse / Bash |
破壞性指令改為先問再放行 |
worktree-hookspath-fix.sh |
SessionStart、PostToolUse / EnterWorktree |
自癒 worktree 被寫入的絕對 core.hooksPath |