昨天講完第 3 層是什麼、以及它在那個案子裡完全沒有被設置。今天全部是可以複製的東西:三個階梯,由零風險到真的會擋,加起來不到一小時。
第 0 層是寫在文件裡的規定。第 1 層是「實際的值是什麼」的來源。第 3 層是工具層強制,AI 物理上做不到違反。第 4 層是擋不住的,至少讓機器發現。preventive 是事前擋掉,detective 是事後發現。
但結論我要先講,因為它決定你怎麼讀後面:這三個階梯做到的是「順手改不了」,不是「改不了」。
hook 擋得住順手的動作,擋不住有意的意圖。真正的「改不了」要靠檔案系統權限——後面有十個實測情境標出這條界線。
我是做完才發現的。在那之前,我以為我裝的是一道牆。

今天會用到 jq 和 hook。這兩樣都不是為了管 AI 而發明的,你很可能早就用過。
jq 是命令列的 JSON 處理工具。平常拿它來看 API 回傳了什麼、從一包設定檔裡撈一個欄位、把巢狀的 JSON 攤成一行。它只做一件事:把 JSON 拆開來看。
hook 更熟。pre-commit 跑 lint、pre-push 跑測試——commit 之前先攔一下,不過關就不讓你 commit。掛一支自己寫的腳本在某個時機上,讓它決定放不放行。
AI 工具把同一套搬了過來。時機換成「session 開場」「AI 要動工具之前」「AI 收尾」。時機到了,工具把「現在的狀況」包成一段 JSON,從 stdin 丟給你的腳本;腳本 exit 0 就放行,exit 2 就擋下來,那次寫檔、那行指令根本不會發生。
jq 在這裡的工作,就是把那段 JSON 拆開,讓腳本看得出 AI 到底要寫哪個檔、要寫什麼內容、要跑什麼指令。沒有它,腳本收到的只是一團文字。
會擋的那種腳本,這篇叫它 guard。
擋昨天那一行。
那個案子在 12 月 26 日把「DB Schema 不可動」寫成方框,主規範文件又重申了一次。粗體、驚嘆號、「絕對」「必須」「嚴禁」,該用的都用了。然後測試問題單裡出現:
AI 創建了新的資料表
規定是清楚的,語氣是強的,結果是沒用的。因為那句話從頭到尾只是一句話——它寫在文件裡,要生效得靠 AI 自己願意配合。
先看「AI 創建了新的資料表」在檔案上長什麼樣。AI 不會直接去動資料庫,它是生一支 migration 檔:
// Migrations/20260115023000_AddOrders.cs
migrationBuilder.CreateTable(
name: "Orders",
columns: table => new { Id = table.Column<int>(nullable: false) });
關鍵是那一行 migrationBuilder.CreateTable。要建表,這串字就一定得出現在某個檔案裡。
而 AI 要把這支檔案寫出去,得先呼叫工具。工具在真的寫之前,會把這次呼叫包成一段 JSON 丟給你的腳本:
{
"tool_input": {
"file_path": "Migrations/20260115023000_AddOrders.cs",
"content": "migrationBuilder.CreateTable(\n name: \"Orders\", ..."
}
}
看到 content 了嗎?檔案還沒落地,但它要寫的內容已經在你手上。 剩下的就很機械:用 jq 把 content 撈出來,grep 找那串字,找到就 exit 2。
jq -r '.tool_input.content // ""' | grep -q 'migrationBuilder\.CreateTable' && exit 2
exit 0
exit 2 的意思是「這次工具呼叫失敗」。那支檔案不會出現在磁碟上,AI 收到一段錯誤訊息,然後它得換個做法。
規定沒有變,還是那句「DB Schema 不可動」。變的是它現在有牙齒:從「請你不要」變成「你做不到」。
這就是強檢核——規則不靠自律,靠程式。
上面那兩行是骨架,真正能用的版本要處理更多情況(Edit 走的是別的欄位、Bash 也能寫檔、動詞不只一個),那是第二步的事。
先把這支 guard 的性格講清楚,它決定了哪些規則搬得過來、哪些搬不過來。
它不理解你在寫什麼。 它只做一件事:在內容落地之前,看看裡面有沒有出現某一串字。migrationBuilder.CreateTable 對它來說不是「建立資料表」,是 28 個字元。
這個性格帶來三件好處。判斷是確定性的——同樣的輸入永遠同樣的結果,不需要 AI 參與,不會今天擋明天不擋。成本幾乎是零,一次 grep。而且它看的是「即將被寫進去的內容」,不是磁碟上的現況,所以檔案還不存在也攔得到。
所以可以搬的不是 migrationBuilder 那串字,是它的形狀:
一條人話規則 → 違規時一定會出現的固定字串 → 寫入之前 grep 得到
同一條「不可以動 Schema」,換個棧就是換一個字串:
| 技術棧 | guard 要找的東西 |
|---|---|
| EF Core(.NET) | migrationBuilder.CreateTable、.AddColumn、.Sql |
| Rails | create_table、add_column、change_column |
| Django | migrations.CreateModel、migrations.AddField |
| Flyway/純 SQL | CREATE TABLE、ALTER TABLE、DROP TABLE |
規則也不限於 Schema。只要符合上面那個形狀,同一支 guard 換一行字串清單就能用:不准動 lock 檔、不准改 CI 設定、不准刪測試、不准把金鑰寫進程式碼。
而它的邊界來自同一個性格:違規必須在文字上留下固定的痕跡。
「不可以把商業邏輯寫進 Controller」就沒有這種痕跡。它沒有一個固定字串,你 grep 不到,硬寫只會變成誤擋——而依 Day 16 的說法,那種護欄最後都會被關掉。
所以動手之前先分類:能寫成字串的,往第 3 層搬;寫不成的,留給第 4 層去發現。昨天那個判準的第二句話,就是為這種規則準備的。
今天的三個階梯就是三支這樣的腳本:一支不會擋、一支真的會擋、一支只負責發現。
這一節的腳本有相依,而我第一版一個字都沒交代。這件事本身就違反我在第 1 層講的那條:規範文件不能引用一個不存在的東西。
先講最兇的那個。
假設你照下一節把 guard(寫入之前攔截的腳本)裝好,開始工作。AI 寫第一個檔案,被擋下來。換一個,還是被擋。你回頭檢查規則、路徑、matcher,每一項都對。
問題在於你的機器上沒有 jq(命令列的 JSON 處理工具)。
那支 guard 是 fail-closed 的——解析不出 JSON 就一律擋。jq 不在,它就永遠解析不出來,於是它非常盡責地擋下每一次寫入。而錯誤訊息裡不會出現 jq 這三個字。
設計是對的,體驗是災難。腳本第一行要補這個:
command -v jq >/dev/null || { echo "⛔ 這支 hook 需要 jq,請先安裝" >&2; exit 2; }
它一樣擋,但它說得出為什麼。一道擋得住、卻講不出理由的閘門,使用者第一件事就是關掉它。
另外兩個相依溫和得多,壞的時候你看得見:
bash 3.2 就夠。 腳本用到 [[ ]] 和 <<<,macOS 內建的 3.2 都有;用 sh 跑會當場炸。grep -oE 兩邊相容,但 sed -i 在 macOS 要寫成 sed -i '';換平台會炸,一樣看得見。那些 hook 的事件名稱、JSON 欄位、退出碼語意、設定檔格式,全部是特定工具的 API(.claude/settings.json、PreToolUse/PostToolUse/SessionStart、$CLAUDE_PROJECT_DIR)。
換一個工具全部不適用,而且這些欄位改過好幾次。原理可以搬,語法不能抄——請以官方文件為準,這一節寫的是我當時看到的行為,不是規格。
動手前也順手看一下勘誤頁。因為光是這一節,我自己就改過三次:hook 掛錯事件、子 shell 裡的 exit 出不去、grep 抓錯對象。與其假裝它以後都不會錯,不如給你一個會更新的地方。
設定檔放在專案根目錄的 .claude/settings.json。最小可運作的版本是 15 行:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash .ai/scripts/checklist.sh"
}
]
}
]
}
}
那個 command 指的是一支你要自己建的腳本。這件事我原本沒交代,而它是這一節最容易卡住的地方:
mkdir -p .ai/scripts
$EDITOR .ai/scripts/checklist.sh # 內容見下面
chmod +x .ai/scripts/checklist.sh # 這一步不做,hook 會靜默失敗
路徑相對於專案根目錄。
第一支腳本我建議從永遠 exit 0 的那種開始:只印一段提醒,不做任何判斷。
但這裡有一個坑,我自己踩過。
我原本把它掛在 Stop 上,心想「每次 AI 收尾都唸一次清單」。裝完之後終端機一片安靜。因為 exit 0 的 stdout 不會進到模型的 context——只有 SessionStart、UserPromptSubmit、UserPromptExpansion 這幾種會被加進去,Stop 不在名單裡。
它跑了,它 exit 0 了,而它什麼都沒發生。連錯誤訊息都沒有。
所以正確的掛法是 SessionStart:
#!/bin/bash
# SessionStart hook — 流程提醒。永遠 exit 0,stdout 會被加進模型的 context。
cat <<'EOF'
🔔 收尾檢查
─────────────────────────────────
□ 驗證階段已對所有交付標準逐項跑過
□ 待辦清單裡沒有還在 in_progress 的項目
□ 連續失敗 3 次以上時,已經停下來問人
□ 這次改了哪些檔案,已經在回覆裡說明
─────────────────────────────────
EOF
exit 0
把上面設定檔的 "PostToolUse" 換成 "SessionStart"、matcher 那一行拿掉。
為什麼從這支開始:零風險、零相依(只有一個 cat)、內容全部是你自己的話。而它掛在 SessionStart 上是真的會被模型讀到。你可以直接問它「你這個 session 的收尾檢查清單有哪幾條」來驗證。
這個驗證動作不要省。一支「裝了但沒作用」的 hook 比沒有更糟,因為你會以為那裡有防護。
開場那個方框,現在要變成程式。
昨天給過一個五行的示範版,也說了它不完備——只涵蓋三個動詞,RenameColumn、AlterColumn、Sql() 都沒擋到。下面是補完的版本,你可以現在就裝上去。它為什麼長這樣、我第一版錯在哪,看完再說。
#!/bin/bash
# PreToolUse: 阻擋 Migration 的結構異動(Edit/Write 的內容 + Bash 的指令)
# 註:用 set -uo pipefail,不要 set -eu —— grep 沒命中會回 1,
# 配 set -e 會讓腳本在「沒有違規」的時候直接中止,看起來像通過,其實是崩了。
set -uo pipefail
command -v jq >/dev/null || { echo "⛔ 這支 hook 需要 jq,請先安裝(brew install jq)" >&2; exit 2; }
# 那份關鍵規則文件寫的是「絕對禁止異動 DB Schema」。這一行是它的可執行版本。
VERBS='migrationBuilder\.(AddColumn|DropColumn|CreateTable|DropTable|RenameColumn|AlterColumn|Sql)'
input=$(cat)
# 看「即將被寫進去的內容」,不是磁碟上的檔案——PreToolUse 跑的時候那支 migration 還不存在。
# 三個來源都要看:Write 的 content、Edit 的 new_string、Bash 的 command。
body=$(jq -r '[.tool_input.content, .tool_input.new_string, .tool_input.command]
| map(select(. != null)) | .[]' <<<"$input" 2>/dev/null)
# ── fail-closed:解析不出來就擋,不是放行 ──
if [ -z "$body" ] && [ -n "$(printf '%s' "$input" | tr -d '[:space:]')" ]; then
echo "⛔ 無法解析工具輸入,保守起見擋下。請人工確認。" >&2
exit 2
fi
if printf '%s' "$body" | grep -qE "$VERBS"; then
{
echo "⛔ 偵測到 Migration 結構異動"
echo "DB Schema 不可動。要改請走變更流程,不要直接產 migration。"
} >&2
exit 2
fi
exit 0
設定檔的 matcher 要寫成 "Edit|Write|NotebookEdit|Bash"。裝完之後請故意違規一次:叫 AI 新增一張資料表,確認它被擋;再叫它用 cat > 把同樣的內容寫進 Migrations/,確認也被擋。
這是我原本寫的:
#!/bin/bash
# PreToolUse: 阻擋 Migration 結構異動 ← 這一版有洞
set -eu
input=$(cat)
body=$(echo "$input" | jq -r '.tool_input.content // ""')
[[ -z "$body" ]] && exit 0 # ← 洞在這裡
case "$body" in
*"migrationBuilder.CreateTable"*)
{ echo "⛔ 偵測到 Migration 結構異動"; } >&2
exit 2
;;
esac
exit 0
設定檔那邊我寫的是 "matcher": "Edit|Write"。
這兩件事加起來,它擋不住任何有心繞過的寫入。
Bash 不在 matcher 裡,hook 根本不會被觸發。就算把 Bash 加進去,Bash 的輸入欄位是 command 不是 content,jq 取不到值,[[ -z "$body" ]] && exit 0 那一行直接放行。連 Edit 都擋不住——它帶的是 new_string,也不是 content。
於是這些全部通過:
# 用 Bash 直接把 migration 寫出來
cat > Migrations/20260115_AddOrderTable.cs <<'EOF'
migrationBuilder.CreateTable(name: "Orders", ...);
EOF
# 讓工具自己去產,指令字串裡連一個被禁的動詞都沒有
dotnet ef migrations add AddOrderTable
加護欄的時候要問「繞過它的路是什麼」,而我加這道的時候沒問。
而這正好是後面會講到的那四種護欄失效裡的一種。範圍太窄:只擋到一種變形,換個寫法就繞過去了。我在寫這一段的時候犯的,就是我自己列在清單上的那一條。
上面那個補完的版本,三個修法對應三種失效:matcher 加 Bash、三個輸入欄位都看、動詞清單補齊,擋的都是範圍太窄;解析失敗改成 exit 2,擋的是 fail-open——工具壞掉的時候安靜地全部放行。
補完之後它還是擋不住最後那一條:
dotnet ef migrations add AddOrderTable
因為檔案是工具產生的,不是 AI 寫的。 hook 看到的指令字串裡沒有任何一個被禁的動詞,而真正含有 CreateTable 的那支 .cs 檔,是 dotnet 自己寫到磁碟上的——那一刻沒有任何工具呼叫經過 hook。
這不是補動詞可以解決的,是層級不對。
而 Bash 這一路的字串比對到底有多脆,我在另一支 guard 上實測過。那一支擋的是「不可寫入某個參考目錄」,做法同樣是比對指令字串。十個情境,三個繞過、兩個誤擋:
| 情境 | 結果 | |
|---|---|---|
sed -i s/x/y/ ro/a.txt |
擋下 | ✅ |
cd ro && sed -i s/x/y/ a.txt |
放行 | ❌ 指令字串裡沒有 ro/ |
D=ro; echo hi > $D/a.txt |
放行 | ❌ 變數展開發生在 shell,不在 hook |
python3 -c 字串拼接路徑 |
放行 | ❌ 路徑根本不是字面值 |
cat ro/a.txt(純讀取) |
擋下 | ❌ 誤擋 |
grep -rn foo ro/(純讀取) |
擋下 | ❌ 誤擋 |
因為它是字串比對,而 shell 的語意不是字串。 同一件事換到 Migration 上完全一樣:cd Migrations && cat > Foo.cs、把內容組在變數裡再寫出去、用 Python 拼字串,hook 看到的都只是一段不含關鍵字的文字。
要標清楚證據等級:上面那張表是我逐條跑出來的,dotnet ef 那一條也是實際確認過的。但「換到 Migration 上完全一樣」這句是推論,我沒有把那十個情境在 Migration 的版本上重跑一次。它是推論,不是實測。
所以這一版的正確定位是:它擋得住「順手」,擋不住「有意」,而且會誤傷讀取。 誤擋可以靠加判斷寫入動詞來收,但繞過那幾條在 hook 這一層解不掉。真正擋得住 dotnet ef migrations add 的,是讓開發環境連到唯讀副本——資料庫自己拒絕,不需要任何規則。
這就是開場那句話的實測版本:hook 是次好的 preventive(事前擋掉),不是 preventive。
還有一個我原本寫下去的「防呆」,也要一起收回:
bash -c '[ -x .../check.sh ] && .../check.sh || true'
腳本不存在就靜默通過。當時我覺得這很體貼。而它其實是在說「當護欄壞掉的時候,就當作沒有護欄」。preventive 的閘門必須 fail-closed;detective(事後發現)可以 fail-open。 這兩類要分開接線,而那個 || true 只該用在第二類。
Day 13 講過,過期的規範對 AI 不是失效,是誤導。它會照著一個已經不存在的結構產碼,而且不會報錯。而第 0 層的內容幾乎全部是路徑,所以路徑一失效,就是那件事:AI 讀不到,也不會報錯,它會用先驗繼續產碼。
核心只有五行:
#!/bin/bash
fail=0
for doc in CLAUDE.md .ai/INDEX.md; do
[ -f "$doc" ] || continue
while IFS= read -r p; do
[ -n "$p" ] && { [ -e "$p" ] || { echo "❌ $doc 引用了不存在的路徑: $p" >&2; fail=1; }; }
done < <(grep -oE '`\.(ai|dev)/[^`]+`' "$doc" | tr -d '`' | sort -u)
done
exit $fail
這裡有一個我第一版寫錯、而且錯得很安靜的地方。 我原本把內層寫成管線接 while read:
grep -oE ... | tr -d '`' | sort -u | while read -r p; do
[ -e "$p" ] || { echo "❌ ..."; exit 1; } # ← 出不去
done
while 在管線右側是一個子 shell,exit 1 只結束那個子 shell,外層拿到的還是 0。 實測的結果是:它把紅字印出來,然後放行。而且會不會擋,取決於哪一份文件先壞(壞的那份如果排在最後,剛好會回 1,看起來像正常運作)。
上面用的是 while ... done < <(...)(程序替換),不是 for p in $(...)。後者會依空白斷字,一個叫 .ai/my scripts/a.sh 的真實檔案會被拆成兩條不存在的路徑然後誤報。那是用一個誤報換掉一個漏報,不算修好。 順便改成收集完全部再退出,而不是遇到第一個就走。因為你要的是「哪幾條壞了」,不是「有沒有壞」。
掛在 Stop 上(這一支只是印給你看,不需要進模型 context)。驗證方式一樣:在 CLAUDE.md 裡故意寫一個不存在的路徑,確認它回 1。這是投報率最高的一支。五分鐘寫完,而它擋掉的是最陰險的一類失效:沒有錯誤訊息、沒有警告,你只會覺得「這次 AI 表現比較差」。
文件檢查是 detective。它的職責是發現,不是阻止。所以腳本還沒寫好的時候可以靜默通過:
bash -c '[ -x "${CLAUDE_PROJECT_DIR:-.}/.ai/scripts/check.sh" ] && \
"${CLAUDE_PROJECT_DIR:-.}/.ai/scripts/check.sh" || true'
但同一個寫法絕對不能套到第二步那支 PreToolUse 上。那一支是 preventive,它壞掉的時候必須擋,不是放行。(另外:用 $CLAUDE_PROJECT_DIR,不要寫死絕對路徑,不然換一台機器就壞了。)
換你自己的規則的時候,不必從零寫。今天踩出來的東西,剛好可以整理成一份規格丟給 AI。
下面這段可以直接貼給 Claude 或 Codex,把角括號裡的兩行換成你的:
幫我寫一支 PreToolUse hook 腳本(bash),擋下違反這條規則的寫入。
規則:<用一句人話寫,例如「不可以異動資料庫 Schema」>
違規特徵:<違規時一定會出現的字串,例如 migrationBuilder.CreateTable、AddColumn、Sql>
要求:
1. 輸入是 stdin 的一段 JSON,用 jq 解析。
2. 要看三個欄位:.tool_input.content、.tool_input.new_string、.tool_input.command。
只看 file_path 會漏掉「用 Bash 寫檔」這條路。
3. fail-closed:jq 解析不出來就 exit 2,不可以放行。
4. 腳本第一行先確認 jq 存在,不存在就 exit 2 並印出原因。
5. 命中就把「違反了哪條規則、該怎麼做」寫到 stderr,然後 exit 2;沒命中 exit 0。
6. 用 set -uo pipefail,不要用 set -eu——grep 沒命中會回 1,配 set -e 會讓腳本
在「沒有違規」的時候中止,看起來像通過,其實是崩了。
7. 不要用 grep | while 的寫法,管線右側是子 shell,exit 出不去。
另外給我:
- 對應的 .claude/settings.json 片段,matcher 要涵蓋 Edit|Write|NotebookEdit|Bash。
- 三組測試輸入與各自的預期退出碼:一個該擋的、一個該放行的、一個故意餵壞 JSON 的。
第 2 到第 7 條不是規格潔癖,是這一篇的三個坑:漏看欄位、fail-open、子 shell 吃掉 exit。你不寫進去,AI 產出來的版本大機率就是我第一版那個樣子——看起來會擋,實際上不會。
最後那一項要特別留著。它產完你要真的去跑那三組輸入,尤其是「該擋的」那一組。一支「裝了但沒作用」的 guard 比沒有更糟,而這件事不會因為腳本是 AI 寫的就不成立。
還有一件 AI 幫不了你的:角括號裡的「違規特徵」只能你自己填。 那條規則能不能寫成一個固定字串,是這一整節在講的判斷,而那個判斷需要你知道自己的系統違規時長什麼樣。
我原本有三個地方講得太滿:
第三條最根本:它跟被它管的對象在同一個可寫範圍裡。
stderr + exit 2 的真閘門(30 分)→ 文件路徑存在性(5 分)PreToolUse 才擋得住,PostToolUse 是檔案寫下去之後才跑的
明天講第 4 層:當機器擋不住的時候,怎麼讓機器至少能發現。