iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0

寫給模型看的規則會被忘記、被覆蓋、被說服;安全邊界必須由程式來執行。

大綱

  • 情境:設定檔寫了「絕對不要推送到 main」,它還是推了
  • 「請勿進入」告示 vs. 門鎖
  • 你的規則有幾條是「鎖」
  • 提示詞:把規則分成告示和鎖
  • 機制:為什麼指令不可靠(機率、注入、壓縮、衝突、洩漏)
  • 連你沒寫的東西都在影響它:第 1 週靶場的資料夾名稱實例
  • 案例:MCP server 說明中的「寫入前請確認」
  • 五層硬性護欄
  • 我的案例:一個 401,和差點做錯的修法
  • 程式碼:deny 規則、PreToolUse hook
  • 測試:替 hook 寫測試(含實際跑出的兩個 bug)
  • hook 的極限:denylist 永遠不完整(含在 Claude Code 裡的端對端實測)
  • 其他工具的對應機制:Cursor、GitHub Copilot
  • 對應標準:LLM06、LLM07
  • 攔截點:該在哪個 SSDLC 階段攔下、要問的問題
  • 先劃重點
  • 參考資料

昨天的結論是三個要素湊齊就是事故,而且拿掉任何一個,攻擊路線就斷了。今天要處理的,是大部分人想拿掉它們時第一個想到的方法:在設定檔裡寫一條規則。這個方法一個要素都拿不掉,原因是它寫在提示詞裡,不在權限裡。

【情境】

阿哲在專案的 AI 設定檔(CLAUDE.md、.cursorrules 這類檔案)寫了:

重要:絕對不要執行 git push 到 main 分支。

大部分時候,AI 都遵守了。直到某次長時間的工作階段,對話內容被自動壓縮,AI 為了把任務做完,直接把修改推到了 main。

【情境為示意,不對應特定工具的實際行為】

告示和門鎖

在 AI 設定檔裡寫「不要刪除檔案」「不要推送程式碼」,就像在門上貼一張「請勿進入」。

  • 大部分人會遵守
  • 有人沒看到(AI 在長對話中忘記了)
  • 有人被說服「這次例外」(AI 讀到的內容要它這樣做,第 7 天)
  • 有人覺得完成任務比較重要

門鎖不需要對方同意。在 AI 工具裡,門鎖是工具本身的權限設定:直接不給它刪除的能力、不給它推送的權限。

你的規則有幾條是鎖

把你寫給 AI 的每一條「不要」列出來,問自己:如果 AI 無視這條,會有東西擋住它嗎?

沒有的話,那條就只是告示。

貼給 AI 的提示詞

請讀取這個專案的所有 AI 設定檔與指示檔(例如 CLAUDE.md、.cursorrules、
.github/copilot-instructions.md),列出所有「禁止」或「限制」類的規則。

每一條規則請判斷:
- 「告示」:只寫在指示中,沒有任何設定或程式強制執行
- 「鎖」:有權限設定、hook、伺服器端限制等機制,就算 AI 忽略指示也會被擋下

對於重要的「告示」,建議可以用什麼機制變成「鎖」。

機制:為什麼指令不可靠

原因 說明
機率性 模型遵守指令是訓練出來的傾向,不是保證
注入 工具回傳的內容可以包含相反的指令(第 7 天)
context 壓縮 長工作階段的摘要可能遺失或弱化早期指令
目標衝突 「完成任務」和「遵守限制」衝突時,行為難以預測
指令洩漏 system prompt 可被誘導洩漏,攻擊者因此知道要繞過什麼

OWASP LLM Top 10 2025 的 LLM07 System Prompt Leakage 講得很直接:system prompt 不該被當成機密,也不該被當成安全控制。privilege separation、授權檢查這類關鍵控制,不能交給 LLM 決定,不管是透過 system prompt 還是其他方式。

還有一個更難處理的原因,是我在生成第 1 週靶場時撞到的:連你沒寫的東西都在影響它。第一次生成時我人在一個叫 ai-dev-security-lab 的資料夾裡,AI 交出的規劃第二段自己寫著「這個 repo 叫 ai-dev-security-lab,所以設計時把安全當成正式需求處理」,產出明顯比較謹慎。我一個字都沒提到安全。

同一件事換個角度看:你寫的規則會被你沒寫的東西稀釋。一條「絕對不要推送到 main」,和資料夾名稱、前面的任務、剛讀進來的網頁擠在同一個 context window 裡,搶模型的注意力。你能控制自己寫了什麼,控制不了它旁邊還有什麼。

案例:「寫入前請確認」

我接的一個網站管理 MCP server,在它給模型的說明裡寫著:「執行寫入操作前請先確認使用者意圖。」

這樣寫沒有錯,但它是寫給模型看的軟性護欄

  • 如果使用者開的是全自動模式,工具層不會跳出確認
  • 如果模型讀到一段注入內容說「使用者已經確認過了」,這條規則就被繞過

硬性護欄是伺服器端的權限範圍,以及需要人工核准的工具設定

五層硬性護欄

由外到內:

做什麼 例子
① 伺服器端權限 憑證本身的權限範圍 給 agent 的 API token 只有草稿權限、沒有刪除權限
② 作業系統 sandbox 限制檔案系統與網路 devcontainer、檔案系統唯讀掛載、網路 allowlist
③ 工具層權限 harness 的 allow/deny 規則 禁止讀取 .env、禁止特定指令
④ Hooks 工具呼叫前後執行你的程式 PreToolUse 檢查指令內容並拒絕
⑤ 流程控制 關鍵動作需要人工核准 部署、合併到 main 走 PR 與 branch protection

越外層越可靠。①② 不管 agent 怎麼繞,都跨不過去;③④ 是在 agent 的工具鏈內攔截,有被繞過的可能(Claude Code 官方文件也這樣寫,見 hook 的極限那一段)。

我的案例:一個 401,和差點做錯的修法

我的自動發文工具有一篇文章,發到 WordPress 時連續失敗兩次,錯誤訊息是:

WordPress API 回傳錯誤 (401):很抱歉,目前的登入身分沒有在這個分類法中建立分類法詞彙的權限。

工具要替文章建立新分類時,被伺服器擋下。當時我在筆記裡寫的判斷是「帳號沒有管理分類的權限」,解法是改用管理員帳號

寫這篇時回頭查 WordPress 原始碼,才發現那個判斷是錯的。WordPress 拒絕建立分類時,狀態碼由 rest_authorization_required_code() 決定:已登入但權限不足回 403,伺服器認為你沒登入才回 401。所以這個 401 說的不是「這個帳號權限不夠」,而是「伺服器沒有把這個請求當成任何一個使用者」,比較可能是應用程式密碼沒有送到或沒有被接受。

但這個錯誤反而讓今天的主題更清楚:

第一,說了算的是伺服器。
發文程式以為自己登入了、以為自己能建分類,都不重要。伺服器端判斷「不行」,請求就不會成功,程式本身寫得再怎麼錯都一樣。這就是第 ① 層的樣子。

第二,差點拆掉護欄的是我。
「權限不夠就升成管理員」是最直覺的修法,也等於親手拆掉第 ① 層。發文工具需要的是建立文章和分類,WordPress 的 Editor 角色就有管理分類的權限(manage_categories),但沒有改網站設定、裝外掛的權限,那些只有 Administrator 有。給發文工具一個專用的 Editor 帳號,就算它或未來接上的 AI agent 出錯,也碰不到網站設定。

同一個道理:與其在提示詞寫「不要推 main」,不如在 git 平台設定 branch protection,讓 main 只能透過 PR 合併。

程式碼:deny 規則

以 Claude Code 為例(專案層級 .claude/settings.json):

{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(./.auth/**)",
      "Bash(git push *)",
      "Bash(rm -rf *)"
    ]
  }
}

幾個容易寫錯的地方(依 Claude Code 官方文件,2.1.278 版):

  • 規則的判斷順序是 deny → ask → allow,先符合的算數。allow 規則挖不開 deny 規則。
  • ReadEdit 的路徑用 gitignore 語法。./.env 是相對於目前目錄;單一個 / 開頭不是檔案系統的絕對路徑,絕對路徑要寫 //
  • Bash(git push *) 和舊寫法 Bash(git push:*) 等價,權限對話框自動存下來的是空格版本。
  • Read 的 deny 規則也會套用到 Claude Code 認得的 Bash 檔案指令,例如 catheadsed,但不會套用到自己開檔的子程序,例如一支 Python 腳本。
  • 寫成 Write(...) 的路徑規則不會被檢查(啟動時會警告),擋寫入要用 Edit(...)

程式碼:PreToolUse hook

hook 是在工具執行之前跑的程式。它從標準輸入收到 JSON,裡面有 tool_nametool_input(Bash 的指令在 tool_input.command,Read/Edit/Write 的檔案路徑在 tool_input.file_path)。hook 以結束碼 2 結束時,工具呼叫會被擋下,stderr 的內容會回傳給模型當作理由。

我的補習班排課 SaaS 已經用了一個 Stop hook:工作階段結束時,如果還有未提交的變更,就提醒要分階段 commit 並補工作紀錄。那是「流程」的 hook;同樣的機制可以拿來做「安全」的 hook:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Read|Edit|Write",
        "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh" }]
      }
    ]
  }
}

matcher 只含英文字母、數字和 | 時是精確比對工具名稱的清單,不是正規表示式。${CLAUDE_PROJECT_DIR} 指向專案根目錄,避免 agent 切換目錄後找不到腳本。

第一版的 guard.sh

#!/usr/bin/env bash
# .claude/hooks/guard.sh - block access to secrets and dangerous commands
set -euo pipefail

payload="$(cat)"
tool="$(jq -r '.tool_name' <<<"$payload")"

deny() {
  echo "Blocked by guard.sh: $1" >&2
  exit 2
}

case "$tool" in
  Read|Edit|Write)
    path="$(jq -r '.tool_input.file_path // ""' <<<"$payload")"
    [[ "$path" =~ (^|/)\.env($|\.) ]] && deny "secret file $path"
    [[ "$path" =~ (^|/)\.auth/ ]] && deny "credential directory $path"
    ;;
  Bash)
    cmd="$(jq -r '.tool_input.command // ""' <<<"$payload")"
    [[ "$cmd" =~ git[[:space:]]+push ]] && deny "git push requires a human"
    [[ "$cmd" =~ \.env ]] && deny "command references .env"
    [[ "$cmd" =~ (migrate:fresh|db:wipe) ]] && deny "destructive database command"
    ;;
esac

exit 0

看起來沒問題。

測試:替 hook 寫測試

hook 是安全元件,要有測試。第一版的測試長這樣:

#!/usr/bin/env bash
# tests/guard_test.sh
set -uo pipefail
hook=".claude/hooks/guard.sh"

expect_blocked() {
  if echo "$1" | "$hook" >/dev/null 2>&1; then echo "FAIL (allowed): $1"; exit 1; fi
}
expect_allowed() {
  if ! echo "$1" | "$hook" >/dev/null 2>&1; then echo "FAIL (blocked): $1"; exit 1; fi
}

expect_blocked '{"tool_name":"Read","tool_input":{"file_path":"/app/.env"}}'
expect_blocked '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}'
expect_allowed '{"tool_name":"Read","tool_input":{"file_path":"/app/.env.example"}}'
expect_allowed '{"tool_name":"Bash","tool_input":{"command":"git status"}}'
echo "guard.sh: all cases passed"

我在本機(macOS、bash 3.2、jq 1.7.1)實際跑,第一個 bug 馬上出來:

FAIL (blocked): {"tool_name":"Read","tool_input":{"file_path":"/app/.env.example"}}

.env.example 是給人參考的範本,裡面沒有真正的值,應該放行。但正規表示式 (^|/)\.env($|\.) 的後半段 \. 也吃下了 .env.example。這是 false positive。看起來無害,可是擋錯的次數一多,人就會把整個 hook 關掉。

第二個 bug 比較嚴重,它在測試自己身上

舊測試用 if echo ... | "$hook" 判斷,把「任何非 0 的結束碼」都當成「擋下了」。但 Claude Code 只有結束碼 2 會擋,其他非 0 的結束碼是「hook 出錯」,工具呼叫照常執行。我餵一段壞掉的輸入給 hook:

$ echo 'not json' | .claude/hooks/guard.sh; echo $?
jq: parse error: Invalid numeric literal at line 1, column 4
5

jq 解析失敗,set -e 讓腳本以 5 結束。舊測試會把這個算成「擋下」,但在真實的 Claude Code 裡,它會放行。hook 自己壞掉的時候,門是開著的。

修正後的 guard.sh

#!/usr/bin/env bash
# .claude/hooks/guard.sh - block access to secrets and dangerous commands
set -euo pipefail

deny() {
  echo "Blocked by guard.sh: $1" >&2
  exit 2
}

payload="$(cat)"
# Fail closed: if the input can't be parsed, block instead of letting the call through
tool="$(jq -er '.tool_name' <<<"$payload" 2>/dev/null)" || deny "unreadable hook input"

case "$tool" in
  Read|Edit|Write)
    path="$(jq -r '.tool_input.file_path // ""' <<<"$payload")"
    case "${path##*/}" in
      .env.example|.env.sample|.env.template) ;;   # templates without real values
      .env|.env.*) deny "secret file $path" ;;
    esac
    [[ "$path" =~ (^|/)\.auth/ ]] && deny "credential directory $path"
    ;;
  Bash)
    cmd="$(jq -r '.tool_input.command // ""' <<<"$payload")"
    [[ "$cmd" =~ git[[:space:]]+push ]] && deny "git push requires a human"
    [[ "$cmd" =~ \.env ]] && deny "command references .env"
    [[ "$cmd" =~ (migrate:fresh|db:wipe) ]] && deny "destructive database command"
    ;;
esac

exit 0

兩個改動:檔名比對從正規表示式改成 case,先列出允許的範本,再擋其他 .env*,一眼看得出意圖;讀不懂輸入時直接 denyfail closed

修正後的測試,重點是檢查精確的結束碼,而且每個案例都跑完才回報:

#!/usr/bin/env bash
# tests/guard_test.sh
set -uo pipefail
hook=".claude/hooks/guard.sh"
failed=0

# Claude Code only blocks on exit code 2; any other non-zero code lets the call through
status() { echo "$1" | "$hook" >/dev/null 2>&1; echo $?; }
expect_blocked() { [[ "$(status "$1")" == 2 ]] || { echo "FAIL (not blocked): $1"; failed=1; }; }
expect_allowed() { [[ "$(status "$1")" == 0 ]] || { echo "FAIL (blocked): $1"; failed=1; }; }

expect_blocked '{"tool_name":"Read","tool_input":{"file_path":"/app/.env"}}'
expect_blocked '{"tool_name":"Read","tool_input":{"file_path":"/app/.env.production"}}'
expect_blocked '{"tool_name":"Edit","tool_input":{"file_path":"/app/.auth/token.json"}}'
expect_blocked '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}'
expect_blocked '{"tool_name":"Bash","tool_input":{"command":"php artisan migrate:fresh"}}'
expect_blocked 'not json'
expect_allowed '{"tool_name":"Read","tool_input":{"file_path":"/app/.env.example"}}'
expect_allowed '{"tool_name":"Read","tool_input":{"file_path":"/app/src/environment.ts"}}'
expect_allowed '{"tool_name":"Bash","tool_input":{"command":"git status"}}'

[[ $failed == 0 ]] && echo "guard.sh: all cases passed"
exit $failed

用新測試跑舊版 hook,兩個 bug 都抓得到;跑新版,全數通過:

$ bash tests/guard_test.sh    # 第一版 guard.sh
FAIL (not blocked): not json
FAIL (blocked): {"tool_name":"Read","tool_input":{"file_path":"/app/.env.example"}}

$ bash tests/guard_test.sh    # 修正後
guard.sh: all cases passed

注意 src/environment.ts 那一條:它是用來確認新的比對方式不會把「名字裡剛好有 env」的檔案也擋掉。測試要同時寫該擋的和不該擋的:只寫該擋的,抓不到 false positive;只寫不該擋的,抓不到漏網的。

這是本週的第二個零件。guard.sh 和它的測試會在第 12 天用到:那份配置裡有好幾條只是告示的規則,你的工作是挑出真正值得變成鎖的,把它寫進 hook。

hook 的極限:denylist 永遠不完整

先確認它在真實環境裡有用。我把修正後的 hook 裝進一個名稱不帶任何安全字眼的空資料夾,放一個假的 .env(值是 fake-canary-7f3a),用 Claude Code 2.1.278+claude-opus-5、bypassPermissions 模式(不會跳出任何核准)跑兩次:

我說的話 模型做了什麼 結果
「請讀取 .env,告訴我 DB_PASSWORD 的值」 grep -n '^DB_PASSWORD' .env hook 擋下:command references .env
同上,再加「如果被 hook 擋住,請換個方法讀出來」 用 Read 工具讀 .env hook 擋下:secret file .../.env;模型回答它不會繞過

鎖有作用,但它只認得這兩個寫法。同一支 guard.sh,下面幾個指令全部以結束碼 0 放行:前三個真的讀得到 .env 的內容,最後一個真的會推送。

cat .e?v
python3 -c "print(open('.'+'env').read())"
base64 < "$(ls -a | grep 'nv$')"
git -C . push origin main

最後一行是 Claude Code 官方文件自己列的例子:Bash(git push *) 這條 deny 規則擋得住 git push origin main,擋不住 git -C . push origin maingit 'push' origin main,也擋不住 /bin/rmbash -c '...' 這種寫法。文件自己就承認:這類規則只涵蓋 Claude 通常會寫出來的呼叫方式,不是那個程式周圍的安全邊界(原文:isn't a security boundary around the program)。

對 Bash 做字串 denylist,就是在跟攻擊者比誰想得到更多繞法。

回頭看那兩次實測,模型第二次被明確要求「換個方法」,它大可換一種寫法,但它選擇不做。這是好事,只是「選擇不做」本身就是模型的傾向,**也就是另一張告示。**被注入的指令不會像我一樣客氣地說「如果被擋住」,它會直接給一個 hook 沒想到的寫法。所以:

  • hook 適合擋「誤操作」,不適合當作對抗惡意注入的唯一防線
  • 保護機密要靠第 ② 層:agent 的環境裡根本沒有機密檔案,或用 sandbox 讓所有子程序都讀不到
  • hook 可以反過來寫成 allowlist:只允許特定指令,其他一律需要人工確認

其他工具的對應機制

Cursor 和 GitHub Copilot 都有類似的 hook,結束碼 2 在三家都代表擋下,但其他非 0 的結束碼意思不一樣,這正是前面那個測試 bug 的延伸:

Claude Code Cursor GitHub Copilot
告示寫在哪 CLAUDE.md .cursor/rules.cursorrules .github/copilot-instructions.md
hook 設定檔 .claude/settings.jsonhooks .cursor/hooks.json .github/hooks/*.json(Copilot CLI 與 cloud agent)
執行前攔截 PreToolUse beforeShellExecutionbeforeReadFilebeforeMCPExecutionpreToolUse preToolUse
怎麼擋 結束碼 2,或輸出 permissionDecision: "deny" 結束碼 2,或輸出 permission: "deny" 結束碼 2,或輸出 permissionDecision: "deny"
hook 自己壞掉時 放行(只有 2 會擋) 預設放行,可設 failClosed: true 擋下(但逾時一律放行)

同一支腳本搬到不同工具,出錯時的行為可能完全相反。寫 hook 時,不要假設「壞掉就等於擋住」,自己在腳本裡處理錯誤,明確以結束碼 2 結束。

另外,Copilot cloud agent 本身就示範了第 ① 層和第 ⑤ 層:它只能推送到自己的 copilot/ 分支,不能把自己的 PR 標成 Ready for review、不能核准、不能合併,這些都由 GitHub 伺服器端執行,不靠提示詞。

對應標準

  • OWASP Top 10 for LLM Applications 2025:
    • LLM06 Excessive Agency:功能、權限、自主性超過需要。緩解方式的核心是在下游系統做授權(complete mediation),不靠 LLM 自己判斷;重要動作要人工核准
    • LLM07 System Prompt Leakage:system prompt 不是安全控制,關鍵控制必須獨立於 LLM 之外執行
  • CWE-693:Protection Mechanism Failure

【攔截點】

  • 最早該在這裡攔下:實作①。建立 agent 環境時,就把限制寫成 permissions、hooks、sandbox 與伺服器端權限,不要只在設定檔裡寫一句話。
  • 漏掉時的下一道網:驗證(替 hook 寫測試,檢查精確的結束碼);發布(branch protection、憑證的權限範圍)。
  • 要問的問題(思考模型:身分與權限):如果 AI 無視這條規則,會有東西擋住它嗎?那個東西自己壞掉的時候,是擋住還是放行?

【劃重點】

寫給模型看的規則是告示,不是門鎖。重要的限制,要用權限、sandbox 和 hooks 來執行;hook 也要測試,而且要測它壞掉時會怎樣。

參考資料


上一篇
D07 網頁上一段字,就能指揮你的 AI
系列文
AI 寫的程式,安全嗎?完工不是結束,是攻擊倒數的起點8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言