把 Claude Code 從「我盯著它寫」升級到「讓它自己跑一段時間」之後,多半都會碰到一些小事故:一次誤判把 .git 整個砍光、所有版控歷史和未推送的 branch 一起送葬、一次自動 git push --force 把 main 分支爛掉、一次想看設定檔結果順手讀到 .env 把 OpenAI API key 送進對話脈絡。功能上 Claude Code 內建的 permission 模型可以白名單/黑名單擋掉一部分風險,但粒度比較粗——禁止整個 Bash 工具會讓 agent 寸步難行,全開又怕誤觸。這時 PreToolUse hook 就是補上中間那塊:在工具實際執行前先攔截下來,做條件式判斷。
前一篇 Claude Code Hooks 教學 講的是 PostToolUse 接 Prettier,做事後格式化。這篇換到事前守門員 PreToolUse 上,用三個實戰場景走完:擋住 rm -rf 之類的危險指令、保護 main 分支不被 force push、防止敏感檔案被讀寫送出。順便把 hook 跟 Claude Code 內建 permissions 的分工說清楚,最後整理常見的偵錯方向。
PreToolUse 在做什麼
每一次 Claude Code 想呼叫工具(Bash、Edit、Write、Read、Grep 等),執行前都會先過一輪 PreToolUse。Claude Code 把當下的工具名稱、輸入參數、session id、cwd 等資訊整包成 JSON,從 stdin 餵給 hook 指定的指令,等指令跑完才決定這次工具呼叫要不要放行。整個時序大概是這樣:
- 使用者送出 prompt → Claude 思考 → 決定要呼叫某個工具
- Claude Code 先過內建的 permissions 規則(allow / deny / ask 白名單與黑名單)
- 通過 permissions 後,進入 PreToolUse hook,這是工具實際執行前的最後一道閘門
- hook 放行 → 工具實際執行 → PostToolUse → 工具結果回給 Claude
跟 PostToolUse 相比,PreToolUse 多了一個關鍵能力:它可以阻止工具被執行。PostToolUse 拿到的是「事情已經發生」的結果,能做的最多是讓 Claude 知道結果不對、請它修;PreToolUse 則是還沒發生,能直接攔下來。對「不可逆的動作」(刪檔、force push、外送 API、送 Slack 訊息)來說,這個差別非常重要。

Hook 跟 Claude Code 怎麼對話
在跳到「怎麼擋」之前,先把「hook 是什麼」這件事說清楚,後面的設計才容易讀懂。前一篇講 PostToolUse 自動跑 Prettier 時已經帶過,這邊再簡單複習:在 Claude Code 眼裡,hook 不是什麼特別的東西,就是一支「被丟一段 JSON 進 stdin 的 shell script」。Claude Code 啟動它、把工具呼叫的上下文 JSON 餵給 stdin、等它跑完,然後檢查兩件事——這支 script 的退出碼、以及它在 stdout 寫了什麼。
退出碼是 Unix shell 從 1970 年代就有的慣例:每支程式結束時都會回一個 0 到 255 的整數,0 代表「跑成功」、非 0 代表「跑失敗」。Bash 跑完 echo $? 就能看到。Hook 也是同一套規則,不需要學新語法,平常 shell script 怎麼寫退出碼,hook 就怎麼寫。
PreToolUse 在這個慣例之上多定義了一條特殊規則:退出碼 2 表示「擋下這次工具呼叫」。其他非 0 退出碼還是當普通錯誤處理,只會把訊息顯示給使用者;唯獨 2 這個值會被 Claude Code 接住,把當下的工具呼叫整件事中止,並且把 hook 的 stderr 內容回灌給 Claude 當「為什麼被擋」的理由。如果除了單純擋掉之外、還想表達更細的指令(例如「擋掉,但跳個確認框讓使用者選」),就改用 stdout 輸出一段 JSON 給 Claude Code 解析,下面那節會展開。
換句話說,hook 跟 Claude Code 之間有兩個檔位:簡單情況用退出碼(0 放行、2 擋下),進階情況用 JSON。兩種寫法都會在後面實作裡用到,先從最簡單的「退出碼 2 擋下」開始。
阻擋的兩種寫法:退出碼 2 或 JSON
有了上面的基礎,這兩種寫法的差別與適用場合就比較好理解:
- 退出碼
2(最簡單):script 最後echo "理由" >&2寫一行說明、然後exit 2,工具呼叫就被擋下、理由也回灌給 Claude。沒有第三種選項、不用分支判斷時最快 - JSON 輸出(進階):script 不靠退出碼說話,而是
echo一段合法 JSON 到 stdout,exit 0。Claude Code 會解析hookSpecificOutput.permissionDecision欄位,三種值對應三種行為"allow":直接放行,順便跳過 Claude Code 內建的使用者確認框"deny":阻擋,把permissionDecisionReason內容餵回給 Claude(跟退出碼 2 結果一樣,多了個結構化欄位填理由)"ask":跳出對話框讓使用者親手按下確認,這是退出碼版本做不到的能力
實務上的取捨:純粹擋掉一個指令、不需要分支判斷時用退出碼 2 最直覺;要做到「擋掉、放行、或跳確認框」三選一,就改用 JSON。ask 模式特別適合 git push --force、rm -rf 這種「九成情況沒事、一成情況很糟」的指令,把判斷權留給人類比 hook 邏輯自己決定來得安全。

實作一:擋下 rm -rf 與危險刪除
第一個經典場景:防止 Claude 在 Bash 工具裡跑出毀滅性的 rm -rf。設定寫在專案的 .claude/settings.json(沒這資料夾就 mkdir -p .claude):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/guard-rm.sh",
"timeout": 5
}
]
}
]
}
}
對應的 .claude/hooks/guard-rm.sh:
#!/usr/bin/env bash
# .claude/hooks/guard-rm.sh
# 攔截 Bash 工具裡的危險 rm 指令
set -euo pipefail
# Claude Code 用 stdin 傳 JSON 進來
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // ""')
# 用 regex 比對危險 pattern;多寫幾種變形避免被空白或 -fr 順序繞過
if echo "$cmd" | grep -Eq 'rm[[:space:]]+(-[a-zA-Z]*[rf][a-zA-Z]*[[:space:]]+)+(/|~|\$HOME|\*)'; then
echo "已阻擋:偵測到危險的 rm 指令「$cmd」。請改用較小範圍的刪除,或先告訴使用者要做什麼。" >&2
exit 2
fi
# 沒命中就放行
exit 0
這支 script 做三件事:從 stdin 讀 JSON、用 jq 拆出實際要跑的 Bash 指令、用 regex 比對危險 pattern。命中就印錯誤訊息到 stderr 並 exit 2,Claude Code 看到退出碼 2 就會擋下這次 Bash 呼叫,並把 stderr 內容塞回給 Claude 當作「下一步該怎麼做」的提示——它讀到之後通常會改變策略,先問使用者要刪什麼、或改用更精準的刪除路徑。
幾個容易踩到的細節:regex 要考慮 rm -rf、rm -fr、rm -rf(多個空白)、rm -r -f 等變形;目標 pattern (/|~|\$HOME|\*) 涵蓋了「砍根目錄、砍家目錄、砍變數展開、砍萬用字元」幾種高危情境,但小範圍的 rm -rf node_modules、rm -rf build/ 會放行——這是刻意保留的彈性,Claude 重建依賴或清理 build 產物是合理需求,全擋會讓 agent 不能用。要更嚴格的話可以再加 node_modules、.git、.env 等敏感目錄到 deny 列表,後面實作三會延伸這個做法。
記得幫 script 加上執行權限,後面所有 script 都一樣:
chmod +x .claude/hooks/guard-rm.sh
實作二:保護 main 分支不被 force push
第二個場景:擋住 git push --force、特別是推到 main 或 master 的情況。這次改用 JSON 輸出的 permissionDecision: "ask",讓使用者親手按下確認,比直接 deny 更實用——畢竟有些情境(個人 feature branch 整理 commit)force push 是合理的,只是 main 分支不能亂來。
#!/usr/bin/env bash
# .claude/hooks/guard-git-push.sh
# 偵測 git push 的高風險組合,改成 ask 模式
set -euo pipefail
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // ""')
# 不是 git push 直接放行
if ! echo "$cmd" | grep -Eq '(^|[[:space:];&|])git[[:space:]]+push'; then
exit 0
fi
# 抓出 force 標誌與目標 branch
is_force=$(echo "$cmd" | grep -Eq -- '--force([^-]|$)|--force-with-lease|[[:space:]]-f([[:space:]]|$)' && echo yes || echo no)
is_protected=$(echo "$cmd" | grep -Eq '(main|master)([[:space:]]|$)' && echo yes || echo no)
if [ "$is_force" = "yes" ] && [ "$is_protected" = "yes" ]; then
# 高風險:force push 到 main / master,改成請使用者確認
jq -n --arg cmd "$cmd" '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": ("即將 force push 到 main/master:" + $cmd + "。請確認這是預期行為。")
}
}'
exit 0
fi
# 其餘 git push 放行
exit 0
對應的 settings.json 新增一筆 PreToolUse 設定即可。如果想要同時啟用實作一的 rm 守門,兩支 script 可以掛在同一個 matcher 下、由 Claude Code 依序執行:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": ".claude/hooks/guard-rm.sh", "timeout": 5 },
{ "type": "command", "command": ".claude/hooks/guard-git-push.sh", "timeout": 5 }
]
}
]
}
}
實際跑起來的效果:Claude 想 git push --force origin main 時,畫面會跳出確認框問「即將 force push 到 main/master,請確認這是預期行為」,按取消就回到 Claude 讓它另尋他法、按允許才真的執行。把這條規則 commit 進專案的 .claude/settings.json,整個團隊用 Claude Code 都會自動套用。
同一個 script 套路可以擴成更完整的 git 守門員,像是擋 git reset --hard HEAD~、擋 git checkout -- 把未 commit 的修改丟掉、擋 git branch -D main 等。原則是「不可逆 + 影響共用狀態」的指令都值得包進 ask 模式。
實作三:保護敏感檔案不被讀寫
第三個場景比前兩個微妙:阻止 Claude 讀到 .env、credentials.json、SSH 私鑰之類的敏感檔案。寫入很好擋——matcher 設 Edit|Write 加一個檔名比對即可;難的是讀取,因為 Claude Code 至少有四種方式讀檔:Read 工具直接讀、Grep 工具搜內容、Bash 工具裡 cat .env、甚至 Bash 用 python -c "open('.env').read()" 繞遠路。
先處理直接的部分。把 Read、Edit、Write、Grep 四個工具共用一支守門 script:
#!/usr/bin/env bash
# .claude/hooks/guard-secret-file.sh
# 攔截直接讀寫敏感檔案
set -euo pipefail
input=$(cat)
tool=$(echo "$input" | jq -r '.tool_name // ""')
# 不同工具放路徑的欄位不一樣
case "$tool" in
Read|Edit|Write)
target=$(echo "$input" | jq -r '.tool_input.file_path // ""')
;;
Grep)
target=$(echo "$input" | jq -r '.tool_input.path // .tool_input.glob // ""')
;;
*)
exit 0
;;
esac
# 敏感檔案 pattern(依專案調整)
if echo "$target" | grep -Eq '(^|/)\.env(\..+)?$|(^|/)credentials\.json$|\.pem$|\.key$|(^|/)id_rsa(\.pub)?$|(^|/)\.git/'; then
jq -n --arg t "$target" --arg tool "$tool" '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": ("已阻擋 " + $tool + " 存取敏感檔案:" + $t + "。請改用環境變數或 secret manager 取得需要的設定。")
}
}'
exit 0
fi
exit 0
對應的 settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Edit|Write|Grep",
"hooks": [
{ "type": "command", "command": ".claude/hooks/guard-secret-file.sh", "timeout": 5 }
]
}
]
}
}
這份配置處理掉了主力路徑——只要 Claude 想用 Read 開 .env、用 Grep 搜 credentials.json、或用 Edit 改 id_rsa,都會被擋下並收到「請改用環境變數」的提示。但是 Bash 工具還是條漏的路:
# 這些都還能讀到 .env:
cat .env
head .env
tail .env
less .env
xxd .env
grep DATABASE .env
sed -n '1p' .env
awk 'NR==1' .env
cat < .env
python -c "print(open('.env').read())"
node -e "console.log(require('fs').readFileSync('.env','utf8'))"
要把這條路也補上,可以加一支 Bash 專用守門員,掃指令裡是否同時出現「讀檔指令 + 敏感檔名」:
#!/usr/bin/env bash
# .claude/hooks/guard-secret-bash.sh
# 擋住 Bash 裡常見的敏感檔讀取
set -euo pipefail
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // ""')
# 敏感檔案 pattern
secret_re='\.env(\..+)?|credentials\.json|\.pem|\.key|id_rsa'
# 常見讀檔指令
reader_re='\b(cat|less|more|head|tail|xxd|hexdump|od|strings|grep|rg|sed|awk|tac|nl|file)\b'
# 命中「讀檔指令 ... 敏感檔」或重導向 < 敏感檔
if echo "$cmd" | grep -Eq "${reader_re}.+${secret_re}"; then
reason="已阻擋 Bash 讀取敏感檔案:「$cmd」。"
elif echo "$cmd" | grep -Eq "<[[:space:]]*[^|&]*(${secret_re})"; then
reason="已阻擋透過重導向讀取敏感檔案:「$cmd」。"
else
exit 0
fi
jq -n --arg r "$reason" '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": $r
}
}'
exit 0
把這支也掛到 Bash matcher:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": ".claude/hooks/guard-rm.sh", "timeout": 5 },
{ "type": "command", "command": ".claude/hooks/guard-git-push.sh", "timeout": 5 },
{ "type": "command", "command": ".claude/hooks/guard-secret-bash.sh", "timeout": 5 }
]
},
{
"matcher": "Read|Edit|Write|Grep",
"hooks": [
{ "type": "command", "command": ".claude/hooks/guard-secret-file.sh", "timeout": 5 }
]
}
]
}
}
這樣可以擋下大多數常見的讀檔指令,但要老實說:Bash 的可繞性永遠補不完。python -c "open(...)"、node -e、perl -e、自己寫個 shell function、把檔名拆字串組起來,都能繞過 regex。要再嚴格一點可以連 python -c、node -e 這類「動態執行」也一併擋掉,但會犧牲不少 agent 的彈性。
因此實務上的建議是:hook 當作「降低誤觸風險」的一道防線,不要當成主要安全機制。真正敏感的東西不該放在 repo 內讓 agent 看得到——用 1Password CLI、 gopass、環境變數、或雲端 secret manager(AWS Secrets Manager、Google Secret Manager、HashiCorp Vault)把秘密放在 Claude Code 看不到的層級。Hook 攔的是「Claude 不小心讀到」而不是「有心人想偷讀」,兩者要分清楚。
Hook 與 Permissions 怎麼分工
Claude Code 內建還有一套 permissions 機制(在 settings.json 的 permissions.allow、permissions.deny、permissions.ask),跟 PreToolUse hook 看似功能重疊,實際定位不同:
| Permissions | PreToolUse Hook | |
|---|---|---|
| 判斷依據 | 固定語法的規則字串(如 Bash(npm:*)) | 任意 shell script,可以用 jq 拆 JSON 做複雜判斷 |
| 適合用來 | 粗粒度白名單/黑名單 | 條件式守門、改寫、log |
| 執行順序 | 先過 | 後過 |
| 能否回灌訊息給 Claude | 有限 | 可(stderr 或 JSON permissionDecisionReason) |
| 能否動態決定 | 否 | 是 |
實務上的分工建議:
- Permissions allow:寫經常使用、確定安全的指令(如
Bash(npm:*)、Bash(git status:*)),讓 Claude 不必每次都跳確認框 - Permissions deny:寫絕對不允許的工具或指令前綴(如
Bash(sudo:*)、WebFetch),這層擋掉就不會進到 hook - PreToolUse hook:寫「同一個工具但要看內容才能決定」的細部判斷。
Bash整個工具不能禁,但rm -rf /要擋;git push大多沒問題,但--force到 main 要確認;Read一般要開放,但.env要擋。這種「同一工具、不同情境」的條件式守門,就是 PreToolUse 的最佳場景
換句話說,permissions 處理「能不能用這個工具」,hook 處理「用的時候要看內容判斷」。兩者疊在一起的話會像一個雙閘門,permissions 過完再進 hook,最後才是工具實際執行。

偵錯:Hook 沒觸發、擋了 Claude 沒收到原因
PreToolUse hook 寫起來最容易卡在「明明設好了卻沒觸發」「擋下了但 Claude 看不到理由」這幾種狀況。可以從這些方向排查:
- matcher 大小寫:工具名稱是
Bash、Edit、Write、Read、Grep,第一個字母都大寫。寫成bash或edit不會匹配任何工具,hook 永遠不會跑 - 退出碼 2 才會回灌 stderr:很多人擋掉了但 Claude 還是一臉茫然繼續嘗試,多半是用了
exit 1。要讓 Claude 看到理由必須是exit 2(或用 JSONpermissionDecision: "deny"加permissionDecisionReason),其他非 0 退出碼 stderr 只顯示給使用者 - 檢查 stdin 內容:開發 hook 時最有用的招式是把 stdin JSON 完整 dump 出來,看 Claude Code 到底傳了什麼。在 script 開頭加一行
tee /tmp/hook-input.json <<< "$input" >/dev/null,跑一次 Claude Code 之後cat /tmp/hook-input.json | jq就能看到完整結構,比對tool_input.command、tool_input.file_path等欄位實際長什麼樣 - JSON 輸出格式錯:用 JSON 阻擋時 stdout 必須是合法 JSON,多一個逗號、key 拼錯都會讓 Claude Code 解析失敗回退到「視為退出碼 0 放行」。debug 時把 JSON 內容先
tee /tmp/hook-out.json,跑完後用jq .驗證格式是否正確 - PATH 問題:hook 被啟動時環境變數是精簡的,
~/.zshrc設的 PATH 不一定生效,jq或其他工具可能找不到。建議在 script 開頭手動補export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH",或對關鍵指令直接寫絕對路徑 - settings.json 沒重載:剛改完設定要重新啟動 Claude Code 對話,新對話開始前不會重讀設定檔。改完後在新對話試一個會觸發的小動作驗證
把 PreToolUse 當補強,不是當主要安全機制
整篇看完之後,會有一個常見的誤會值得先打消:PreToolUse hook 不是用來防駭客、不是用來抵禦惡意 prompt injection。真正的安全還是要靠 git 版本控制、CI 的 review、雲端權限模型、secret manager 這些底層機制;hook 處理的是「Claude Code 自動跑了一段時間,怎麼降低不小心誤觸的成本」這個比較窄的問題。
因此寫 hook 時不必執著於「補到一個漏洞都不剩」,反而要把它當成「最常見的誤觸路徑攔下 80%」就夠了。實作三那段 Bash 漏洞補不完是正常現象,補到 cat / head / tail / grep / less / xxd 這些日常指令就已經攔下絕大多數情境,剩下的靠不把秘密放進 repo 治本。能跑、能維護、能在團隊內共用,比起追求完美攔截更重要。
建議從這份模板開始用:先把 guard-rm.sh、guard-git-push.sh、guard-secret-file.sh、guard-secret-bash.sh 四支 script 放進專案 .claude/hooks/、commit 進 git,整個團隊用 Claude Code 都會自動套用同一份規則。後續團隊踩到新坑(例如某次 Claude 不小心 npm publish 一個內部套件)就加進對應的 guard script,逐步累積成屬於這個 repo 的「AI 守門員清單」。想看更完整的 hook 事件與 JSON 欄位定義,可以查 Claude Code 官方 hooks 參考文件。