← 返回所有文章
發佈於 · 作者 Renaud Deraison

Bromure 裡的 OpenShell 政策,以及每一條連線背後的推理

NVIDIA 的 OpenShell 用一份政策檔描述編碼代理可以碰什麼。Bromure Agentic Coding 5.4 在代理的 VM 裡執行這些政策:VM 內部用 Landlock 和 seccomp,網路規則則在你的 Mac 上,同時以一個哨兵模組監看客體的核心。接著,它把每一條網路連線連回開啟它的處理程序、啟動那些處理程序的工具呼叫,以及代理在發出那次呼叫時給出的推理。

編碼代理需要真正的存取權才派得上用場,而政策說明要給多少。它最好是一份你能讀、能檢查、也能執行的單一文件,而不是分散在防火牆、掛載選項和代理自身設定裡的各種設定。

NVIDIA 的 OpenShell

OpenShell 是 NVIDIA 以 Apache 2.0 授權釋出的開源代理執行環境。我們以它的政策格式為基礎,一份 YAML 文件涵蓋四個領域:

  • filesystem_policy 和 landlock:代理可以讀寫哪些路徑,由 Linux 核心的 Landlock 安全模組強制執行。
  • process:代理以哪個使用者和群組身分執行。
  • network_policies:每個程式可以連到哪些目的地,以及可以對那裡送出哪些請求,細到 HTTP 方法和路徑。沒有被允許的一律拒絕:「除非 network_policies 裡有規則允許,否則 OpenShell 會拒絕沙箱發出的每一條對外連線。」

OpenShell 也附帶一個證明器,用 SMT 求解器檢查一份政策是否允許了另一份政策範圍以外的任何東西。組織可以設下一道邊界,每次變更都得到一個是或否的答案,而不必逐一閱讀差異。

這是一個嚴謹的設計,出自顯然仔細研究過代理會怎麼出錯的人之手,而且是開放的,所以我們就從它出發。

Bromure 裡的 OpenShell

在 Bromure Agentic Coding 5.4 中,工作區可以用 OpenShell 政策取代 Bromure 自己的防火牆規則。我們採用的是政策格式和它的語意,不是程式碼:引擎是我們自己的,在 Mac 上,再加上 Linux 客體裡我們自己的元件。為了確認兩者一致,我們把自己的引擎和 OpenShell 的參考引擎放在 125,666 個生成的案例上跑,涵蓋網路規則、HTTP 規則、MCP、GraphQL、WebSocket 和目的地位址檢查。兩者沒有任何一個案例不一致。這些測試找出了像 ..%3b 和雙重編碼斜線(%252F)這類路徑把戲,我們現在的處理方式和 OpenShell 相同。

Bromure 和 OpenShell 的差別,在於強制執行發生在哪裡。OpenShell 透過一個閘道和一個容器執行環境啟動沙箱。Bromure 則把每個工作區放在你 Mac 上各自一台可拋棄的 Linux VM 裡,所以政策分成兩邊執行:

  • 在 VM 裡:檔案系統用 Landlock,再加上 seccomp、no_new_privs,以及切換到政策指定的使用者。下一節會說明做法。
  • 在 Mac 上:網路規則。每一條連線都經由 Bromure 的虛擬交換器離開 VM,由它檢查目的地和連接埠;再經過它的 TLS 代理伺服器,由它檢查 HTTP 方法和路徑,以及 MCP、GraphQL 和 WebSocket 訊息。位址由 Mac 解析並釘選,所以沒辦法利用 DNS 繞過 allowed_ips 規則。VM 裡的 root 改不了一條位於 VM 之外的規則。

政策也可以指名允許建立連線的程式。開啟嚴格模式時,Bromure 會以雜湊值檢查是哪個二進位檔開啟了連線:curl 可以連到 api.github.com,Python 腳本則不行。再搭配一條 HTTP 規則,例如對 api.github.com/repos/** 只允許 GET,一個 DELETE 就會在代理伺服器被拒絕。這些 Landlock 都做不到:它只能限制檔案。

當組織從 bromure.io 設下邊界時,如果有安裝 NVIDIA 的 openshell-prover,Bromure 就用它檢查每一份工作區政策是否在邊界之內;沒有安裝時,則用一個更嚴格的內建檢查。超出邊界的政策無法儲存。

我們刻意做了一個改動:每個區段只有在存在時才會套用。沒有 filesystem_policy 的政策不會啟用 Landlock,沒有 process 的政策也不會切換使用者。OpenShell 會填入預設值,因為它的驅動程式總是送出一份完整的文件。在 Bromure 裡,採用 OpenShell 永遠不會悄悄弄壞一個工作區。這樣就有了三個層級:

政策包含在 VM 裡套用的它保證的
只有網路規則什麼都沒有;代理保有 sudo網路政策,以及核心哨兵的觀察
filesystem_policyLandlock,在仍是 root 時套用連 root 也逃不出的檔案系統限制
process 區段,或嚴格模式Landlock、seccomp、no_new_privs、非 root 使用者;撤銷 sudo 和 docker以上全部,沒有通往 root 的路,也沒辦法停掉 Bromure 自己的處理程序

有幾個欄位會被接受但不會執行。主要是憑證改寫和簽章欄位:Bromure 本來就把真正的憑證留在 Mac 上,在流量出去的路上才換進去,所以代理手上拿的永遠只是替身。

VM 內部:Landlock、seccomp 和權限降級

客體這一半是 OpenShell sandbox-linux crate 中強制執行程式碼的重新實作。它以 Python 搭配 ctypes 撰寫,因為它必須在開機時於 VM 內執行,而那裡沒有 Rust 工具鏈。它發出的系統呼叫和 Rust 的 landlock 與 seccompiler crate 完全相同,另有一套差異比對測試工具會建置 OpenShell 真正的 crate,逐一路徑、逐一系統呼叫地比較兩者。

一個 root 監督程式 bromure-sandboxd 會啟動每一個代理工作階段。它把工作階段移進專屬的 cgroup,也就是沙箱 cgroup,然後分三步限制它。

Landlock。Landlock 讓一個沒有特權的處理程序把自己,以及它啟動的每一個處理程序,限制在一份路徑清單內。監督程式會:

  1. 以規則集所管轄的權限呼叫 landlock_create_ruleset。在客體的 6.8 核心(Landlock ABI 4)上,這些權限包括執行、讀取和寫入檔案、列出目錄、建立和移除各種類型的檔案、跨目錄重新命名(REFER),以及截斷;
  2. 以 O_PATH 開啟 read_only 和 read_write 裡的每一條路徑,並用 landlock_add_rule 加入,作為帶有讀取權限或全部權限的 PATH_BENEATH 規則;
  3. 呼叫 landlock_restrict_self。

從此以後,任何受規則集管轄、卻沒有規則授予的存取,都會以 EACCES 失敗,不管是誰提出。Landlock 檢查的是任務,而不是使用者,所以 sudo 也無法放寬它。處理程序可以疊加更多規則集,卻永遠無法移除任何一個,這就是為什麼檔案系統的變更需要重新啟動 VM,而網路規則可以即時套用。landlock.compatibility 決定在不支援該 ABI 的核心上會怎麼做:best_effort 以核心提供的功能繼續執行,hard_requirement 則拒絕啟動工作階段。

seccomp。在嚴格模式下,或政策有 process 區段時,監督程式會設定 no_new_privs,並安裝 OpenShell 的三個 seccomp 過濾器:

  • 主過濾器預設允許,並封鎖能繞過沙箱的東西。其中包括 ptrace、bpf、io_uring_setup、userfaultfd、perf_event_open、process_vm_readv 和 process_vm_writev、整個 mount 系列,以及 setns。它封鎖 memfd_create 和帶有 AT_EMPTY_PATH 的 execveat,因為從匿名檔案執行程式碼可以繞過 Landlock。它封鎖帶有 CLONE_NEWUSER 的 unshare 和 clone,因為新的使用者命名空間會發放 capabilities。它也封鎖 packet、藍牙和 vsock 家族的 socket,以及路由以外的 netlink。
  • 相容性過濾器以 ENOSYS 回應 clone3。seccomp 過濾器能讀取暫存器的值,卻無法追蹤指標,而 clone3 是透過指標傳遞旗標。ENOSYS 讓 glibc 退回使用 clone,主過濾器就能檢查它的旗標。
  • 強化過濾器阻止工作階段碰觸它的監督程式,因為監督程式以同一個使用者身分執行:不能用 ptrace、process_vm_*、pidfd_getfd 或 kcmp,也不能對監督程式的處理程序或處理程序群組發送訊號。

我們和 OpenShell 有兩處不同,而且只在於回報什麼,以及訊號瞄準的對象。我們的過濾器帶有 SECCOMP_FILTER_FLAG_LOG,所以被拒絕的系統呼叫會傳到核心的稽核掛鉤,讓哨兵看得到。訊號規則則被縮小範圍,讓互動式終端機裡的 shell 工作控制(kill %1)仍然能用。

權限降級。run_as_user 把工作階段切換到政策指定的使用者,不帶預設使用者的任何群組,所以沒有 sudo,也沒有 docker。使用者的家目錄透過 idmapped 掛載掛進工作階段,所以工作階段建立的檔案在磁碟上仍然屬於預設使用者,代理的設定也照樣適用。

客體核心裡的哨兵

為了記錄代理實際做了什麼,Bromure 會把自己的核心模組載入 VM,也就是哨兵。它的設計假設代理能在 VM 裡取得 root,所以凡是 root 能阻止的東西都不算證據:擷取、緩衝和傳輸全都在核心裡執行。

擷取。哨兵從核心空間註冊 kprobes:

  • 程式啟動,在 security_bprm_committed_creds,附帶命令列和處理程序的啟動時間;
  • 憑證變更:setuid 和 setgid 系列、capset 和 commit_creds;
  • ptrace、模組載入和卸載、bpf、kexec_load、mount、unshare 和 setns;
  • 新的網路流量:TCP 在送出第一個 SYN 時(tcp_connect),UDP 在第一次送往某個目的地時,以及 ICMP 和 raw socket,每一個都附帶背後最多八個處理程序的鏈;
  • 被拒絕的系統呼叫,在 audit_seccomp;
  • 被拒絕的檔案操作,透過核心檔案安全掛鉤上的 kretprobes:security_file_open,以及 mkdir、rmdir、unlink、symlink、link、rename 和 truncate 的 security_path_* 掛鉤。當某個掛鉤對沙箱 cgroup 裡的處理程序傳回 EACCES 或 EPERM,那就是一次拒絕。執行 Landlock 不允許的檔案,會以一次帶有 FMODE_EXEC 的被拒絕開啟呈現。

監督程式透過一個只能設定、永遠無法更改的模組參數,把沙箱 cgroup 的 ID 交給哨兵一次。root 無法把它指向別處,讓代理的活動被標成 Bromure 自己的活動。

傳輸。一個核心執行緒用 sock_create_kern 開啟一個連到 Mac 的 AF_VSOCK socket。這個 socket 沒有檔案描述元,所以不會出現在 /proc 裡任何處理程序底下,使用者空間裡也沒有任何東西能關閉它。事件經過一個 256 格的環形緩衝區,每一種事件都限速為每秒 64 個,突發時最多 256 個。每個訊框是一個 4 位元組的長度,後面接著 JSON。

保持載入。這個模組沒有 exit 函式,所以核心拒絕卸載它,而且它還額外持有一個對自己的參照。載入一完成,載入程式就把核心的 lockdown 提高到 integrity,若工作區要求哨兵,則提高到 confidentiality。從此以後,root 無法載入未簽章的模組、無法透過 /dev/mem 寫入核心記憶體,也無法用 kexec 切換到沒有哨兵的核心。

證明它還活著。哨兵每秒送出一次心跳,附帶序號、被丟棄和被限速的事件數,以及有多少探針處於啟用狀態。root 透過 debugfs 一次關掉所有 kprobe 時,啟用數並不會露出破綻:那樣做不會動到每個探針自己的旗標,所以數字看起來仍然正常。為了抓到這種情況,哨兵在自己的一個空函式上掛了一個 kprobe,並在每次心跳時呼叫它一次。如果命中計數沒有變動,就表示探針沒有在觸發,不管原因為何,心跳都會如實回報。

證明它就是哨兵。每次載入都會產生一個 32 位元組的祕密。第一次 hello 會帶上它,Mac 則在該次開機期間把它釘選下來。重新連線時絕不重複送出祕密,而是送出 sha256(secret, boot_id, connection number),所以就算某個處理程序在第一次 hello 之後從記憶體讀出了祕密,仍然無法冒充這個模組。出現第二個哨兵、祕密不對、連線編號被重複使用、序號出現缺口,或 15 秒的沉默,都算竄改。root 剩下的手段是讓 VM 當機或關機,而 Mac 會把這看成沉默。

發布。模組不在 VM 裡建置。CI 為 20 個 Ubuntu 24.04 核心版本各建一個模組,每個都建兩次,兩次建置必須逐位元組相符。每個模組以及列出它們的目錄都以 ed25519 金鑰簽章,簽章或雜湊值對不上的模組,app 一律拒絕。

建置過程中學到的一課:有一段時間,檔案拒絕探針拿一個無號的傳回值去和 -EACCES 比較,結果一次也沒有觸發。測試只檢查探針有沒有註冊,而它總是有註冊。現在測試會檢查一次拒絕是否真的產生事件。

判斷事件。在 Mac 上,事件依它代表的意義判斷,而不是依觸發了哪個系統呼叫。每一個事件都會為分數加上權重,60 秒內累積到 20 就會觸發警報:

事件權重
竄改哨兵,或在嚴格沙箱內部取得 root20
未簽章的模組、BPF 載入、kexec、試圖降低 lockdown;同一個程式在一分鐘內被拒絕 20 次10
ptrace,或被 seccomp 拒絕的系統呼叫3
掛載或命名空間變更;被丟棄的事件2
被沙箱拒絕的檔案操作1
在允許 sudo 的工作區裡執行 sudo0,顯示在時間軸上

第一分鐘就響的警報,會讓人學會忽略真正的警報。所以 Bromure 自己的例行工作在沙箱外執行,永遠不會以拒絕的形式出現;Bromure 在開機時組裝 VM 所產生的事件,在一段固定的開機時間窗內只會被計數,不會觸發警報。警報觸發時,Bromure 可以隔離工作區:切斷 VM 的網路並將它暫停。

溯源鏈:每一條連線背後的推理

網路記錄會顯示 VM 裡有個東西連到了某台主機,卻不會顯示為什麼。當做事的是代理時,你需要知道的正是那個為什麼。

Bromure 能告訴你,因為它看得到兩端。它的 AI 代理伺服器位在代理和模型供應商之間,會在代理採取行動前讀取每一個回應,所以它知道每一次工具呼叫、它要執行的命令,以及模型在那之前寫了什麼。哨兵則回報 VM 啟動的每一個程式和它的命令列,以及每一條新連線和它背後的處理程序鏈。Bromure 把兩者接起來。

假設你請代理加一個會重試付款 API 的健康檢查。它判斷自己需要一個 HTTP 客戶端,於是執行 npm install axios。npm 從 registry.npmjs.org 抓取套件,這是政策允許的。接著,下載內容裡的一個 postinstall 腳本啟動,試圖連到 telemetry.example.net,沒有任何規則允許這個連線。連線在 Mac 上的代理伺服器被拒絕,而溯源鏈顯示了它是怎麼發生的:

Bromure 對一條通往 telemetry.example.net 的被封鎖連線所顯示的溯源鏈:提示、代理的推理和自述的意圖、標示為完全相符的 Bash 工具呼叫 npm install axios、從 claude 經過 bash、npm 和 sh 到執行 scripts/postinstall.js 的 node 共五個處理程序、TCP 流量,以及「被請求政策封鎖」的決定,下方列出通往 registry.npmjs.org 的允許流量
一條被封鎖連線的溯源鏈:請求、推理、工具呼叫、一路往下到 postinstall 腳本的五個處理程序、流量和決定。從 registry.npmjs.org 的允許下載列在同一個工具呼叫底下。

代理從來沒有打過任何連到 telemetry.example.net 的命令。單純的網路記錄只會顯示一個 Node 處理程序在跟一台陌生主機說話;溯源鏈則顯示那是某個相依套件的安裝腳本,位在代理出於明確理由而執行的 npm install 底下。

連結分三步完成:

  1. 工具呼叫。AI 代理伺服器會把模型回應中的每一次工具呼叫保留十分鐘:它的 ID(tool_use_id,或 OpenAI 的 call_id)、命令,以及模型在同一輪中、在它之前寫下的最多 2,000 個字元。
  2. Shell。Mac 根據哨兵回報的程式啟動,為每台 VM 維護一張處理程序表,以 pid 和啟動時間為鍵,所以被重複使用的 pid 不會繼承到別人的歷史。在回應後 30 秒內、於代理底下啟動的 shell,如果在還原代理自己的引號處理後,命令出現在它的命令列裡,就會連結到那次工具呼叫。例如 Claude Code 會以 bash -c '… eval '"'"'<command>'"'"' …' 的形式執行命令。這就是完全相符。如果代理改寫了命令,Bromure 會退而依第一個字、時間和親子關係判斷,並把這個比對標示為模糊相符。
  3. 決定。流量會依協定、目的地、連接埠和來源連接埠,與交換器在 5 秒內做出的允許或拒絕配對;當代理伺服器檢查過請求時,也會與它的決定配對。

那個 shell 底下的每一條連線都會繼承這個連結。這包括經由 Bromure 代理伺服器橋接的流量,否則它們全都會看起來像是連到 localhost 的連線。橋接器在每一條通往 Mac 的串流開頭,都會先送出一行指明客戶端來源連接埠的資訊,讓 Mac 能把本機連線對應到真正的目的地。

在安全性視窗裡,對某一列網路紀錄按 Show the Whole Chain,就會開啟這個檢視。

有些模型會直接發出工具呼叫,事先什麼都不寫。這時檢視會改為顯示提示和該次呼叫自述的意圖。背景服務,以及在工具呼叫之外啟動的任何東西,都會被標示為無法追溯到代理。

溯源鏈也改變了封鎖之後會發生的事。只看到連線被重設或權限錯誤的代理,會把它當成故障,改試別的主機、連接埠或工具,從外面看起來就像代理失控了。Bromure 會在代理自己的工作階段裡告訴它:政策拒絕了這個請求、原因是什麼,以及應該來問你,而不是想辦法繞過去。執行中工作區的 Sandbox 抽屜會列出被封鎖的項目,每一項都有一個允許按鈕,而政策顧問會根據這些拒絕替你草擬規則,由你決定要不要核准。

一個使用 OpenShell + Sentry 的執行中工作區的 Sandbox 抽屜,列出四個被封鎖的目的地、各自被嘗試的次數和最近一次的時間、每一項的「允許」按鈕,以及網路變更即時生效的說明
Sandbox 抽屜列出被封鎖的目的地。網路變更即時生效。

代理的推理一律顯示在你自己的安全性時間軸上。只有在組織開啟這項功能時,它才會被送到該組織在 bromure.io 上的主控台,而這項功能預設是關閉的,私人設定檔則永遠不會送出。命令列上的權杖在任何東西離開 Mac 之前就會被遮蔽。

一段在示範工作區上錄製的 24 秒循環影片。安全性時間軸記錄了哨兵和 Landlock 拒絕的事:一個伸手去拿 SSH 和 AWS 金鑰的 Node 處理程序,以及一次 ptrace。接著,被封鎖的連線展開成它的溯源鏈,從封包一路讀回代理的推理。

開啟方式

OpenShell 政策、核心哨兵和溯源鏈隨 Bromure Agentic Coding 5.4 推出。它們預設是關閉的。要為某個工作區開啟,請打開設定,進入該工作區的沙盒原則,把 Sandbox engine 設為 OpenShell + Sentry。你可以直接在那裡撰寫政策,或匯入一份現有的 OpenShell YAML 檔。

Bromure 工作區設定的「沙盒原則」窗格,沙箱引擎設為 OpenShell + Sentry,OpenShell 政策允許 github.com、對 registry.npmjs.org 的唯讀存取,以及對 pypi.org 的唯讀存取
一個使用 OpenShell + Sentry 的工作區。入門政策允許 GitHub,以及對 npm 和 PyPI 的唯讀存取:安裝可以,發布則被封鎖。
  • 網路規則即時生效。檔案系統的變更要到下次 VM 啟動時才生效,因為 Landlock 對執行中的處理程序只能收緊、不能放寬。
  • 哨兵預設以盡力而為模式執行。把它設為 Required,Bromure 就會把核心的 lockdown 提高到最嚴格的等級,並把始終沒有連上的哨兵視為竄改。
  • 在要求哨兵的情況下,如果該核心的模組已有快取,工作區的 shell 在 4 秒內就緒;如果 Bromure 必須去抓取模組,則是 6 秒。

想看它實際運作,請看 Claudia 的三集影片:她撰寫一份政策,在代理工作時調整它,並把一場凌晨 2 點的供應鏈攻擊一路追溯回代理的推理。

感謝 NVIDIA 的 OpenShell 團隊公開釋出政策格式、參考引擎和證明器。我們的成果就建立在這三者之上。