iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
AI Security

一個 Agent 我查不動,一個我改得動:內部威脅偵測的 30 天系列 第 14 篇

回頭看 Day1:同一支會當掉的 hook,omp 擋下、Cursor 放行——那 11 天,其實是一個預設值

  • 分享至 

  • xImage
  •  

昨天(Day13)我決定把模型移出熱路徑:hook 照舊只寫紀錄,問 Jev 的事交給另一支常駐程式慢慢做。那篇誠實欄的最後一條寫著:「答案晚到,代價是它不能拿來擋任何事。」

這句話的另一面是:能在當下擋住 Agent 的,只剩 hook 自己。所以下一個問題很直接——hook 自己壞掉的時候,會怎樣?這件事,Day1 其實已經遇過一次。

昨天預告的「Agent 說做完了,真的做完了嗎」,我挪到 Day17,用一個更小的版本做:給它一件在限制下根本做不到的事,看它怎麼交件。

先介紹今天的另一個主角:omp

Day1 我說,這 30 天用 Cursor(改不動的閉源)和 Pi(可改的開源)做對照。從今天起,開源這一側換成 omp(oh-my-pi)。它是 Pi 的 fork,一樣是 MIT 授權的開源專案,主體用 TypeScript 寫、跑在 Bun 上,底層有一大塊是 Rust。Day1 引用 Pi 的安全文件,說它設計上刻意不做沙箱隔離;omp 的文件也多處寫明 not sandboxed。這個系列用的版本是 omp 18.2.8。

兩邊的 hook 長得不一樣。Cursor 的 hook 是登記在 .cursor/hooks.json 裡的外部指令,每觸發一次就開一個新行程,Day13 量到的 1.7 秒就是這樣來的。omp 的 hook 是放在專案 .omp/hooks/pre/ 底下的 TypeScript 檔,由 omp 自己載入,跟 omp 跑在同一個行程裡。

另外,我用一支 extension(cursor-sdk)讓 omp 接上 Cursor 的模型。這一點很重要:兩邊可以跑同一個模型。

為什麼要跟 Cursor 比

Day10 到 Day12,我量的都是 Cursor:它會等 hook 跑完、hook 讓每一步多花 1 到 2 秒、同時跑的 hook 會把紀錄蓋掉。只看一邊有個盲點:看到一個行為,分不出那是 Cursor 的選擇,還是每個 Agent 都會這樣。

所以從今天起,能兩邊都跑的實驗,我就兩邊各跑一次:同一個模型(Claude Opus 5.5)、同一句提示詞、同樣規則的 hook。結果不一樣的地方,就能歸給平台。差異出現時,omp 那邊還能打開原始碼,找到是哪一行造成的;Cursor 那邊,只能從外面觀察。這就是系列標題那句話:一個查不動,一個改得動。

今天先重跑 Day1 的兩件事:一支監視器故障 11 天沒人知道;還有 WebSearch/WebFetch,本機監視器看不到。兩邊用的是同一種壞掉的 hook。

那 11 天,Cursor 文件說有一個開關

Cursor Hooks 有一個 failClosed 選項,預設 false。腳本 crash、逾時、非 0 exit、完全沒輸出——這四種情況預設放行。Day1 的 BOM 讓 Python 一啟動就崩潰,正是其中之一,所以 AI 照常工作。設成 true,同樣四種情況改成擋下;文件明講這個選項是為 beforeMCPExecution、beforeReadFile 這類安全關鍵 hook 準備的。

界線要分清楚:腳本有跑完、但輸出的 JSON 格式錯誤,這種情況本來就預設擋下,不需要 failClosed。只有「整支腳本死掉」才是預設放行。另外,腳本以 exit code 2 結束等同回傳 permission: "deny",這是為了相容 Claude Code 的 hook。

至於「監視檔」,Cursor 的 Compliance 文件寫得很直白:「We do not log agent responses or generated code content. Instead, we recommend using hooks to log prompts and code.」Day1 的 hooks.jsonl 從頭到尾就是我自己腳本的產物,不是 Cursor 給的保底。

這次,我真的讓 Cursor 跑了一次

Cursor 的 SDK 文件寫明,SDK 的 local runtime 會遵守專案裡的 .cursor/hooks.json。所以我不必相信文件:我用 @cursor/sdk 在一個暫存專案裡啟動 Cursor 自己的 agent,工具是 Cursor 自己的 Read/Write/Shell,只載入專案層的設定,模型是 Opus 5.5。

Cursor 版的「BOM 崩潰」hook 長這樣,先記下自己被呼叫過,然後以 exit code 1 結束:

import { readPayload } from "./_log.mjs";

await readPayload("preToolUse");
process.stderr.write("BOM crash simulation: SyntaxError reading stdin\n");
process.exit(1);

掛在 preToolUse 上,給兩邊同一句話:用 write 工具建一個內容為 hi 的 hello.txt,失敗就停下來回報錯誤。

平台 hook 設定 發生了什麼 hello.txt
omp(Opus 5.5) 會丟例外的 tool_call hook 擋下:Extension …/crash-on-tool-call.ts failed: BOM crash simulation: SyntaxError reading stdin 沒有被建立
Cursor(Opus 5.5) 會 exit 1 的 preToolUse hook,預設設定 放行。hook 確實被呼叫、確實崩潰了,模型回報:The tool call didn't return any errors. 被建立了
Cursor(Opus 5.5) 同一支 hook,加上 "failClosed": true 擋下:Rejected: Tool blocked because this hook is configured to fail closed (block when it fails). Hook "…/crash-hook.mjs" failed with exit code 1: BOM crash simulation: SyntaxError reading stdin 沒有被建立

Day1 花了 11 天才發現的事,這次 14 秒就重現了。最刺眼的是第二列那句「The tool call didn't return any errors.」——模型沒有說謊,從它的角度看,確實什麼事都沒發生。監視器壞了,工作照樣完成,沒有任何一方知道。這就是 Day1 的處境。

加上 failClosed: true 之後,Cursor 的訊息反而非常清楚,連 hook 的 stderr 都一起轉給了模型。所以開關是有效的,只是預設是關著的。

這也讓系列標題裡的「查不動」更精確一點:透過 SDK,我看得到 Cursor 的 hook 引擎怎麼表現;但它的程式碼我讀不到,預設值也改不了。我能做的,是記得在每一支安全 hook 上加 failClosed。omp 那邊,這個行為就寫在 runner.ts 裡,看得到,也改得動。

omp 這邊:我先驗錯了一次

第一次驗證時,我直接拿 omp 原始碼裡的 HookRunner/HookToolWrapper 寫測試,得出「tool_call 沒有 timeout」的結論。後來重讀 docs/hooks.md 才發現:預設 CLI 載入 .omp/hooks/pre/*.ts 時,走的是另一條 extension runner 路徑,我測的是舊路徑。原始碼在手上,一樣可能驗錯地方。

所以這次改走讀者載入 hook 的同一條路:把 hook 檔放進 <cwd>/.omp/hooks/pre/,讓 omp 自己的 discovery 去找、去載入,再交給預設的 ExtensionRunner/ExtensionToolWrapper 處理。範圍要先講清楚:工具是我寫的 stub,tool call 是我直接送進去的,沒有模型參與——驗的是 hook 這一層怎麼處理失敗,不是整個 agent 跑起來的樣子。模擬 BOM 崩潰的 hook 長這樣:

export default function (pi) {
	pi.on("tool_call", async () => {
		throw new Error("BOM crash simulation: SyntaxError reading stdin");
	});
}

四種壞法,實際結果

壞法 掛在哪個事件 實際結果
handler 丟例外(模擬 BOM) tool_call 擋下,工具沒執行
handler 卡住不回應 tool_call 逾時後擋下(預設 30 秒,測試設 300ms)
handler 丟例外 tool_result 放行,結果原封不動;錯誤回報給 onError
hook 檔語法錯誤,載入失敗 — guard 不存在,寫入 .env 照樣執行

omp 回報的原文:

Extension <cwd>/.omp/hooks/pre/crash-on-tool-call.ts failed: BOM crash simulation: SyntaxError reading stdin
Extension <cwd>/.omp/hooks/pre/hang-on-tool-call.ts timed out after 300ms
Failed to load extension: Failed to parse extension source for dependency rewriting: <cwd>/.omp/hooks/pre/broken-syntax.ts: Unexpected token (2:85)

前三列說明:在 omp 裡,安全判斷該掛在 tool_call——丟錯、卡住都算「不同意」。掛在 tool_result 的東西壞掉,工具照樣跑完,只是錯誤不會憑空消失,有 onError 可以接。

換真的模型再跑一次

上面那張表裡,tool call 是我手動送的。第一種和第四種壞法,我另外讓真的模型在 omp 上跑:omp 透過 cursor-sdk extension 接上模型,工具是 omp 真正的 write,hook 由 omp 自己從 .omp/hooks/pre/ 載入。

裝的 hook 模型 omp 回給模型的 hello.txt
會丟例外的那支 composer-2.5、Opus 5.5 Extension …/crash-on-tool-call.ts failed: BOM crash simulation: SyntaxError reading stdin 兩次都沒有被建立
語法錯誤、載入失敗的那支 composer-2.5 Successfully wrote 2 bytes to hello.txt 被建立了

兩次 omp 都在 stderr 留了一行字:丟例外的那次是 Extension error (…/crash-on-tool-call.ts): BOM crash simulation: SyntaxError reading stdin,載入失敗的那次是 Failed to load extension …/broken-syntax.ts: …。

第四種壞法,才是 Day1 的真正翻版

fail-closed 有一個前提:hook 要先載入成功。檔案本身載入失敗,guard 就等於不存在,後面沒有任何東西在擋——這跟 Day1 那支壞掉的監視器是同一種處境。

兩邊放在一起看,其實是同一件事的兩個版本:Cursor 是 hook 跑起來之後壞掉,預設放行;omp 是 hook 載入時就壞掉,等於不存在。差別在於 omp 至少會出聲:非互動模式的 stderr 印了一行 Failed to load extension——但 session 照常執行,模型要寫的檔案照樣寫出來了。Day1 是監視器壞了沒人知道;這裡是有人在旁邊小聲說了一句,工作照樣繼續。結論很實際:裝了 guard,要確認它真的載入了、真的會擋,不是放進資料夾就算數。

我沒有冤枉 WebSearch

我原本懷疑 Day1 的「本機監視器看不到 WebSearch」,是我自己沒掛對 matcher:Cursor 的 Third Party Hooks 文件證實 WebFetch、WebSearch 在 Cursor 內部有工具身份,但主要 Hooks 文件列出的 matcher 清單裡沒有它們,兩份官方文件對不起來。

這次直接驗。還是 Cursor 自己的 local runtime、還是 Opus 5.5,工具只開 webSearch 和 webFetch,掛一支不帶 matcher、什麼工具都會觸發的 preToolUse,外加 beforeMCPExecution,兩支都只記錄、不攔截。問的就是 Day1 那一句:查今天台北的天氣。

模型真的查到了:「Taipei is hot, sunny and dry today (Monday, September 28, 2026)」,現在大約 90°F(32°C),而且註明「This was the reading as of 11:00 AM local time.」

本機的 hook:0 次呼叫。SDK 自己的事件串流裡,也一個 tool_call 事件都沒有,只有模型的文字、狀態和用量。本機這一側拿到的只有最後那段文字;搜尋看起來是在 Cursor 的伺服器那一側完成的(我沒有另外抓本機的網路流量來確認)。

Day1 的結論是對的,不是 matcher 的問題。這次測的是 SDK 的 local runtime,Day1 是 Windows 上的 Cursor IDE;兩邊的現象一致。

今天的 5 分鐘小練習

第 1 步:把上面那支會 throw 的 hook 存成 .omp/hooks/pre/crash.ts,叫 omp 寫一個檔案,看它是不是被擋下。

第 2 步:故意把檔案改出語法錯誤,重開 omp,看它有沒有告訴你這支 hook 沒載入。非互動模式下,我看到的是 stderr 的一行 Failed to load extension;互動介面上怎麼顯示,我沒有驗證。

第 3 步:在你的 Cursor 專案裡放一支一定會 exit 1 的 preToolUse hook,叫 AI 建一個檔案,看檔案是不是照樣出現;再加上 "failClosed": true 重跑一次。

第 4 步:掛一支不帶 matcher、只記錄的 preToolUse hook,叫 AI 查一次天氣,看紀錄檔裡有幾筆。我這次看到的是 0。


今天對應的威脅: T1、T2、T3 全部——監視器本身誠不誠實、到底有沒有在跑,是三種威脅能不能被抓到的前提。

引用來源:

  • Cursor 官方 Hooks 文件(failClosed、exit code、matcher 清單)、Third Party Hooks(Tool Name Mapping)、Compliance and Monitoring 文件;Cursor SDK 文件(local runtime 遵守 .cursor/hooks.json)
  • omp 18.2.8:README.md(Pi 的 fork、MIT、TypeScript/Rust/Bun)、docs/extension-loading.md(not sandboxed)、docs/hooks.md、extensibility/extensions/runner.ts(emitToolCall、EXTENSION_HANDLER_TIMEOUT_MS = 30_000)
  • cursor-sdk extension:extensions/cursor-sdk/README.md(讓 omp 用 Cursor 的模型)
  • Cursor live:verification/cursor-live/(C1a、C1b、C10;cursor-summary.txt)
  • omp hook 層實測:verification/bun-test-output.txt(Day14-a~d)
  • omp live:verification/live/L1-crash-tool-call/、verification/live/L2-broken-syntax/、verification/live/OPUS-L1-crash-tool-call/

上一篇
問一次 Jev 只要 0.25 秒,塞進 hook 卻要 1.7 秒:我最後把模型移出了熱路徑
下一篇
同一個模型考兩個平台:六題只差一題、omp 贏——它輸的,是卷子外面的三個地方
系列文
一個 Agent 我查不動,一個我改得動:內部威脅偵測的 30 天 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言