把 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 想呼叫工具(BashEditWriteReadGrep 等),執行前都會先過一輪 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 訊息)來說,這個差別非常重要。

一次工具呼叫的完整時序:使用者送出 prompt 之後,Claude 思考、過 Permissions 規則、進入 PreToolUse hook,通過後才實際執行工具,再進入 PostToolUse 收尾
一次工具呼叫的完整時序,PreToolUse 是工具實際執行前的最後一道閘門

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 --forcerm -rf 這種「九成情況沒事、一成情況很糟」的指令,把判斷權留給人類比 hook 邏輯自己決定來得安全。

Hook 結束後 Claude Code 依退出碼分支:退出碼 2 阻擋並把 stderr 回灌、退出碼 0 再看 stdout 是否為合法 JSON,是的話依 permissionDecision 分 allow / deny / ask 三種行為
退出碼與 JSON 輸出共同決定 Claude Code 對工具呼叫的下一步

實作一:擋下 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 -rfrm -frrm -rf(多個空白)、rm -r -f 等變形;目標 pattern (/|~|\$HOME|\*) 涵蓋了「砍根目錄、砍家目錄、砍變數展開、砍萬用字元」幾種高危情境,但小範圍的 rm -rf node_modulesrm -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、特別是推到 mainmaster 的情況。這次改用 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 讀到 .envcredentials.json、SSH 私鑰之類的敏感檔案。寫入很好擋——matcher 設 Edit|Write 加一個檔名比對即可;難的是讀取,因為 Claude Code 至少有四種方式讀檔:Read 工具直接讀、Grep 工具搜內容、Bash 工具裡 cat .env、甚至 Bashpython -c "open('.env').read()" 繞遠路。

先處理直接的部分。把 ReadEditWriteGrep 四個工具共用一支守門 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、用 Grepcredentials.json、或用 Editid_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 -eperl -e、自己寫個 shell function、把檔名拆字串組起來,都能繞過 regex。要再嚴格一點可以連 python -cnode -e 這類「動態執行」也一併擋掉,但會犧牲不少 agent 的彈性。

因此實務上的建議是:hook 當作「降低誤觸風險」的一道防線,不要當成主要安全機制。真正敏感的東西不該放在 repo 內讓 agent 看得到——用 1Password CLIgopass、環境變數、或雲端 secret manager(AWS Secrets Manager、Google Secret Manager、HashiCorp Vault)把秘密放在 Claude Code 看不到的層級。Hook 攔的是「Claude 不小心讀到」而不是「有心人想偷讀」,兩者要分清楚。

Hook 與 Permissions 怎麼分工

Claude Code 內建還有一套 permissions 機制(在 settings.jsonpermissions.allowpermissions.denypermissions.ask),跟 PreToolUse hook 看似功能重疊,實際定位不同:

PermissionsPreToolUse 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,最後才是工具實際執行。

Permissions 與 PreToolUse Hook 的雙閘門結構:Permissions 用固定規則做粗粒度白名單/黑名單,通過後再進 hook 用 script 做條件式守門,最後工具才實際執行
Permissions 在前、hook 在後的雙閘門結構,前者控「能不能用工具」、後者控「用時看內容判斷」

偵錯:Hook 沒觸發、擋了 Claude 沒收到原因

PreToolUse hook 寫起來最容易卡在「明明設好了卻沒觸發」「擋下了但 Claude 看不到理由」這幾種狀況。可以從這些方向排查:

  • matcher 大小寫:工具名稱是 BashEditWriteReadGrep,第一個字母都大寫。寫成 bashedit 不會匹配任何工具,hook 永遠不會跑
  • 退出碼 2 才會回灌 stderr:很多人擋掉了但 Claude 還是一臉茫然繼續嘗試,多半是用了 exit 1。要讓 Claude 看到理由必須是 exit 2(或用 JSON permissionDecision: "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.commandtool_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.shguard-git-push.shguard-secret-file.shguard-secret-bash.sh 四支 script 放進專案 .claude/hooks/、commit 進 git,整個團隊用 Claude Code 都會自動套用同一份規則。後續團隊踩到新坑(例如某次 Claude 不小心 npm publish 一個內部套件)就加進對應的 guard script,逐步累積成屬於這個 repo 的「AI 守門員清單」。想看更完整的 hook 事件與 JSON 欄位定義,可以查 Claude Code 官方 hooks 參考文件


Sponsored Links

發佈留言