# 05 — 四層防線：從「提醒自己小心」到「機器擋下來」

上一層：[04 任務層](04-task-files.md)　下一層：[06 日常循環](06-daily-loop.md)　模板：[templates/](templates/)

---

## 為什麼需要四層，而不是一層

因為每一層都有一種**它自己看不見的失敗**：

| 層 | 它擋什麼 | 它自己的盲點 | 所以要有下一層 |
|---|---|---|---|
| **L1 hook** | 指令樣式、檔案事件 | 只在 agent 下指令時觸發 —— 你自己開終端機打的它看不到 | git 原生 hook 補這個縫 |
| **L2 守衛** | 「做完了也不會知道出錯」的事 | **壞掉的守衛跟正常的守衛在終端機上長得一模一樣** | L3 演練 |
| **L3 演練** | 證明守衛真的會紅 | 它驗的是「邏輯」，不是「在真環境成立」 | L4 端到端 |
| **L4 端到端** | schema／型別／外鍵在真環境的行為 | 慢（數十分鐘），不可能每次改動都跑 | 回到 L1 |

---

## L1 — hook：執行環境跑，你想忘也忘不掉

### 它的成本結構是這一層存在的全部理由

```
不觸發時：0 上下文成本（它不在你的對話裡）
觸發時：  一段訊息，而且是在你真的要做那件事的當下
```

對比一下：寫進規則正文的一條規則，**每一次對話**都要付它的行數；
而它擋不住復發（見 [02](02-rules-file.md) 的「發作三次」）。

### 三種事件掛三種東西

| 事件 | 時機 | 適合掛什麼 | 動作 |
|---|---|---|---|
| **PreToolUse** | 指令送出**之前** | 危險指令樣式 | `deny`（直接擋）／`ask`（問一下） |
| **PostToolUse** | 檔案寫入**之後** | 語法檢查、敏感內容寫錯目錄、同步提醒 | `block`（擋下這次編輯）／`note`（提醒） |
| **UserPromptSubmit** | 我送出訊息時 | 每日簡報、把某個檔的規則放到眼前 | `note` |

### 五個真實的 hook 規則（可直接照抄概念）

| 規則 | 動作 | 它擋的那次事故 |
|---|---|---|
| `pipe-to-head-tail` | deny | 守衛指令接了 `\| tail`，`&&` 看到的是**管線末端的 exit 0**，於是帶著紅燈提交 |
| `migration-without-target` | deny | 設定檔有三個具名資料庫目標，指定錯就**靜默升級到另一個庫** |
| `server-app-dir` | deny | `--app-dir` 不改工作目錄，`./app.db` 解析成一個新的空檔，症狀是「資料全不見了」 |
| `commit-secret-scan` | deny | 測試 profile、內部文件、commit email 都曾隨 repo 外流 |
| 前端語法檢查 | block | SyntaxError 讓整支 script 不執行，**而畫面看起來像功能 bug** |

最後一條特別值得說：兩個並行的對話各自宣告了一次同名的 `const`，
症狀是「翻譯功能壞了」，實際上是整支 script 沒被執行、翻譯函式從來沒被呼叫過。
**這種錯誤靠讀 code 找要花半小時，靠 hook 在存檔當下擋只要 3 秒。**

### 🚨 加新 hook 的兩條硬規矩

1. **必附反向案例。** 一支「全部擋掉」的 hook 也會在只有正向測試的演練裡拿滿分。
   那個專案的 hook 演練有 41 個方向，其中 **18 個是反向**（近似但不該中的指令必須放行）。
2. **誤報比漏報更危險。** 誤報會讓人乾脆把 hook 關掉，然後你連漏報都不會知道。
   有一條規則因為正則寫得太粗，**連續誤報六次** —— 它擋掉了偵測器自己的程式碼，
   也擋掉了正在解釋這條規則的文件。

> **判準：偵測「值」，不要偵測「描述值的樣式」。**
> 一個憑證偵測器要求「完整三段含簽章」而不是「看起來像 base64」，誤報才會降下來。

### ⚠️ 兩個誠實的限制

- **agent 的 hook 只在 AI 下指令時跑。** 你自己開一個終端機打 `git commit` 它完全不知道。
  ⇒ 所以安全掃描那一半要**另外掛在 git 原生的 `.git/hooks/pre-commit`**，
  這樣不管誰、從哪個終端機提交都會觸發。
- **`.git/hooks/` 不進版控** ⇒ 換機器、重新 clone 之後就沒了。
  而「守衛不見了」比「守衛報錯」更安靜。
  ⇒ **把安裝動作本身寫成版控裡的腳本**（`tools/install_git_hooks.py`），
  換機器時是「再跑一次」不是「重寫一遍」。而且它要 fail-closed：找不到直譯器就擋下提交並說明。

---

## L2 — 守衛：`tools/*.py`，PASS 只印一行

### 設計原則

```
PASS 時：只印一行，含覆蓋率數字。原始碼永遠不進上下文。
FAIL 時：印出「實際抓到的那一行」，不是只印 pass/fail。
前置條件不滿足時：FAIL，不可以是 return。
```

最後一條是重點：

> 🚨 **守衛的「前置條件不滿足」必須是 FAIL。**
> 看到 `if not X: return` 出現在守衛裡，先問「**X 不在的時候，誰會知道**」。

真實案例：一支守衛的端點檢查因為載不到設定檔而整段跳過，**它照樣 exit 0**。
那個方向從此完全沒在驗，而終端機上看到的是一個漂亮的綠燈。

### 🟢 綠燈的可信度 —— 十條判準

這一節是整套系統價值密度最高的地方。**它是一支守衛用五種不同方式報假綠燈換來的。**

1. **綠燈必須附「我到底驗到了什麼」的數字。**
   種 0 個欄位／渲染 0 處／走完 0 個分頁 ⇒ **一律 FAIL**。沒有數字的 PASS 只是「沒去看」的同義詞。
2. **覆蓋率是守衛的價值本身。** 沒走到的地方，綠燈只是沒去看 —— 覆蓋數字要印出來讓人一眼驗收。
3. **守衛卡住 ≠ 守衛沒發現**，兩者在終端機上長得一模一樣。
   等外部回應的迴圈要有上限，等不到要**大聲失敗**。
4. **改了比對邏輯就要重跑故障演練**（注入 → 確認會紅 → 逐位元組還原）。
   演練要印**實際抓到的那一行**；還原一律 `try/finally`，收尾重新驗一次錨點。
5. **靜態掃描回答不了「輸出的那一刻有沒有做對」。**
   要用注入實測（種 payload → 讓程式自己渲染 → 數觸發次數）。
   同理：由工廠動態產生的路由數不可 `grep`，要把 app 跑起來列舉。
6. **同一件事有 N 種寫法 ⇒ 沒有人數得出來誰有保護。**
   解法是「**唯一入口 ＋ 機器守衛**」，不是「把規則寫清楚一點」——
   規則說得出「該用什麼」，說不出「有人沒用」。
7. **過時的狀態行是雙向有害的**：把做完的寫成待辦（排程時整包重做）／
   把已經安全的寫成不安全（浪費上線那天的時間）。看到就當場改。
8. **守衛的「前置條件不滿足」必須是 FAIL，不可以是 `return`。**
9. **「原始碼檢查全過」也是一種會騙人的綠燈** —— 它答不出「行為對不對」。
   **數行為**（呼叫數／渲染數／走過的頁數）才是綠燈的內容物。
   而且「我測過了」要接一句「**我是在哪一種狀態下測的**」。
10. **`cmd | tail` 會把 exit code 換成 `tail` 的。**
    守衛一律單獨執行並讀 exit code，或 `set -o pipefail`。

### 一支好守衛的輸出長什麼樣

```
$ python tools/role_check.py
PASS  寫入端點 148 個 · 帶 write=True 145 · 具名豁免 3 · 未保護 0
```

一行，但它回答了「你驗到了幾個」。對照一支**壞掉但看起來一樣正常**的守衛：

```
$ python tools/role_check.py
PASS
```

第二種你永遠不知道它是驗過了，還是一個 `return` 就跳出去了。

### 「改到什麼就跑什麼」對照表

在規則檔裡放這樣一張表，是我找到最有效的形式 ——
**因為它的第一欄是「我剛才改了什麼」，這是我唯一確定知道的事**：

| 改到 | 跑 | 它擋的那種**安靜**失敗 |
|---|---|---|
| 前端任何 render | `xss_fuzz.py` | 畫面全對、console 乾淨、測試全綠，但別人的 JS 在使用者的瀏覽器裡跑 |
| model / migration | `pg_check.py` | **本機全綠、上雲當場死**（同一份 model 在兩個 dialect 翻成不同 DDL） |
| 端點 / 權限依賴 | `role_check.py` | **前端把按鈕變灰、後端其實照寫**（`write` 預設 False ⇒ 漏傳＝沒防護且沒錯誤訊息） |
| 規格 / 資料模型 | `spec_check.py` | 規格與 code 漂移 |
| 索引 / 擴充點 | `ext_check.py` | 索引說謊（符號改名／檔案搬走）。**腐爛的索引比沒有索引更糟，它會誤導** |

---

## L3 — 演練：證明守衛真的會紅

### 為什麼這一層不可省略

> **壞掉的守衛跟正常的守衛，在終端機上長得一模一樣。**

一支新守衛曾經用**五種不同的方式**報假綠燈。五種都是「印 PASS」，五種都沒在驗東西。

### 標準流程

```python
for direction in DIRECTIONS:
    inject(direction)                    # 注入一個假故障
    assert run_guard() != 0, "沒紅 ⇒ 這個方向沒在驗"
    assert direction.marker in output    # 印出實際抓到的那一行
    restore()                            # try/finally
    assert file_bytes == original_bytes  # 逐位元組比對還原乾淨
```

四個細節都是踩出來的：

1. **注入要真的改檔案**，不是 mock。mock 驗的是你的想像。
2. **要檢查「抓到的那一行」**，不是只看 exit code —— 有可能是**別的原因**讓它紅的。
3. **還原一律 `try/finally`** —— 演練中斷會留下一個被污染的 repo。
4. **逐位元組比對** —— 「看起來還原了」跟「還原了」是兩件事。

### 🚨 反向案例

演練必須包含「**近似但不該中的輸入必須放行**」的方向。
沒有反向案例的話，一支 `return FAIL` 的守衛會在演練裡拿滿分。

那個專案的比例：hook 演練 41 個方向裡 **18 個是反向**（約 44%）。

---

## L4 — 端到端：本機綠不算數

### 核心判準

> **本機綠只代表「邏輯與回歸沒壞」，不代表 schema、型別、外鍵在真的資料庫上成立。**

上雲那天一天出現四個 bug，全部是「本機 SQLite 綠、PostgreSQL 紅」。
從此多一條規則：**部署前必須對一個真的 PostgreSQL（獨立 branch）跑一輪全套**（約 35 分鐘）。

### 三個環境的分工

```
本機 SQLite     開發中的快速回饋（約 1 分鐘）
雲端 dev branch 提交與部署前的閘門（真 PostgreSQL，約 35 分鐘）
雲端 production 正式資料，測試永不寫入（fail-closed，不是印警告）
```

⚠️ **fail-closed 不是可選的。** 印警告會被忽略，而被忽略的代價是正式庫多一批假資料，
**而且沒有人會發現**（測試自己會清掉大部分，看起來一切正常）。

### 🚨 兩種資料狀態都要跑

那四個 bug 裡有一個躲過了「有 225 個使用者的本機庫」那次驗證 ——
而它要防的情境正是**上線那天的形狀**（空庫）。

⇒ 推論：**「再跑一次看有沒有過」不是驗證。**
修完要問「**我剛才是在哪一種狀態下驗的、另一種呢**」。

### 同樣的道理適用於任何「只在一種環境驗過」的東西

一個部署設定檔放錯位置三個月沒人發現，因為它的**內容、語法、檔名全都正確**。

> **靜態檢查抓不到「東西放在對的形狀、錯的地方」。**

---

## 已知限制（誠實的部分）

- **hook 攔不到 shell 重導向。** `db_peek prod > output.txt` 這種寫法，
  agent 的 hook 看到的是指令、看不到它寫出什麼；git 的 hook 要到 commit 那一刻才看得到檔案。
  那段空窗只能靠「**輸出一律寫進被 gitignore 的目錄**」這條規則本身，以及把那個目錄整個 ignore 當後備。
- **守衛守不住「需求本身就錯」。** 沒有任何一層接得住這個。
- **L4 太慢，不可能每次改動都跑。** 所以 L1–L3 的覆蓋率決定了你多常需要 L4。

---

## 接下來

- 把這四層串起來的日常流程 → [06-daily-loop.md](06-daily-loop.md)
- 可直接改的 hook 與守衛骨架 → [templates/](templates/)
- 事故轉守衛的 prompt → [prompts/04-postmortem-to-guard.md](prompts/04-postmortem-to-guard.md)
