那天我讓 Claude Code 處理一個 merge conflict。它提議「先 git reset --hard 再重來一次」,我點了 yes。
抹掉了一上午沒 commit 的工作。沒有 reflog 救援——因為改動從沒進過 index。
那次之後我寫了一個 PreToolUse Hook:每次 Claude Code 要跑 Bash 或改檔案之前,先攔一道。rm -rf、git reset --hard、git push --force 一律擋下、要我確認;改超過 5 個檔案會提醒。這篇拆解這個 Hook 的設計。
TL;DR
| 項目 | 設計 |
|---|---|
| Hook 類型 | PreToolUse,matcher = Bash|Edit|Write |
| 語言 | Python(跨平台、不用管 shell 差異) |
| 規則 | 16 條 regex,分四類(delete / git_danger / git_commit / git_warn) |
| 狀態 | state.json 持久化,分支/目錄變或閒置 24h 自動重置 |
| 範圍提醒 | 編輯 > 5 個檔案、單次刪除 > 50 行 |
| 誤判處理 | 「確認:xxx」字串放行 |
為什麼內建權限不夠
Claude Code 內建的 permission 可以 allow/deny,但有幾個擋不住的場景:
| 限制 | 範例 |
|---|---|
| 答應一次就一直答應 | 批准 git push 後,跑 git push --force 也不再問 |
| 無法區分相似指令 | git commit 和 git commit --amend 在 deny 規則裡分不開 |
| 沒有狀態記憶 | 「已改 6 個檔案」這種跨工具呼叫的累積資訊 |
| 訊息給人看、不給 AI 看 | deny 拒絕後 Claude Code 不知道「為什麼被拒、該改用什麼」 |
Hook 的彈性遠大於 deny,代價是要自己寫程式碼。我踩過幾次坑後才覺得這個投資值得:
git reset --hard抹掉一上午(開頭那次)- 一次重構改到 14 個檔案才發現方向錯,其中
print/目錄根本不該動 - 在 Docker 裡
rm -rf logs/,但工作目錄不在我以為的位置,差點刪掉整個app/
這些都不是 Claude Code 的錯——是它速度太快,等我發現已經來不及。護欄是給人留的反應時間。
Hook 的生命週期
Claude Code 的 Hook 觸發點與資料流:
使用者送出訊息
│
▼
[UserPromptSubmit Hook] ← 偵測複雜任務、注入提示
│
▼
Claude Code 推理 → 決定要呼叫工具
│
▼
[PreToolUse Hook] ← 本文主角:攔截危險操作
│
├─ approve → 工具執行 → [PostToolUse Hook]
│
└─ block → 工具不執行,reason 回到 Claude Code,它自行調整
safety_guard.py 掛在 PreToolUse——指令還沒跑、檔案還沒改之前就攔截。這是唯一能「真的阻止」的時機。
Hook 的輸入輸出
Hook 從 stdin 收 JSON、往 stdout 印 JSON:
// Input
{
"toolName": "Bash", // Bash | Edit | Write | ...
"toolInput": {
"command": "git push", // Bash
"file_path": "...", // Edit/Write
"old_string": "...", "new_string": "..."
}
}
// Output
{
"decision": "approve", // approve | block
"reason": "..." // block 時必填,會顯示給 Claude Code 看
}
approve 放行、block + reason 攔截。reason 寫得越清楚,Claude Code 越知道下一步該怎麼配合。
三層防禦的決策邏輯
工具呼叫進來 → load_state()
│
├─ Bash ──→ 命中 delete pattern?
│ ├─ Yes → 確認過? approve : block「確認:xxx」
│ └─ No → git_danger? ──同上確認流程
│ └─ No → git_commit? → block 導向 /commit
│ └─ No → git_warn? ──同確認流程
│ └─ No → approve
│
├─ Edit/Write ──→ deleted_lines > 50? → block「確認刪除」
│ └─ ≤ 50 → 加入 modified_files
│ └─ modified > 5 且未警告過? → block 範圍提醒
│
└─ 其他(Read/Glob/Grep)──→ approve
三層由嚴到寬:
- 絕對攔截:
rm -rf、git reset --hard、git push -f、git stash drop——要確認才放行 - 導向自訂流程:
git commit→ 強制走/commit指令 - 建議警告:
git pull、git merge、git push——提醒但確認後放行
規則設計
BLOCKED_PATTERNS = {
"delete": [
(r"rm\s+-rf", "刪除整個目錄"),
(r"rm\s+(-r|--recursive)", "遞迴刪除"),
(r"rm\s+(?!-)", "刪除檔案"),
(r"git\s+clean", "刪除未追蹤檔案"),
],
"git_danger": [
(r"git\s+reset\s+--hard", "硬重置會遺失所有改動"),
(r"git\s+push\s+(-f|--force)", "強制推送會覆蓋遠端歷史"),
(r"git\s+checkout\s+--\s+\.", "還原所有檔案會遺失改動"),
(r"git\s+stash\s+drop", "刪除 stash 無法復原"),
],
"git_commit": [
(r"git\s+commit(?!\s+--amend)", "請使用 /commit 流程"),
],
"git_warn": [
(r"git\s+pull(?!\s+--rebase)", "建議使用 /sync 安全同步"),
(r"git\s+merge", "建議先 /commit 再合併"),
(r"git\s+push(?!\s+(-f|--force))", "建議使用 /commit 流程"),
]
}
每條規則是 (regex, 說明)。說明欄很重要——攔截後 Claude Code 看到這段文字,會根據它調整下一步。
Regex 設計的三個陷阱
陷阱一:用 search 不是 match
re.match(r"git\s+push\s+--force", " git push --force") # ❌ 只匹配開頭,失敗
re.search(r"git\s+push\s+--force", " git push --force") # ✅ 找任何位置
指令前面可能有空格、環境變數、cd && 前綴,必須用 search。
陷阱二:忘記轉義
r"git checkout -- ." # ❌ 點號是萬用字元,會匹配「-- 任意字元」
r"git\s+checkout\s+--\s+\." # ✅ 轉義後只匹配真正的點
陷阱三:Negative lookahead 的順序
(r"git\s+commit(?!\s+--amend)", ...) # 攔 commit,放行 --amend
(r"git\s+push(?!\s+(-f|--force))", ...) # 警告 push,放行 --force(因已被 danger 層攔)
git push --force 必須先被 git_danger 攔下;如果順序反了,git_warn 的 lookahead 會放行它。絕對攔截要在警告之前檢查。
為什麼只有 16 條
每條規則的入選標準:誤觸成本低(一句「確認」就放行)、真的踩過坑、AI 看得懂說明。我不寫 chmod 777、sudo——我的 dev 環境沒這些風險。為自己的工作環境寫,不是做通用安全工具。
跨 Session 狀態管理
難題:Hook 是無狀態的,每次觸發都是獨立的 Python process。要追蹤「已改幾個檔案」就得持久化。
用 state.json 存兩個 namespace:
{
"session": {
"work_dir": "C:/code/AppSystem",
"git_branch": "Dev/issue-1234",
"last_activity": "2026-04-03T14:30:00"
},
"tracking": {
"modified_files": ["form10.php", "form11.php"],
"confirmed_ops": ["del:rm -rf logs/temp"],
"multi_file_warned": false
}
}
session 判斷要不要重置、tracking 累積這次 session 的狀態。
三種自動重置條件
def should_reset_state(saved, current):
if saved.get("work_dir") != current["work_dir"]:
return True, "專案切換"
if saved.get("git_branch") != current["git_branch"]:
return True, "分支切換"
last = saved.get("last_activity")
if last and datetime.now() - datetime.fromisoformat(last) > timedelta(hours=24):
return True, "閒置超過 24 小時"
return False, ""
| 條件 | 為什麼 |
|---|---|
| 工作目錄變 | 不同專案要獨立計數 |
| 分支變 | 不同 issue 是不同任務範圍 |
| 閒置 24h | 跨日後昨天的累積已失效 |
「分支沒換」是最可靠的「同一任務」訊號——比「session 沒重啟」可靠,因為 Claude Code 中斷重連 session 不會變。
state.json 壞了就直接重建(json.JSONDecodeError → fresh state)——Hook 不該因為 state 問題擋住正常工作。
編輯範圍提醒
Hook 在 Edit 執行之前跑,檔案還沒變,所以只能從 old_string / new_string 推算刪除行數:
old_lines = len(old_string.strip().split("\n")) if old_string.strip() else 0
new_lines = len(new_string.strip().split("\n")) if new_string.strip() else 0
deleted_lines = old_lines - new_lines
if deleted_lines > 50:
# block,要求「確認刪除」
兩個閾值:
| 閾值 | 值 | 為什麼 |
|---|---|---|
| 大量刪除 | 50 行 | 一個函式的典型大小,砍掉值得看一眼 |
| 多檔案警告 | 5 個檔案 | 跨檔案重構的規模分界 |
多檔案警告只觸發一次(multi_file_warned flag)——第一次到 6 個檔案時提醒,之後不再每改一個都打斷。代價是可能改到 20 個才發現失控,但目的是「給人停下來的機會」,不是強制限制。
效能
| 操作 | 時間 |
|---|---|
| Python process 啟動 | 30-50ms(主要成本) |
| 讀 state + 16 條 regex + 寫 state | < 10ms |
| 總計 | 30-80ms / 次工具呼叫 |
在 Claude Code 的節奏下感受不到——工具本身的執行時間遠大於這個。但如果 Hook 內呼叫外部指令(git、curl)或跑 LLM 判斷,會慢到 > 200ms 開始有感。我的 Hook 內跑一次 git rev-parse 拿分支名,多 10-30ms,目前可接受。
Edge Cases
實際使用累積的已知行為:
| 情境 | 行為 |
|---|---|
git commit --amend | 不被攔(negative lookahead) |
git push --force-with-lease | 比 --force 安全但仍被攔;加「確認」就過 |
cd /tmp && rm -rf logs | 仍被攔(含 rm -rf) |
git pull && git push | 兩個都檢查,第一個命中就攔 |
| Edit 純新增(old_string 空) | deleted_lines 為負,不觸發大量刪除 |
| Edit 改縮排 | deleted_lines ≈ 0,不觸發 |
替代方案比較
寫 Hook 之前評估過的方案:
| 方案 | 優點 | 缺點 |
|---|---|---|
Permissions deny | 原生、簡單 | 無 regex、無狀態、訊息不給 AI 看 |
| Slash Command 強制流程 | 流程清楚 | 使用者可繞過 |
| Subagent 隔離 | 完整環境隔離 | 不擋指令、只擋影響 |
| PreToolUse Hook(本文) | 完全靈活、有狀態 | 要寫程式、要維護 |
實務上組合用:allow 白名單放行常用指令 → Hook 攔危險操作 → /commit 強制提交流程。Hook 不是萬能藥,簡單黑名單用 deny 比 Hook 好。
除錯:Hook 沒觸發怎麼辦
最常見的問題,檢查順序:
.claude/settings.json的command是絕對路徑嗎?- matcher 是 regex 不是逗號分隔:
"Bash|Edit|Write"✅、"Bash,Edit,Write"❌ - Python 在 PATH 嗎?Windows 上常要寫
C:\\Python310\\python.exe或用py - Unix 上 hook 有執行權限嗎?(
chmod +x)
行為不對時加 logging:
import logging
logging.basicConfig(filename=Path(__file__).parent / "hook.log", level=logging.DEBUG)
logging.debug(f"INPUT: {input_data}") # 跟著 tail -f 看每次 input/output
獨立測試(不用起 Claude Code):
echo '{"toolName":"Bash","toolInput":{"command":"git reset --hard"}}' | python safety_guard.py
# 預期:{"decision": "block", "reason": "⚠️ Git 危險操作攔截..."}
這個測法可以寫成 pytest 集合,把「rm -rf 被擋」「git status 放行」「git commit —amend 不被擋」變成回歸測試。
漸進式演化:不要一開始就寫完整版
我的版本現在有 280 行,但第一版只有 50 行——只攔三個最危險的:
import sys, json, re
DANGER = [r"rm\s+-rf", r"git\s+reset\s+--hard", r"git\s+push\s+(-f|--force)"]
def main():
data = json.loads(sys.stdin.read())
if data["toolName"] != "Bash":
print('{"decision":"approve"}'); return
cmd = data["toolInput"].get("command", "")
for pattern in DANGER:
if re.search(pattern, cmd):
print(json.dumps({"decision": "block", "reason": f"危險:{cmd}"}))
return
print('{"decision":"approve"}')
main()
跑兩天看哪些誤判、哪些漏接,然後逐步加:
| 階段 | 加什麼 | 觸發 |
|---|---|---|
| Week 1 | rm -rf / reset —hard / push -f | 最小可行版本 |
| Week 2 | 「確認:xxx」放行機制 | 誤判要能恢復 |
| Week 3 | state 管理、confirmed_ops | 避免同指令一直確認 |
| Week 4 | git commit 導向 /commit | 規範團隊流程 |
| Week 5+ | 範圍提醒、自動重置、新規則 | 依踩坑經驗 |
規則是長出來的,不是設計出來的。 沒踩過的坑不要先寫規則——容易誤判。
跨平台陷阱
我在 Windows(PowerShell + Git Bash)和 macOS 都用 Claude Code,踩過的平台問題:
| 陷阱 | 解法 |
|---|---|
| Windows 路徑分隔符 | 用 pathlib.Path(__file__).parent / "state.json",別手拼 \\ |
| PowerShell 預設 UTF-16 | 寫檔明確 encoding="utf-8" + ensure_ascii=False(中文不變 \uXXXX) |
| subprocess 引號處理 | 用 list 形式 subprocess.run(["git", "rev-parse", ...]),別 shell=True |
Windows 檔案是 \r\n | old_string 來自 Edit 工具已是 LF;若讀原始檔用 splitlines() 較安全 |
Pattern 校正:從 log 反推誤判
每週看一次 hook.log,逐筆判斷 block:真危險就保留、誤判就調 regex。
真實案例——最初我把所有 git push 都當 danger 攔,一週後 log 顯示:
git push origin main被攔 8 次,每次都「確認」放行 → 噪音git push --force被攔 1 次,每次都該攔 → 正確
於是把 git push 從 danger 降到 warn 層,只保留 --force 在 danger:
"git_warn": [(r"git\s+push(?!\s+(-f|--force))", "建議使用 /commit 流程")],
"git_danger":[(r"git\s+push\s+(-f|--force)", "強制推送會覆蓋遠端歷史")],
誤判成本 vs 修 pattern 的維護成本——值得修就修,不值得就接受誤判(誤判頂多是 Claude Code 多讀一段訊息)。
結語
Claude Code 的安全性不該完全依賴 AI 的判斷。護欄是給人留反應時間的——AI 速度太快,等發現問題已經來不及。
這個 Hook 半年內攔過數十次手滑的 git reset --hard、大型重構失控前的緊急煞車、確保所有 commit 都走 /commit。
如果你常用 Claude Code,從 Week 1 的 50 行版本開始——攔 rm -rf 和 git reset --hard,加一個「改超過 N 個檔案警告」。需求出現再加規則。重點不是這 280 行 Python,是讓 Claude Code 的用法配合你的工作節奏,而不是反過來。
這個 Hook 跟另一個偵測複雜任務、強制先問清楚的 UserPromptSubmit Hook 搭配——一個攔危險指令、一個攔方向錯誤,是我目前 Claude Code 體驗最大的兩個優化。