AX 提示 4 — 沒有審查紀錄的提交,由 hook 擋下

發布

  • AX 提示
  • Claude Code
  • 開發環境自動化
  • 提交

要讓 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 }] }
    ]
  }
}
指引儲存庫提交前的確認git commit 指令 → 包含規則或 hook 檔案?; 包含規則或 hook 檔案? → 進行提交 (否); 包含規則或 hook 檔案? → 有 [verifier-skip] 標記? (是); 有 [verifier-skip] 標記? → 進行提交 (有); 有 [verifier-skip] 標記? → 讀取工作階段紀錄 (沒有); 讀取工作階段紀錄 → 上次提交之後有審查呼叫?; 上次提交之後有審查呼叫? → 進行提交 (是); 上次提交之後有審查呼叫? → 拒絕 — 先審查 (否)否是有沒有是否git commit 指令包含規則或 hook檔案?有 [verifier-skip]標記?讀取工作階段紀錄上次提交之後有審查呼叫?拒絕 — 先審查進行提交
指引儲存庫提交前的確認 — 沒有審查紀錄,提交指令本身就會被拒絕

剛做好的那天,審查代理抓到了什麼?

這個 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 筆,是必須有審查代理的紀錄才能提交的。

碰到審查對象路徑的 150 筆提交的通過途徑以審查紀錄通過: 110件; [verifier-skip] 標記: 38件; [auto-fix] 標記: 2件020406080100以審查紀錄通過110件[verifier-skip]標記38件[auto-fix] 標記2件
碰到審查對象路徑的 150 筆提交的通過途徑 — 2026-10-18 ~ 2026-10-22,用 git log 數出的值

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 連審查結果也開始解讀,判定出錯時要修正就會困難得多。

要不要一起打造您的網站?

免費諮詢