Hooks 是在 Claude Code 生命週期的特定時間點(工具呼叫前/後、對話開始/結束等)自動觸發指定的腳本。
它跟「CLAUDE.md」最大的差別在於:

我在設計這幾條線的時候,只問一個問題:
如果這一步 AI 判斷錯了,我還救得回來嗎?
直接切成兩堆:
| 救得回來 | 救不回來 |
|---|---|
| 改壞一支腳本 → git 還原 | rm -rf 刪掉整個目錄 |
| 寫錯一份文件 → 重寫 | 金鑰進了對話紀錄 → 資安問題,只能整批換掉 |
| 跑錯一次測試 → 重跑 | 改其他人的工作單的狀態或者亂留言 |
| 產出一份爛報告 → 重產 | git --fouse 強制推送分支,直接覆蓋別人的程式碼 |
具體落成三個規則:
| 線 | 規則 |
|---|---|
| 破壞性指令 | AI 不自己執行,改成把指令列給我,我自己貼 |
| 憑證 | 不進 context。連讀都不要讀 |
| 對外寫入 | 只能走已經封裝好的 function,沒有就停下來問 |
這條的重點不是「禁止」,是換一個執行者。
因為那些指令本身是必要的。
重構的時候真的要砍掉一批檔案,rebase 完真的要強制推送。
問題不在指令,在「誰按下 Enter」。
所以做法是:AI 想跑這類指令的時候,攔下來,讓它改成:
這一步需要執行
<完整指令>,它會做<什麼事>,請你確認路徑後自己執行。
我把決定權拿回來,同時保留了它幫我把指令組好的價值。
Claude Code 有 PreToolUse hook,會在指令執行前先跑一段你自己的程式,回傳 deny 就擋下來。
先在 .claude/settings.json 掛上:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/block_destructive.py\""
}
]
}
]
}
}
然後 block_destructive.py 的大致骨架:
#!/usr/bin/env python3
"""PreToolUse hook:攔截破壞性指令,改由使用者手動執行。"""
import json
import re
import sys
DANGER = [
(r"\brm\s+(-\w*\s+)*-\w*[rf]", "遞迴/強制刪除"),
(r"\brm\s+.*\*", "萬用字元刪除"),
(r"\bgit\s+push\s+.*(-f\b|--force)", "強制 push"),
(r"\bgit\s+reset\s+--hard", "硬重置"),
(r"\bgit\s+clean\s+-\w*[fd]", "清除未追蹤檔"),
(r"\bdd\s+", "磁碟寫入"),
]
def block(cmd, desc):
reason = (
f"⛔ 已被安全 hook 攔截:{desc}\n"
f"指令:{cmd}\n\n"
"規範:不要自己執行,也不要重試。"
"請把此指令完整列給使用者、說明它會做什麼,由使用者自行執行。"
)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}))
sys.exit(0)
def main():
data = json.load(sys.stdin)
if data.get("tool_name") != "Bash":
sys.exit(0)
cmd = data.get("tool_input", {}).get("command", "")
for pattern, desc in DANGER:
if re.search(pattern, cmd):
block(cmd, desc)
sys.exit(0)
if __name__ == "__main__":
main()
重點在 block() 裡那句「不要重試」。
沒寫這句的話,AI 被擋下之後會很努力地換各種寫法再試一次,然後你就會看到它連續撞牆五次。
要明確告訴它:這不是失敗,這是規則,改成列給使用者就對了!
上面擋的是「動作」,這條擋的是「看見」。
因為金鑰只要進了對話紀錄,它就可能被帶進:
而且你不會發現,因為它只是混在一大段內容裡的一串字。
{
"permissions": {
"deny": [
"Read(.env)",
"Read(**/.env)",
"Bash(cat *.env*)",
"Bash(cat **/.env*)",
"Bash(grep * .env*)",
"Bash(head * .env*)",
"Bash(tail * .env*)",
"Bash(less .env*)",
"Bash(more .env*)"
]
}
}
這是實際會遇到的:某個功能跑不起來,要確認是不是少了某把 key。
擋掉讀取之後,這件事怎麼做?
答案是給一個只回報存在與否、不印值的檢查指令。
# config_check.py —— 只說有沒有,不說是什麼
REQUIRED = ["API_TOKEN", "SERVICE_URL", "ACCOUNT", "PASSWORD"]
def check():
for key in REQUIRED:
exists = bool(os.getenv(key))
print(f"{'✅' if exists else '❌'} {key}")
輸出長這樣:
✅ API_TOKEN
❌ SERVICE_URL
✅ ACCOUNT
✅ PASSWORD
呼叫外部系統做「新增/修改/刪除」
這些一旦送出去,就在別人眼前了
如果只是人為溝通一下,或許可以互相體諒,手動調整
但萬一對方流程中背後有相關自動化再執行,這時候誤觸發可能會導致背後流程都白白觸發了(甚至有需要話錢的話更恐怖)
身爲 QA 還是謹慎為上才是上上策XDD
我自己的規則是:
這類操作只能呼叫已經封裝好、審查過的 function。
沒有對應的 function,就停下來問我,不准自己組一個。
因為如果放任 AI 自己拼 API 呼叫,它會每次都拼出不一樣的版本。
而封裝好的 function 是寫過一次、審過一次、防呆做過一次,之後每次都走同一條路。
規則寫在文件裡,但 AI 想繞過還是繞得過
所以這條要跟第一條的 hook 合併使用,把「未經封裝的對外寫入」也加進攔截清單:
NET_WRITE = [
(r"\bcurl\b[^\n]*(-X\s*(POST|PUT|DELETE|PATCH)\b)", "未走封裝的對外寫入"),
(r"\brequests\.(post|put|delete|patch)\s*\(", "未走封裝的對外寫入"),
(r"\b(shutil\.rmtree|os\.remove|os\.unlink)\b", "程式碼刪檔"),
]
被擋到的時候,提示要換一種:
這個寫入操作尚未封裝。請停下來告訴使用者,詢問是否要新增到封裝層,不要自己組呼叫繞過。
這樣「文件裡的規範」就變成「機器擋得住的規則」。
寫到這裡可能會覺得這篇好像跟 QA 無關?
但它的核心還是跟 QA 有掛鈎的:
我們判斷風險的方式,從來就不是「這件事會不會出錯」,
而是「出錯的時候,代價、成本是什麼」。
這其實跟排查 Bug 優先序完全是同一套思路。
這篇做的事情,就是把那個提醒從「我每次"記得"問」變成「程式碼 "固定" 每次幫我擋」。
這也個流程相當關鍵!也會跟後續 AI E2E 文章會有非常大的相依
上面那些規則 擋不住所有繞法。
隨便舉一個它就穿了:
python3 -c "print(open('.env').read())"
我沒有辦法窮舉所有繞過方式
所以這條規則的定位要說清楚:
它擋的是日常誤用,不是惡意繞過。
它降低「不小心把 token 帶進 context」的機率,但它不取代「不把敏感內容寫進輸出」這個行為規範。
我覺得誠實標示一個防護的邊界,比假裝它滴水不漏重要。
知道防線在哪裡有洞,跟以為自己沒有洞,是完全不同的安全狀態的。
不過這裡有個問題。
上面這些規則,有些是 hook 在守(AI 繞不過),有些只是寫在文件裡(全靠自律)。
而寫在文件裡的那些,我完全不知道它們到底有沒有被遵守。
明天就講怎麼我初步會怎麼區分相關規則,讓 AI 可以既成本低且能精準執行