要讓 AI 代理做出的變更在提交前先經過審查,與其寫下「要接受審查」的規則,不如用 hook 直接攔截提交指令,從工作階段紀錄確認「上一次提交之後,有沒有審查代理的呼叫紀錄」,這樣比較可靠。Claude Code 的 PreToolUse hook 會在 Bash 工具執行之前,接收指令與工作階段紀錄的路徑,所以如果是 git commit,而且對象是規則或 hook 檔案,就讀取紀錄,沒有審查呼叫時回傳 deny 即可。像錯字這類小修改,可以在提交訊息寫上 [verifier-skip: 原因] 讓它通過,但要讓那段文字原樣留在提交歷史中。
這篇文章是連載的第四篇,連載內容是把一個人使用的開發環境改成以 AI 代理為中心(AX,AI 轉型)的過程中,每天整理一個我做出來的機制。上一篇是把子代理回報縮到 300 字的 hook。
為什麼「一定要經過審查」的規則沒有被遵守?
我的指引儲存庫裡,同時放著規定代理工作方式的規則文件,以及讓機器去遵守這些規則的 hook 腳本。這些檔案只要錯一行,就會影響之後所有的工作階段,所以我很早就訂了「修改規則、hook 的提交,要先經過審查代理(verifier)」這條規則。
問題是,規則只存在於文句中。代理一旦判斷自己的變更是「小修改」,就會跳過審查直接提交。以人的眼光來看並不小的變更也很多。即使把規則改寫成更強烈的措辭,同樣的事仍然重複發生,所以在 2026-10-18,我把方向改成「靠文句不行,要讓機器來確認」。
為什麼用 git 的 pre-commit hook 不行?
最先想到的位置,是 git 的 pre-commit hook。它在提交之前執行,如果以非 0 的代碼結束,就會擋下提交,是很自然的選擇。但是 git hook 沒辦法知道「審查代理有沒有執行過」。這個事實不在 git 裡,而是在代理的工作階段中。
Claude Code 會把工作階段的所有工具呼叫留成 JSON Lines 檔(.jsonl),並把那個檔案路徑(transcript_path)一併傳給 PreToolUse hook。所以如果不是在 git 這邊,而是在代理這邊攔截提交指令,就能讀到同一個工作階段到目前為止呼叫過哪些工具。在知道有沒有審查的那一邊擋下,才是對的。
hook 確認什麼、怎麼確認?
確認的順序有三個步驟。第一,把指令中的 git 呼叫拆開,看有沒有 commit,而且目標儲存庫是不是指引儲存庫(像 git -C 路徑 commit 這種給了路徑的形式也一併抓出來)。第二,收集這次提交要包含的檔案。把已經暫存的,加上同一個指令中 git add 的參數。如果是 -A 或 .,就視為所有變動的檔案。其中只要有一個是規則文件、輸出格式、hook 資料夾、設定同步腳本之類的「審查對象路徑」,就進入第三步。第三,從工作階段紀錄比較最後一次成功的 git commit 與最後一次審查代理呼叫的順序。
只把關鍵的第三步縮短,大概是這樣。
def reviewed_since_last_commit(transcript, current_id):
uses, failed = [], set()
for line in open(transcript, encoding="utf-8"):
for c in (json.loads(line).get("message") or {}).get("content") or []:
if c.get("type") == "tool_use" and c.get("id") != current_id:
uses.append(c)
elif c.get("type") == "tool_result" and c.get("is_error"):
failed.add(c.get("tool_use_id"))
last_review = last_commit = -1
for i, c in enumerate(uses):
if c["name"] == "Agent" and REVIEW_AGENT_RE.search(c["input"].get("subagent_type", "")):
last_review = i
elif c["name"] == "Bash" and c["id"] not in failed and re.search(r"\bgit\b[^;&|]*\bcommit\b", c["input"].get("command", "")):
last_commit = i
return last_review > last_commit
排除失敗的工具呼叫(is_error)這一行很重要。如果審查之後試著提交了一次,卻因為其他原因失敗,就不能因為那次失敗的提交,而誤判成「審查在提交之前」。目前正在攔截的呼叫本身(current_id)也要排除。
被認定為審查的代理名稱,用正規表示式只留三種。如果只要名稱含有「verify」或「review」就一律認定,就會出現利用名稱相似的 skill 呼叫通過的路。
REVIEW_AGENT_RE = re.compile(r"^(oh-my-claudecode:)?(verifier|code-reviewer|security-reviewer)$")
GATED_RE = re.compile(r"^(AGENTS\.md|claude-global\.md|output-styles/|scripts/hooks/|scripts/git-hooks/|scripts/sync-settings\.py)")
hook 的註冊與一般的 PreToolUse 相同。拒絕時,把 permissionDecision 以 deny 回傳,並把原因以句子一併送出。代理讀到那句話,呼叫審查代理之後再次提交。
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "python3 \"$HOME/.claude/hooks/cc-guardrail.py\"", "timeout": 10 }] }
]
}
}
剛做好的那天,審查代理抓到了什麼?
這個 hook 本身也是規則、hook 檔案,所以做好的那天,先給審查代理看了。2026-10-18 的提交內文中留下的指摘有三項。第一,由無人自動化執行的提交,工作階段中沒有審查呼叫,會全部被拒絕;第二,hook 輸入中沒有目前呼叫的 ID 時,目前的提交會被當成「上一次提交」,導致判定錯誤;第三,以 skill 方式進行的驗證呼叫該怎麼計算。三項都反映之後,我才提交。強制審查的機制在第一次審查中就暴露出三個漏洞,反而讓我更確信需要這個做法。
之後又收緊了兩次。2026-10-21 改成先要求 Claude 審查代理的紀錄,詢問其他模型的交叉驗證,要在那之後才認定。2026-10-22 把越來越大的防護腳本,依檢查種類拆成模組,這項檢查就成為一個 161 行的檔案(以 2026-10-22 為準,防護資料夾的檔案有 22 個)。
數字是怎麼得出來的?
我用 git log 數了從放入 hook 的 2026-10-18 到 2026-10-22 之間,指引儲存庫的提交。全部 289 筆中,碰到審查對象路徑的提交有 150 筆,其中 38 筆以 [verifier-skip: 原因] 標記通過。一致性自動修復標記([auto-fix])有 2 筆。其餘的 110 筆,是必須有審查代理的紀錄才能提交的。
38 筆看起來也許不少,但我讓標記一定要寫出原因。所以「為什麼跳過」,之後用一次 git log 就能重新看到。比起擋下跳過本身,我選擇讓跳過的原因留在歷史中。
現在的一天有什麼不同?
在修改規則、hook 檔案的提交之前,現在總會夾進一次審查代理的呼叫。即使代理判斷是「小修改」,hook 也不過問那個判斷,只看紀錄。今天在寫這篇文章之前,我也改了防護模組的一行,結果啟動審查代理失敗了兩次。在那種狀態下提交會被拒絕,所以必須在審查真的執行過之後才能提交。雖然不方便,但這就是設置這個 hook 的理由。
有一個代價。讀不到工作階段紀錄時,hook 不會擋下,而是放行(fail-open)。我判斷這比因為紀錄檔損毀而讓所有提交都停住要好,但我是在知道有這個缺口的前提下使用它。
常見問題
可以和 git 的 pre-commit hook 一起用嗎?
可以一起用,而且角色不同。git hook 適合 lint、測試這類只看檔案就能判定的檢查,這個 hook 看的則是像「工作階段裡有沒有呼叫過審查代理」這種 git 不知道的事實。不過,如果還想擋下用 --no-verify 跳過 git hook,也可以在同一個 PreToolUse hook 裡,依指令字串來拒絕。
代理不會用別種形式的指令繞過嗎?
如果只找 git commit 這幾個字,就會漏掉 git -C 路徑 commit 或 cd 路徑 && git commit。所以先用 &&、;、| 把指令拆開,再對每一段分別解讀 git 呼叫與子指令。即使如此,如果出現新的形式,就加進測試案例。這項檢查附有「沒審查就提交」「審查後提交」「審查後提交又再提交」「以標記通過」四種測試。
也會確認審查代理是否通過嗎?
不會。hook 看的只有「有沒有呼叫紀錄」,不會去讀審查結果來判斷通過與否。依結果進行修改,則留給代理和人。如果 hook 連審查結果也開始解讀,判定出錯時要修正就會困難得多。