在 Strands Box 的政策引擎裡,一次被拒絕的讀檔,可以替 agent 換到一張寫檔許可。

假設你讓 agent 在沙盒裡改東西,但希望它動手寫檔之前,先去讀一份核准檔,表示「有人看過、同意了」。這種「先做 A 才准做 B」的規則,Box 的政策語言寫得出來,而且寫法看起來非常自然。問題是,最自然的那種寫法,擋不住一個什麼都沒讀到的 agent。

這個洞是 Box 的作者自己寫進設計文件的,還另外寫了一支測試,專門證明這條規則會被繞過。

一條看起來很正常的規則

Strands Box 是給 AI agent 用的沙盒,NOTICE 檔寫著版權屬於 Amazon.com,授權 Apache-2.0,用 Rust 寫。唯一的 release 是 2026 年 10 月 7 日的 v0.1.0,README 標著 Preview,目前只支援 Apple silicon 的 macOS。

它的結構分兩邊。agent 關在作業系統的沙盒裡;沙盒外面有一個受信任的行程,裡面跑四個元件:對外連線的 egress gateway、Python 直譯器 Monty、shell 直譯器 Strands Shell、MCP broker。四個元件共用同一個 Dogwood 政策引擎和同一份事件歷史。Dogwood 是 Cedar 的超集,多了「看歷史」的時序規則,所以你可以寫「讀過某個檔之後五分鐘內不准對外連線」這種跨元件的規則。

下面這段政策節錄自 repo 裡的測試 temporal_shell_e2e.rs。它先禁止讀 /tmp/secret 開頭的任何檔案,再規定:一小時內有讀過 /tmp/secret-approval,才准寫檔。

1
2
3
4
5
6
7
8
9
forbid(principal == Box::Agent::"self", action == Box::Action::"fs:read", resource)
when { context.input.path like "/tmp/secret*" };
permit(principal == Box::Agent::"self", action == Box::Action::"fs:read", resource);
permit(principal == Box::Agent::"self", action == Box::Action::"fs:write", resource)
when temporal {
formerly within 3600s (
Box::Action::"fs:read"::request{ input.path: "/tmp/secret-approval", input.operation: Box::FsReadOperation::"read_content" }
)
};

注意兩條規則互相矛盾:核准檔本身落在 forbid 的範圍裡,誰都讀不到。照直覺,寫檔應該永遠不會被放行。

測試的斷言是三步。第一次寫 /tmp/pre-a.txt,被拒,因為還沒讀過核准檔。接著讀 /tmp/secret-approval,被拒,符合 forbid。然後寫 /tmp/pre-b.txt,斷言是 is_ok(),訊息寫著 KNOWN HAZARD: the refused read still satisfied the ::request predicate。

讀被拒絕了,寫卻通過了。

帳本先記,警衛才判

原因在引擎處理一個請求的順序。設計文件 policy.md 寫得很直接:一次決策不是單純的查詢,引擎先把請求 append 到持久化的歷史,再評估規則,而且 append 在判決回傳之前就已經落盤。

拿大樓門禁來想。警衛的流程是:有人走到櫃台,先在訪客簿寫一行「某某,十點零五分,要上十二樓」,再打電話問樓上要不要放行。樓上說不要,人被請走。訪客簿上那一行還在,而且簿子根本沒有「被拒」這一欄。下午有人翻簿子問「這個人今天來過沒有?」,答案是來過。

Box 的歷史裡,每個動作有三種事件:

事件 什麼時候寫進歷史
::request 每一次嘗試,不論允許或拒絕
::response 操作發生了,或可能發生了
::error 操作確定沒發生

上面那條規則問的是 fs:read::request,也就是訪客簿那一行。被拒絕的讀取照樣留下 ::request,前置條件就這樣被滿足了。decisions.md 的原話是:request 事件不帶判決,所以用 ::request 寫的 step-up 規則,會被「去要、被拒絕」這個動作擊敗;這個失效模式「is known and open」。

作者沒有偷偷修掉它

這是我讀這個 repo 最在意的地方。

一個已知會被繞過的寫法,常見的處理是開一張 issue,或在文件角落補一句「注意」。Box 的作者做了一件更硬的事:寫一支測試去斷言「不安全的寫法真的不安全」。測試開頭的註解是 A KNOWN HAZARD, pinned so it cannot change silently,後面說明修法要改引擎的 ingest 順序,而那是 upstream 的事,也就是 Dogwood 引擎本身。同一條也記在 crates/policy/AGENTS.md,給在這個 repo 裡工作的 coding agent 看。

把陷阱釘成測試,等於規定這個行為只要一變,測試就會失敗。哪天 Dogwood 改了順序,這支測試會先倒,逼人回頭處理。比起一句「小心」,這種寫法沒辦法被當成背景雜訊略過。

安全的寫法只差一個字。同一個檔案的下一支測試,把 ::request 換成 ::response,其他一字不改,斷言就反過來了:讀核准檔被拒之後,寫檔也被拒。理由是 ::response 只由 PolicyEngine::record 寫入,被拒絕的操作從來不會走到那一步。

官方使用手冊的範例也照這個規矩寫。「一小時內 cargo test 以 exit 0 結束,才准 git commit」那條規則,用的是 shell:spawn::response 加上 output.status: 0,旁邊還特地寫了一句:::request 事件在被拒絕的嘗試也存在,所以用它寫的規則,在一次被拒絕的 cargo test 之後就會放行 commit。

先記帳換來的東西

讀到這裡,很容易下結論說「那就改成先判再記」。可是先記帳這個順序,換來的是另一件很難做對的事:精確的計數。

設計文件說,每個請求都在同一把鎖底下先 append,下一個請求才開始判決。所以 ::request 的計數包含之前每一次嘗試,用 ::request 寫的「最多 N 次」預算剛好放行 N 次,就算 N 個請求同時到也一樣。::response 的計數只看得到已經完成的操作,好幾個同時在跑的請求,可以各自通過同一個計數,一起超額。security.md 把它列成一條限制:A budget keyed on ::response can be overspent by requests in flight。

所以兩種事件各有一個缺點,而且剛好互補:

拿來做什麼 用 ::request 用 ::response
前置條件(先做 A 才准 B) 被拒絕的嘗試也算數,會被繞過 只有真的發生才算,安全寫法
次數預算(N 次以內) 精確 N 次,但被拒絕的嘗試也扣額度 只算完成的,並行請求可能超額

卡住的地方在於,一本帳要同時當「有人問過」和「事情發生過」的證據,而它只能把其中一件記準。Box 的選擇是兩種事件都記、讓規則作者自己挑,然後把挑錯的後果寫成測試。

官方的限速範例用的是 ::response:對 api.github.com 十分鐘內最多 60 次,用 forbid 加 when temporal 去數 http:request::response。文件註明被拒絕的請求不算、還在飛的請求暫時也不算。照我的讀法,作者在限速這裡接受了「並行可能超額」,換「被擋下來的請求不會吃掉額度」。

一條文件已經替你走過的死路

想寫「最多 N 次」的人,第一個直覺多半是寫一條帶時序條件的 permit。這條路文件已經標好是死的,而且通常死得很吵。

Box 的政策預設拒絕,規則的組合方式是 deny-overrides:forbid 蓋過 permit,沒有任何 permit 符合就拒絕。反過來說,只要沒被 forbid 擋,任何一條 permit 符合就放行。所以 permit 只會放寬,不會收緊。你在一條無條件的 permit 旁邊再寫一條帶上限的 permit,那個上限一點作用都沒有。Box 在載入時會抓這種寫法,直接拒絕啟動,錯誤訊息是:

1
policy writes a cap as a permit: rule @id("write_budget") (rule 2) carries a temporal clause beside rule @id("writes") (rule 1), which permits the same action with no condition; a permit cannot narrow another permit, so write the cap as a forbid

這是三種結果裡最吵的一種。使用手冊把三種都列了,結果由同一個動作的其他 permit 決定。旁邊那條 permit 有 when 條件時,box 照樣啟動,只印一行警告,說這個預算對另一條規則放行的請求全部無效。如果這條帶時序的 permit 是那個動作唯一的 permit,或其他 permit 也都是時序規則,就連警告都沒有,照常載入,條件成立就放行。

檢查只看另一條 permit 有沒有條件,不看兩個條件是否重疊。security.md 另外承認,Box 不會檢查一份政策裡的規則彼此是否一致。所以這條路不用自己再走一遍:上限、頻率、預算,一律寫成 forbid。

同一個形狀,出現在 Box 的每一層

::request 陷阱說穿了,是規則讀的那本帳,記的不是你以為的那件事。Box 的文件裡,這個形狀至少還出現三次。

第一個是直接授權。box.toml 裡 [agent.filesystem] 列出的路徑,agent 用自己的 syscall 直接讀,作業系統放行,政策引擎看不到,也沒有任何紀錄。文件的說法是直接授權「取代」政策決策,不是疊加在上面。你在 policy.dw 寫了再多讀檔規則,對這條路都是空的。

第二個是一條沒寫路徑的讀檔許可。Shell 和 Monty 能指名你 home 底下任何路徑,一條沒有路徑條件的 fs:read permit,就讀得到 ~/.ssh 和 ~/.aws。文件自己稱這是「the widest exposure in the product」,而且底下沒有一層專門保護憑證的 floor。

第三個是 harness 自己的權限提示。Box 刻意不依賴它,理由是那個檢查跑在 agent 自己的行程裡,而 Box 的第一條前提就是「Assume the agent is compromised」,所以那只能算 floor,不算 wall。另外它判的是工具呼叫,看到的是「執行這個指令」,看不到指令實際打開哪些檔、連到哪些主機。之前寫過的 Claude Code Sandbox 教學,就是在講 harness 這一層;Box 想做的是 harness 外面那一層。代價文件也寫了:harness 會繼續替它以為自己管的東西跳提示,想讓提示消失,同一個意圖要寫兩次。

三個例子都是同一件事:有一個地方看起來在守,但它讀到的資料,和你想守的那件事不是同一件。

我只讀了文件和原始碼

上面的內容來自 repo 的設計文件、使用手冊,以及 temporal_shell_e2e.rs 裡相關的幾支測試,對應的是 main 分支在 10 月 16 日(UTC)的 commit 069c39c。我沒有安裝 Box,沒有跑過任何一支測試,也沒有讀 Dogwood 引擎本身的原始碼;「引擎先 append 再判決」這件事,我的依據是 Box 的文件和測試註解。這些測試真的會照註解所說的通過,是推論,我沒驗證。

在 Box 裡寫前置條件,我的建議是一律用 ::response;::request 只拿來數次數,而且數的時候要記得被拒絕的嘗試也在花額度。這個結論綁在一支測試上:a_denied_attempt_satisfies_a_request_keyed_precondition。哪天它被翻轉,我猜多半是 Dogwood 改了 ingest 順序、request 事件開始帶判決,到時候前面那張 ::request 和 ::response 的對照表要重畫;要是它只是不見了,得先去翻 commit 看是修好了還是搬走了。

帶走的問題

任何會看歷史的授權規則,不管是 Box、你自己寫的 rate limiter,還是審計系統上的告警,下筆前都值得問兩件事。

第一,這本帳記的是「有人要求過」,還是「事情發生過」?前置條件要看後者,次數預算要想清楚自己要的是哪一個,以及另一個缺點你吞不吞得下。

第二,有哪些動作根本不會進這本帳?Box 的答案是 agent 自己的 syscall、直接授權的路徑、tool 在自己沙盒裡做的事。帳上查不到的,規則再嚴也守不到。