在 runtime 層擋住 coding agent 偷改單元測試:三層防線實戰
你把測試寫好、紅燈亮著,交給 coding agent 去修。十分鐘後它回報「全綠了」。
你 git diff 一看——它改的不是 src/,是 tests/。那個難搞的斷言被換成 assert True,最麻煩的那個 case 上面多了一行 @pytest.mark.skip。
這不是 agent 壞掉,是它照著你給的目標函數在最佳化:你要的是 exit code 0,它就給你 exit code 0。而改測試永遠是通往 exit code 0 最短的那條路。
在 CLAUDE.md 裡寫「不要修改測試檔」會有一點幫助,但那是機率——它是一段提示詞,會被壓縮、被稀釋、在 context 變長之後被遺忘。這篇要講的是確定性的做法:在 runtime 層擋住,讓那個編輯動作根本送不出去。

本文大綱
這不是「你的 agent 比較壞」,是普遍現象
先確認一件事:這個行為在前沿模型的訓練紀錄裡是被明確記錄下來的。
OpenAI 在 〈Detecting misbehavior in frontier reasoning models〉(對應論文 arXiv:2503.11926,Monitoring Reasoning Models for Misbehavior and the Risks of Promoting Obfuscation)裡描述的實驗環境,任務設定幾乎就是你每天在做的事:給 agent 一個沒寫完的 repo,叫它「讓所有單元測試通過」。結果他們用 GPT-4o 去監看前沿推理模型的 chain-of-thought,抓到模型在思考過程裡直接寫「Let's hack」,然後動手繞過驗證機制。其中一個被公開的例子是:模型判斷真的把那個函式實作出來太難,於是決定「fudge 一下」,讓 verify 永遠回傳 true。
換句話說,偷改測試不是提示詞沒寫好造成的意外,是目標函數本身長出來的行為。你不可能用另一段提示詞把它講掉。
規模有多大?Andre Hora 與 Romain Robbes 在 2026 年 1 月釋出的〈Are Coding Agents Generating Over-Mocked Tests? An Empirical Study〉(arXiv:2602.00409)分析了 2025 年 2,168 個 TypeScript / JavaScript / Python repo 的 120 萬筆以上 commit,其中 48,563 筆來自 coding agent。研究方回報的數字:
- agent 的 commit 裡有 23% 動到測試檔,非 agent 是 13%
- agent 的 test commit 裡有 36% 加了 mock,非 agent 是 26%
- 有 agent 活動的 repo 中,68% 同時含有 agent 加的 mock
這裡要誠實標註:這組數字量的是「動測試」和「加 mock」的頻率,不是「作弊」的頻率。agent 本來就常被指派去寫測試,動測試檔比例高很合理;加 mock 也不等於假通過。作者自己的結論比較克制——他們建議把 mocking 的指引寫進 agent 設定檔。但這個分佈至少說明一件事:測試檔是 agent 高頻接觸的區域,你的防線要蓋在它每天經過的路上。
三層防線:為什麼一層不夠
我的做法是分三層,因為每一層都有明確的逃生口,單靠任何一層都會漏:
| 層 | 攔截時機 | 擋得住 | 逃生口 |
|---|---|---|---|
| L1 Harness | 工具呼叫送出前 | 「動到測試檔」這件事本身 | 管不到自己開檔的子行程 |
| L2 Test runner | 測試真的跑起來時 | skip / only / 空殼 / 同義反覆斷言 | 設定檔本身也可能被改 |
| L3 Commit / CI | commit 與 CI | AST 指紋、測試數退化、mock 越界 | --no-verify 可跳過 pre-commit |
L1 最快也最省事,但它只認得「檔案路徑」,看不懂語意——agent 新寫一個 assert True 的測試,L1 完全不會響。L2 看得懂語意,但 conftest.py 和 vitest.config.ts 本身就是檔案,不鎖住等於沒鎖。L3 最紮實,但回饋太慢,而且 git commit --no-verify 一行就能跳過 pre-commit。
疊起來才有意義。以下逐層給可以直接抄的設定。
L1:Harness 層——讓編輯根本送不出去
最便宜的一招:permission deny 規則
Claude Code 的 permissions.deny 是三十秒就能上線的第一道牆。在 .claude/settings.json:
{
"permissions": {
"deny": [
"Edit(tests/**)",
"Edit(**/*_test.go)",
"Edit(**/*.test.ts)",
"Edit(**/*.spec.ts)",
"Edit(**/conftest.py)",
"Edit(**/vitest.config.ts)"
]
}
}
有四個官方文件裡寫明、但很多人第一次寫就踩到的細節:
一、檔案權限只認 Edit(path) 和 Read(path)。 你如果寫 Write(tests/**) 或 NotebookEdit(tests/**),Claude Code 會收下這條規則、但永遠不去查它,並且在啟動時警告你(v2.1.210 起)。統一用 Edit(...) 就對了——它同時涵蓋 Write 和 NotebookEdit 的路徑檢查。
二、單段目錄 pattern 在 deny 和 allow 裡的深度語意不一樣。 這個不對稱很容易搞混:Edit(tests/**) 作為 deny 規則,會比對任何深度底下叫 tests 的目錄,所以 monorepo 裡的 packages/api/tests/ 也一起擋住;但同一條字串作為 allow 規則,只認 <cwd>/tests。想在兩種規則裡都拿到「任何深度」,寫 Edit(**/tests/**)。
三、deny 一律贏。 deny 優先於 allow 與 ask,而且跨 settings scope——user 層的 deny 蓋得過 project 層的 allow。更重要的是:PreToolUse hook 回傳 "allow" 也蓋不過 deny 規則。另外 deny 和 ask 規則不需要等資料夾 trust 就立即生效,allow 規則才需要。
四、要留後門給「新增測試」,用 gitignore 的反向規則。 deny list 支援 ! 開頭的 negation,它會從前面列出的規則裡挖掉一塊:
"deny": [
"Edit(tests/**)",
"Edit(!tests/_wip/**)"
]
注意順序:! 規則必須排在被它挖洞的規則後面,排第一條等於沒寫。
要更聰明的判斷:PreToolUse hook
路徑規則有個硬限制:它分不出「改既有測試」和「新增測試」。而後者通常是你希望 agent 做的事。
這時候用 hook。下面這支腳本的邏輯是:檔案不存在 → 放行(新增測試 OK);檔案已存在 → 擋掉,並回一段有用的說明。我在本機用模擬的 stdin JSON 跑過四種情境,行為符合預期。
#!/usr/bin/env bash
# .claude/hooks/protect-tests.sh
set -euo pipefail
input=$(cat)
path=$(jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' <<<"$input")
[[ -z "$path" ]] && exit 0
case "$path" in
*tests/*|*test_*.py|*_test.go|*.test.ts|*.spec.ts) ;;
*) exit 0 ;;
esac
# 檔案還不存在 = 在新增測試,放行
[[ ! -f "$path" ]] && exit 0
# 人工開鎖用的逃生門
[[ "${TEST_CHANGE_OK:-0}" == "1" ]] && exit 0
jq -n --arg p "$path" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("測試檔 \($p) 是規格,不是實作。請改 src/ 讓測試通過。" +
"若你確定是規格寫錯了,停下來告訴我「哪一條斷言錯、為什麼」,我會開鎖。")
}
}'
掛進 .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-tests.sh"
}
]
}
]
}
}
兩個實作上的重點:
exit code 2 也能擋。 PreToolUse hook 只要 exit 2,不管有沒有輸出 JSON 都會擋掉這次呼叫,而且 stderr 的內容會變成回灌給 agent 的理由。想寫得更完整(例如同時給 additionalContext)就用上面的 JSON 形式,exit 0 即可。
permissionDecisionReason 要當成 prompt 來寫,不是當成 log 來寫。 這段文字會在 agent 最想改測試的那一瞬間送到它眼前,比你寫在 CLAUDE.md 開頭有效得多——因為它是在正確的時間點出現的。寫「Permission denied」,agent 會開始試別的路徑(換 Bash 工具、換檔名);寫「這是規格,去改 src,真要改規格就先跟我說明哪條斷言錯」,它大多會照做。
別忘了 Bash 這個洞
官方文件把邊界講得很清楚:Read / Edit 的 deny 規則涵蓋 Claude 的內建檔案工具、Claude Code 認得的 Bash 檔案指令(cat、head、tail、sed、tee),以及 > file、< file 這類重導向的目標。
但它不涵蓋「自己開檔的子行程」——例如 python -c "open('tests/x.py','w').write(...)",或任何一支自己讀寫檔案的 Node script。這不是 bug,是權限檢查的本質限制:Claude Code 看得到的是那行指令,不是那個行程之後要開哪些 fd。
要真的封死,得往下掉到 OS 層。Claude Code 內建的 Bash sandbox 用 macOS Seatbelt 或 Linux/WSL2 的機制,對每個沙箱指令和它的子行程一起強制:
{
"sandbox": {
"enabled": true,
"filesystem": {
"denyWrite": ["./tests"]
}
}
}
(原生 Windows 不支援,要在 WSL2 裡跑。另外 sandbox 路徑前綴的語意跟權限規則不同:sandbox 用標準寫法,/tmp/build 就是絕對路徑;權限規則則是 //path 才是絕對路徑,/path 是相對於 settings 來源。這兩套語法混用很容易寫錯。)
換成別的 agent 呢
- Codex CLI:在
~/.codex/config.toml設sandbox_mode = "workspace-write",再用[sandbox_workspace_write]的writable_roots明確列出可寫的目錄。同樣是 OS 層強制,超出範圍的指令直接失敗而不是跑來問你。 - Cursor:
.cursor/hooks.json(或~/.cursor/hooks.json)。可以在beforeShellExecution、beforeMCPExecution、preToolUse回{"permission": "deny", "agent_message": "..."},exit code 2 同樣會擋。要注意的是 Cursor 在檔案編輯這條路上目前只有afterFileEdit(事後通知,擋不住),所以想擋編輯就得走preToolUse。
L2:Test runner 層——讓假通過在跑的當下就露餡
L1 管得住「誰動了測試檔」,管不住「這個測試到底有沒有在測東西」。後者只有在測試真的跑起來的時候才看得出來。

pytest:兩段 conftest
第一段擋 skip。我在 pytest 9.1.1 / Python 3.11 實際跑過:
# conftest.py
import os
import pytest
@pytest.hookimpl(trylast=True)
def pytest_sessionfinish(session, exitstatus):
if os.environ.get("ALLOW_SKIP") == "1":
return
tr = session.config.pluginmanager.get_plugin("terminalreporter")
if tr is None:
return
offenders = [
f"{key}: {getattr(rep, 'nodeid', rep)}"
for key in ("skipped", "xfailed", "deselected")
for rep in tr.stats.get(key, [])
]
if offenders:
tr.write_line("")
tr.write_line("TEST-GUARD: 偵測到被跳過的測試,本次視為失敗", red=True)
for o in offenders:
tr.write_line(f" - {o}", red=True)
session.exitstatus = 1
實際輸出:
.s [100%]
TEST-GUARD: 偵測到被跳過的測試,本次視為失敗
- skipped: test_demo.py::test_skipped_by_agent
1 passed, 1 skipped in 0.01s
EXIT=1
原本 pytest 對「1 passed, 1 skipped」是回 exit 0 的,這段把它扳成 1。agent 看到的是紅燈,不是綠燈,這就夠了。
第二段擋空殼測試與同義反覆斷言。這個要動 AST:
# conftest.py(接續)
import ast, inspect, textwrap
ASSERT_CALLS = {
"raises", "approx", "assert_called", "assert_called_once",
"assert_called_with", "assert_called_once_with", "assert_has_calls",
"assertEqual", "assertTrue", "assertRaises",
}
def _has_real_assertion(fn):
try:
src = inspect.getsource(fn)
except (OSError, TypeError):
return True
for node in ast.walk(ast.parse(textwrap.dedent(src))):
if isinstance(node, ast.Assert):
t = node.test
# assert True / assert 1:同義反覆,不算
if isinstance(t, ast.Constant) and bool(t.value):
continue
# assert x == x:兩邊一樣,不算
if isinstance(t, ast.Compare) and ast.dump(t.left) == ast.dump(t.comparators[0]):
continue
return True
if isinstance(node, ast.Call):
name = getattr(node.func, "attr", None) or getattr(node.func, "id", None)
if name in ASSERT_CALLS:
return True
return False
def pytest_collection_modifyitems(config, items):
bad = [
i.nodeid for i in items
if isinstance(i, pytest.Function) and not _has_real_assertion(i.function)
]
if bad:
raise pytest.UsageError("TEST-GUARD 空殼測試:\n " + "\n ".join(bad))
丟六個測試進去(正常斷言、assert True、assert x == x、完全沒斷言、pytest.raises、mock.assert_called_once),實跑結果:
ERROR: TEST-GUARD 空殼測試:
test_demo.py::test_tautology
test_demo.py::test_same_both_sides
test_demo.py::test_hollow
no tests ran in 0.04s
EXIT=4
三個該抓的抓到,pytest.raises 和 mock 斷言正確放行,退出碼 4(pytest 的 UsageError),非 0。
誠實講這招的限制:它只看單一函式的原始碼,所以會誤判兩種常見寫法——斷言寫在共用 helper 裡的、以及斷言發生在 fixture teardown 的。導入時先在既有測試上跑一次看誤判量,再把你們家的 helper 名字補進 ASSERT_CALLS。誤判率高到要一直加白名單的話,這層就別硬上,留給 L3 的 mutation testing。
Vitest / Jest:兩行設定值回本
// vitest.config.ts
export default defineConfig({
test: {
allowOnly: false, // 本機也禁 .only
passWithNoTests: false, // 測試檔被清空時不要默默通過
setupFiles: ['./test/guard.ts'],
},
})
// test/guard.ts
import { beforeEach, expect } from 'vitest'
beforeEach(() => {
expect.hasAssertions()
})
allowOnly 的預設值是 !process.env.CI——也就是 CI 上本來就會擋 .only,但你本機跑的時候不會。agent 多半是在你本機跑測試的,所以這行要明寫成 false。
expect.hasAssertions() 驗證這個 test 至少呼叫過一次斷言,必須在 test 內呼叫;丟進 beforeEach 就等於每個 test 都套上。這一行是 CP 值最高的反掏空措施——agent 可以刪掉斷言,但刪掉之後測試會自己失敗。
測試數 ratchet:三行擋掉「刪測試」
最便宜的反刪除機制,不需要任何工具:
# 建 baseline
pytest --collect-only -q | tail -1 # 6 tests collected in 0.04s
把數字存進 .test-baseline,CI 裡比對,只准增不准減。agent 想靠「刪掉兩個難搞的、補三個水的」來讓紅燈消失,這一關就過不了。等價指令:vitest list、cargo test -- --list、go test -list .。
L3:Commit / CI 層——唯一繞不過去的那道
agent-test-guard
GitHub 上的 ruskillerpro1987/agent-test-guard(MIT)是我目前看到把這件事做得最系統化的專案:Rust 寫的、用 Tree-sitter 做 AST 解析,涵蓋 TypeScript/JavaScript(Vitest、Jest、Playwright)、Python(pytest、unittest)、Rust、Go。
它的規則表就是前面那張圖:E001 抓 skip/ignore/only、E002 抓同義反覆斷言、E003 抓被掏空或 AST 指紋被改過的測試、E004 抓 E2E 測試裡的內部 mock、E005 抓「測試檔根本沒 import 要測的模組」、E010 抓測試數退化與 baseline 被刪。
兩個設計上值得抄的點:
- Tier A 直接讀 git index stage 0 的 blob,不讀工作目錄。README 說這是為了消除 TOCTOU(檢查完到實際使用之間被掉包)——對於一個正在旁邊持續寫檔的 agent 來說,這個顧慮是真的。
- Monotonic Floor + Ed25519 簽章:測試數只能自動往上棘輪,要縮減必須
test-guard ratchet --sign <key>或--allow-shrink,也就是需要人。這正是 L1 那個TEST_CHANGE_OK逃生門的正規版本。
CLI 大致長這樣:
test-guard init # 建 baseline
test-guard check --staged # pre-commit,README 稱 <25ms
test-guard check --deep # CI,會另外呼叫 runner 做 introspection
test-guard protect # 裝 hook 和檔案鎖
test-guard explain E001 # 查某條規則怎麼修
需要標註的是:「<25ms」和「Tier A 零 subprocess」都是專案 README 自己的數字,我沒有獨立實測。這個 repo 也相當新,真要進 CI 之前建議先讀過原始碼——把一支會決定你能不能 commit 的二進位檔放進流程,門檻本來就該比一般依賴高。從文件看,最值得先抄的其實是它的規則分類法,就算你不用這支工具。
真正的終局:mutation testing
前面所有方法都是在問「測試看起來對不對」。只有 mutation testing 在問「這個測試有在測東西嗎」——它去改你的 src(把 > 換成 >=、把回傳值換掉),然後看測試會不會紅。不會紅,代表那個測試根本沒在守這段邏輯。
這是唯一能抓到 E005(測試沒 import 到真的模組)和過度 mock 的方法,也是對付 Hora 與 Robbes 論文裡那個「36% mock」現象的正解。可用的工具:Stryker Mutator(JS/TS/C#/Scala)、cargo-mutants(Rust,Martin Pool 維護)、mutmut 與 cosmic-ray(Python)、PIT(Java,Henry Coles)。
代價是慢——它要把測試套件跑很多遍。實務做法是只對這次 PR 動過的檔案跑,而不是全 repo。
兩個必須知道的前提
git commit --no-verify 會跳過所有 pre-commit hook。 只要 agent 有 Bash 權限,它就打得出這一行;而在它急著收尾的時候,這行的出現機率並不低。結論很簡單:pre-commit 只是把回饋提早,同一份檢查必須在 CI 再跑一次,CI 才是真正的門。
CODEOWNERS 是最後的人工關卡。 把 tests/ 指派給人,加上 branch protection 的 required review,任何動到測試的 PR 都會需要人點頭。這一層擋的不是 agent,是「累了想趕快 merge」的你。
什麼時候不該鎖
這套東西鎖過頭會很難用,講清楚適用邊界:
TDD 的 red-green 循環本來就要一直改測試。 你在跟 agent 一起做 TDD 的時候,把 tests/ 全鎖死等於叫它原地打轉。做法是按目錄分——鎖 tests/regression/、tests/contract/ 這些「已經是規格」的,放開 tests/wip/。
重構期要整批重寫測試。 這時候該用的是簽章/環境變數開鎖,而不是硬扛。所以前面每個例子都留了逃生門(TEST_CHANGE_OK=1、ALLOW_SKIP=1、--allow-shrink)——防線一定要有一條合法的人工繞道。
沒有逃生門會產生更糟的行為。 這點要特別講:當 agent 發現測試真的改不動、而那條斷言又真的寫錯了,它不會停下來問你,它會去改 src 來迎合那條錯的斷言。你擋掉了一個看得見的壞行為,換來一個看不見的。所以 deny 的 reason 一定要明確寫出「你可以停下來告訴我哪裡錯」這條路。
一次性腳本、原型、週末專案:L2 一層就夠。 四行 vitest.config.ts 加一個 expect.hasAssertions(),投報率遠高於把三層都架起來。
落地順序
如果今天只有半小時:
- 先做 L2(約 10 分鐘,立刻見效)——
allowOnly: false+passWithNoTests: false+beforeEach(expect.hasAssertions),或 pytest 那段 skip guard。這層不需要改 agent 設定,任何 agent 都吃得到,而且對既有專案零破壞。 - 再做 L1(約 15 分鐘)——
permissions.deny先上,路徑規則不夠用再加 PreToolUse hook。記得把conftest.py和vitest.config.ts自己也鎖進去,否則 L2 等於裸奔。 - 最後把同一份檢查搬進 CI——測試數 ratchet 先上(最便宜),mutation testing 只對 PR 動過的檔案跑。
還有一件不花錢的事:把「為什麼不能改測試」寫進 deny 的 reason,而不是只寫進 CLAUDE.md。同一句話,出現在 agent 正要動手的那一刻,效果跟埋在 3000 字系統提示詞裡完全不同。
來源
- OpenAI,〈Detecting misbehavior in frontier reasoning models〉 — https://openai.com/index/chain-of-thought-monitoring/ ;對應論文 Monitoring Reasoning Models for Misbehavior and the Risks of Promoting Obfuscation,arXiv:2503.11926 — https://arxiv.org/abs/2503.11926
- Andre Hora、Romain Robbes,〈Are Coding Agents Generating Over-Mocked Tests? An Empirical Study〉,arXiv:2602.00409(2026-01-30) — https://arxiv.org/abs/2602.00409
- Anthropic,Claude Code 官方文件:Hooks reference — https://code.claude.com/docs/en/hooks ;Configure permissions — https://code.claude.com/docs/en/permissions ;Sandboxing — https://code.claude.com/docs/en/sandboxing
ruskillerpro1987/agent-test-guard(MIT) — https://github.com/ruskillerpro1987/agent-test-guard- OpenAI,Codex 設定參考(
sandbox_mode、writable_roots) — https://developers.openai.com/codex/config-reference - Cursor,Agent hooks 文件 — https://cursor.com/docs/agent/hooks
- Vitest,
allowOnly設定 — https://vitest.dev/config/allowonly ;expect.hasAssertions()— https://vitest.dev/api/expect - 題目來源討論串:r/ChatGPTCoding, "Tired of coding agents modifying your unit tests" — https://www.reddit.com/r/ChatGPTCoding/comments/1wkvupp/tired_of_coding_agents_modifying_your_unit_tests/
整理:DataAgent · Coding Agent 實戰教學
文章裡這些把關做法,要在一個團隊裡真的落地、而不是只有你一個人在用,通常卡在流程與共識。我把這部分整理成企業內訓:
coding agent 導入與治理 — 企業內訓與顧問 →


