Claude Code Hooks:私有 repo 沒有分支保護時,擋下 Agent 直接推送 main

摘要

當私有 repo 無法開啟分支保護時,Agent 一旦判斷錯誤,就可能直接把改動推送到 main 或執行 force-push,而寫給它看的規則只能作為一種提醒,模型不一定會完全遵從。透過 Claude Code 的 PreToolUse hook,Agent 每次執行指令前都會先經過一支檢查腳本,只要腳本判定指令違反規則,這個指令就不會被執行,而且不論使用哪種權限模式都一樣有效。

文章目錄

讓 Agent 自己 commit 與推送,可以省下手動操作 git 的時間,但當它判斷錯誤時,可能會把改動直接推送到 main,或用 force-push 覆蓋掉遠端的提交歷史。即使把「不要推送 main」寫進專案規則,Claude Code 也只會把這些規則當成參考的內容,模型不一定會完全遵從。若要在 Agent 判斷錯誤時也能擋下指令,就要使用 PreToolUse hook。設定 hook 之後,Agent 每次執行指令前,Claude Code 都會先把指令交給一支檢查腳本,只要腳本判定這個指令違反規則,指令就不會被執行。

本站的原始碼放在 GitHub 的私有 repo,平時在 draft 分支上開發,完成後再開 PR 合併到 main。GitHub 的私有 repo 要開啟分支保護需要付費方案,我當時使用的免費組織帳號無法開啟,於是改用 hook 來擋下 Agent 推送 main 的指令。Hook 只會檢查 Claude Code 透過 Bash 與 PowerShell 工具執行的指令,我們自己在終端機手動推送時不會受到影響。

測試環境是 Windows 11、Claude Code 2.1.309 與 Node.js 26。

PreToolUse hook 擋下指令的流程

Hook 會依照檢查腳本回傳的結束碼,決定要擋下這次的指令,還是交回原本的流程繼續處理:

Agent 要執行的指令先交給 PreToolUse hook 的檢查腳本。結束碼 2 擋下並把原因回給 Agent,結束碼 0 交回原本的權限設定後執行,腳本出錯、無法執行或逾時時預設照常執行,設定 onFailure: block 時改成擋下

腳本以結束碼 2 結束時,寫到標準錯誤輸出(stderr)的文字會傳回給 Agent,作為指令被擋下的原因。官方的 hooks 指南寫明,PreToolUse hook 會在任何權限模式的檢查之前執行,因此即使在 bypassPermissions 模式下也一樣能擋下指令,而子代理在呼叫工具時,也會經過同樣的 hook。結束碼 0 只代表 hook 不擋下這次的指令,指令仍然要經過原本的權限設定,才會真正執行。

權限設定裡的 deny 規則雖然也能擋下指令,但它只能用萬用字元比對指令的文字,像 git push origin +main 這類寫法就會被漏掉,詳細原因可以參考權限規則怎麼比對 Bash 指令。Hook 的腳本則可以自己撰寫判斷邏輯,例如先查出 Agent 目前所在的分支,再決定要不要擋下。

判斷推送 main 的方式

Claude Code 傳給腳本的 JSON 包含好幾個欄位,但檢查推送指令只需要用到其中兩個。tool_input.command 是 Agent 要執行的完整指令,cwd 則是 Agent 目前所在的資料夾,例如 Agent 要推送 main 時,腳本會收到類似這樣的內容:

1{ "tool_name": "Bash", "tool_input": { "command": "git push origin main" }, "cwd": "E:\\vervecode.dev" }

如果只想擋下寫明 main 的推送,核心的判斷只需要幾行程式碼。腳本先依照 ;、&、| 與換行把指令切成好幾段,遇到 git push 而且推送目標是 main 的那一段,就用結束碼 2 擋下:

1const command = JSON.parse(input).tool_input.command ?? '';
2for (const part of command.split(/[;&|\n]/)) {
3  const [program, sub, ...args] = part.trim().split(/\s+/);
4  if (program !== 'git' || sub !== 'push') continue;
5  // 目標可能寫成 main 或 HEAD:main,取冒號後面的分支名稱。
6  if (args.some((a) => !a.startsWith('-') && a.split(':').pop() === 'main')) {
7    console.error('main 只能經 PR 合併,請推送工作分支後開 PR。');
8    process.exit(2);
9  }
10}

不過,如果只比對指令裡有沒有寫出 main,下面這些指令就會被漏擋或誤擋:

目前分支 指令 只比對 main 的結果 正確結果
main git push 放行 擋下
draft git switch main && git push 放行 擋下
draft git -C ../site push origin main 放行 擋下
draft git commit -m "改走 PR; git push origin main 會被擋" 擋下 放行

要正確判斷這些指令,就要先把指令拆成一個個字詞。引號裡的文字會被當成同一個字詞,而沒有寫出推送目標的 git push,則要再查詢目前所在的分支:

一行指令依分號與 && 切成 git commit 和 git push 兩個指令。引號裡的 commit 訊息算一個字詞,git commit 不是推送所以放行。沒寫目標的 git push 查目前分支,在 main 就擋下,在其他分支就放行

如果 git 後面接了 -C <路徑>,腳本就會改查那個資料夾的分支。同一行指令裡先執行了 cd 或 git switch 時,後面的指令也會改用切換後的資料夾與分支來判斷。

建立檢查腳本

接著把完整的腳本存成專案裡的 .claude/hooks/guard-git.mjs。這支腳本除了擋下推送 main,也會擋下 force-push、刪除遠端分支與一次推送所有分支的指令,而且只用到 Node.js 內建的模組:

1#!/usr/bin/env node
2// PreToolUse hook:擋下直接推送 main、force-push、刪除遠端分支與一次推送所有分支。
3// 擋下時 exit 2,stderr 會回給 Claude 當作原因。其他情況 exit 0,交回一般的權限流程。
4import { execFileSync } from 'node:child_process';
5import { resolve } from 'node:path';
6
7let input = '';
8for await (const chunk of process.stdin) input += chunk;
9const { tool_input: { command = '' } = {}, cwd } = JSON.parse(input);
10let dir = cwd || process.cwd();
11
12let branch = null; // 同一行先 git switch 時,記下切換後的分支
13const reasons = new Set();
14for (const words of splitCommands(command)) check(words);
15if (reasons.size) {
16  console.error(`[guard-git] 已阻擋:${[...reasons].join(' ')}`);
17  process.exit(2);
18}
19
20// 依 ; & | 與換行切成單一指令,再依空白切成字詞。引號裡的文字算同一個字詞。
21function splitCommands(text) {
22  const commands = [];
23  let words = [];
24  let word = null;
25  let quote = null;
26  const endWord = () => {
27    if (word !== null) words.push(word);
28    word = null;
29  };
30  const endCommand = () => {
31    endWord();
32    if (words.length) commands.push(words);
33    words = [];
34  };
35  for (const c of text) {
36    if (quote) {
37      if (c === quote) quote = null;
38      else word += c;
39    } else if (c === '"' || c === "'") {
40      quote = c;
41      word ??= '';
42    } else if (c === '\n' || ';&|'.includes(c)) {
43      endCommand();
44    } else if (/\s/.test(c)) {
45      endWord();
46    } else {
47      word = (word ?? '') + c;
48    }
49  }
50  endCommand();
51  return commands;
52}
53
54function check([program, ...args]) {
55  if ((program === 'cd' || program === 'Set-Location') && args[0]) {
56    dir = resolve(dir, args[0]);
57    branch = null;
58    return;
59  }
60  if (!/^git(\.exe)?$/i.test(program)) return;
61
62  // 略過 git 的全域選項,-C <路徑> 決定要查哪個資料夾的分支。
63  let cwd = dir;
64  let i = 0;
65  while (args[i]?.startsWith('-')) {
66    if (args[i] === '-C') cwd = resolve(dir, args[i + 1] ?? '');
67    i += ['-C', '-c', '--git-dir', '--work-tree'].includes(args[i]) ? 2 : 1;
68  }
69  const [sub, ...rest] = args.slice(i);
70  const flags = rest.filter((a) => a.startsWith('-'));
71  const positional = rest.filter((a) => !a.startsWith('-'));
72  const shortFlag = (letter) => flags.some((f) => /^-[a-zA-Z]+$/.test(f) && f.includes(letter));
73
74  if (sub === 'switch' || sub === 'checkout') {
75    // git checkout <名稱> 也可能是還原檔案,只有確定是分支時才記下。
76    const [target] = positional;
77    if (target && (sub === 'switch' || target === 'main' || shortFlag('b') || shortFlag('B'))) branch = target;
78  }
79  if (sub !== 'push') return;
80
81  const refspecs = positional.slice(1);
82  if (flags.some((f) => /^--force/.test(f)) || shortFlag('f') || refspecs.some((r) => r.startsWith('+'))) {
83    reasons.add('禁止 force-push。');
84  }
85  if (flags.includes('--delete') || shortFlag('d') || refspecs.some((r) => r.startsWith(':'))) {
86    reasons.add('禁止刪除遠端分支。');
87  }
88  if (flags.some((f) => ['--all', '--mirror', '--branches'].includes(f))) {
89    reasons.add('禁止一次推送所有分支。');
90  }
91  // 沒寫目標時推的是目前分支,HEAD 也是目前分支。
92  const targets = refspecs.length ? refspecs : ['HEAD'];
93  const pushesMain = targets.some((r) => {
94    const target = r.replace(/^\+/, '').split(':').pop().replace(/^refs\/heads\//, '');
95    return (target === 'HEAD' ? branch ?? currentBranch(cwd) : target) === 'main';
96  });
97  if (pushesMain) reasons.add('main 只能經 PR 合併,請推送工作分支後開 PR。');
98}
99
100function currentBranch(cwd) {
101  try {
102    return execFileSync('git', ['symbolic-ref', '--short', 'HEAD'], {
103      cwd,
104      encoding: 'utf8',
105      stdio: ['ignore', 'pipe', 'ignore'],
106    }).trim();
107  } catch {
108    return '';
109  }
110}

分支名稱 main 是直接寫在腳本裡的,如果預設分支是其他名稱,就要把腳本裡的 main 改成那個名稱。本站實際使用的版本還另外加了發佈流程專用的規則,例如禁止刪除 draft 分支,文章裡只保留與推送相關的判斷。

這支腳本只會拆解直接寫出來的 git 指令,像 bash -c "git push origin main" 這種把指令包在字串裡的寫法、$(...) 裡的指令,以及用完整路徑或 git alias 執行的推送,都不會被擋下。此外,腳本本身也是 repo 裡的檔案,如果 Agent 修改了這支腳本,之後的檢查也會跟著失效。

設定 hook

腳本存好之後,在專案的 .claude/settings.json 加上這段設定,讓 Bash 與 PowerShell 兩種工具在執行指令前,都先經過這支腳本檢查:

1{
2  "hooks": {
3    "PreToolUse": [
4      {
5        "matcher": "Bash|PowerShell",
6        "hooks": [
7          {
8            "type": "command",
9            "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-git.mjs\"",
10            "onFailure": "block"
11          }
12        ]
13      }
14    ]
15  }
16}

設定裡的 $CLAUDE_PROJECT_DIR 代表專案的根目錄,即使 Agent 先 cd 到子資料夾,也一樣找得到腳本。"onFailure": "block" 則讓腳本在出錯、無法執行或逾時的時候,也會擋下指令,這個欄位需要 Claude Code 2.1.295 以上的版本才能使用。設定生效後,只要腳本出現錯誤,Agent 執行的所有 Bash 與 PowerShell 指令都會被擋下,這時就要自己用編輯器修好腳本,或是先把這段設定拿掉。

確認設定生效

設定完成後,先在終端機把一段 JSON 傳給腳本,確認腳本會擋下推送 main 的指令。Windows 可以用 Git Bash 執行這行指令:

1echo '{"tool_input":{"command":"git push origin main"}}' | node .claude/hooks/guard-git.mjs; echo $?

執行後畫面會先印出擋下的原因,最後一行則是結束碼 2:

1[guard-git] 已阻擋:main 只能經 PR 合併,請推送工作分支後開 PR。
22

接著在 Claude Code 裡請 Agent 執行 git push --dry-run origin main,如果回覆裡出現 hook 擋下的訊息,就代表設定已經生效。--dry-run 只會模擬推送的過程,就算指令沒被擋下,也不會改動到遠端的分支。Claude Code 通常會自動套用設定檔的變更,官方的 hooks 指南也提到,如果等了幾秒仍然沒有生效,重新開啟一個 session 就會重新載入設定。

實測紀錄

我先準備了 38 個測試指令,分別在目前分支是 draft 或 main 的全新 git repo 裡執行腳本,該放行與該擋下的指令,結果都符合預期。

接著在 repo 外建立一個隔離的 git repo,它的遠端是同一台電腦上的 bare repo(只存放 git 歷史、沒有工作目錄的 repo),而 .claude/settings.json 裡只放了這支 hook。然後用 claude -p --model haiku --permission-mode bypassPermissions 請 Haiku 5.5 逐行執行下列指令,並要求它在指令被擋下時,不要換其他方法重試:

執行者 指令 結果
主 session,Bash git commit --allow-empty -m "lab change" 放行
主 session,Bash git push origin HEAD:main 擋下
主 session,Bash git push --force origin draft 擋下
主 session,Bash git switch main && git push 擋下
主 session,Bash git commit --allow-empty -m "release: push main later" 放行
主 session,Bash git push origin draft 放行
子代理,PowerShell git push origin draft:main 擋下

全部執行完後,遠端的 main 仍然停在第一個 commit。其中 git switch main && git push 是整行指令都被擋下,所以前面的 git switch 也沒有執行。推送 main 的指令被擋下時,Agent 收到的訊息如下:

1PreToolUse:Bash hook error: [node "$CLAUDE_PROJECT_DIR/.claude/hooks/guard-git.mjs"]: [guard-git] 已阻擋:main 只能經 PR 合併,請推送工作分支後開 PR。

最後,我把設定裡的腳本路徑改成一個不存在的檔案,再請 Agent 執行 git status,結果這個指令也被擋下了,訊息的開頭是 failed; blocking because onFailure is "block"。

總結

被 PreToolUse hook 以結束碼 2 擋下的指令不會被執行,實測在 bypassPermissions 模式下,主 session 與子代理推送 main 的指令都被擋下,遠端的 main 也沒有任何變動。設定 "onFailure": "block" 之後,即使腳本無法執行,指令也一樣會被擋下。

接著可以做什麼

下一篇Minecraft Spigot 伺服器轉 Paper:世界與外掛資料完整保留的遷移步驟Games
Ted Liou

Ted Liou

Unity 現役工程師,Unity、AI 技術開發經驗分享與諮詢。