我的 Claude Code 安全護欄:用 PreToolUse Hook 攔截危險操作


那天我讓 Claude Code 處理一個 merge conflict。它提議「先 git reset --hard 再重來一次」,我點了 yes。

抹掉了一上午沒 commit 的工作。沒有 reflog 救援——因為改動從沒進過 index。

那次之後我寫了一個 PreToolUse Hook:每次 Claude Code 要跑 Bash 或改檔案之前,先攔一道rm -rfgit reset --hardgit 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 commitgit commit --amenddeny 規則裡分不開
沒有狀態記憶「已改 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

三層由嚴到寬:

  1. 絕對攔截rm -rfgit reset --hardgit push -fgit stash drop——要確認才放行
  2. 導向自訂流程git commit → 強制走 /commit 指令
  3. 建議警告git pullgit mergegit 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 777sudo——我的 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 沒觸發怎麼辦

最常見的問題,檢查順序:

  1. .claude/settings.jsoncommand絕對路徑嗎?
  2. matcher 是 regex 不是逗號分隔:"Bash|Edit|Write" ✅、"Bash,Edit,Write"
  3. Python 在 PATH 嗎?Windows 上常要寫 C:\\Python310\\python.exe 或用 py
  4. 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 1rm -rf / reset —hard / push -f最小可行版本
Week 2「確認:xxx」放行機制誤判要能恢復
Week 3state 管理、confirmed_ops避免同指令一直確認
Week 4git 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\nold_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 -rfgit reset --hard,加一個「改超過 N 個檔案警告」。需求出現再加規則。重點不是這 280 行 Python,是讓 Claude Code 的用法配合你的工作節奏,而不是反過來

這個 Hook 跟另一個偵測複雜任務、強制先問清楚的 UserPromptSubmit Hook 搭配——一個攔危險指令、一個攔方向錯誤,是我目前 Claude Code 體驗最大的兩個優化。