macOS:把安全策略拼成一個字串
Seatbelt 吃的是一段文字。文字由靜態基線加現拼的讀寫網段組成。路徑不進這段文字,走旁邊的參數列。
sandbox-exec 的策略正文只留佔位符,真實路徑走 -D。挖空一條路徑時,要同時擋住這個節點、它的子孫,以及把這個節點搬走。
- 權限檔案拆成讀寫網路manager.rs L375
- 固定使用 /usr/bin/sandbox-execseatbelt.rs L56
- 裝入四份靜態 sbplseatbelt.rs L21
- 按根生成 allow 與 require-notseatbelt.rs L476
- 可寫根釘上 file-write-unlinkseatbelt.rs L508
- 排除子路徑同時寫 literal 與 subpathseatbelt.rs L551
- 拼網路段,managed 無埠走受限seatbelt.rs L309
- sections join 成 -p 文字seatbelt.rs L1012
- 路徑寫成 -DKEY=valueseatbelt.rs L1024
- 雙短橫線後接使用者命令seatbelt.rs L1029
param 代號。真實目錄名落在 -D。帶引號的路徑汙染不了 SBPL 語法。subpath,mkdir .codex 會成功。兩條 require-not 加上最後的 unlink,才把節點、子孫和搬家一起釘住。同事把倉庫設成可寫根,讓模型在裡面改程式碼。模型執行 mkdir .codex,想在倉庫裡落一份自己的配置。命令立刻失敗,stderr 寫著 Operation not permitted。可寫根聽起來像整棵樹,實際不是。
再試一條更繞的路。模型先在倉庫裡寫一個普通檔案,再 mv 整個工作區,想把裡面的 .codex 一起挪到沙箱外面。這條也失敗。錯誤還是 Operation not permitted。
macOS 上 get_platform_sandbox 直接返回 MacosSeatbelt。那個布林開關只對 Windows 有意義。包裝器不是 Codex 自己的 exe,是系統裡固定路徑的 /usr/bin/sandbox-exec。PATH 上放一個同名二進位沒用。
出處:codex-rs/sandboxing/src/manager.rs 第 62 至 76 行 · codex-rs/sandboxing/src/seatbelt.rs 第 52 至 56 行
策略文字分兩層。四份 .sbpl 用 include_str! 編進二進位,當靜態基線。動態段按這一次的可讀可寫根現拼。拼接順序固定:基線、讀、寫、網路在前。全盤可讀才加 preferences。受限讀才加平臺預設路徑。祖先的 file-write-unlink 放最後,避免前面更寬的 allow 把 rename 用的 unlink 重新開啟。
出處:codex-rs/sandboxing/src/seatbelt.rs 第 21 至 27 行 · codex-rs/sandboxing/src/seatbelt.rs 第 985 至 1012 行
基線第一句業務規則是 (deny default)。沒寫明的事一律拒絕。子行程繼承這份底稿,sandbox-exec 拉起的 bash 再 fork 出來的子孫,仍在同一套規則裡。
Seatbelt 的核心介面就是吃一份 SBPL 文字。能控的是文字怎麼生成。預設拒絕,再按這一次的根往上開門,換語言重寫也用得上。片段的順序是合同:更窄的 deny 必須能壓住前面更寬的 allow。
有人把工程放在 ~/work/app (copy) 這種帶括號和空格的目錄裡,或者路徑裡出現引號。SBPL 裡括號、引號、分號都是語法。路徑一旦插進策略正文,使用者目錄名就能變成語法。sandbox-exec 解析失敗,模型看到的是包裝器錯誤,不是沙箱拒絕。
動態讀寫規則只往正文裡寫 (param "WRITABLE_ROOT_0") 這類鍵名。真實路徑寫成 -DKEY=value,成為獨立 argv。排除子路徑同樣走參數列。策略正文和 -D 必須同時出現,測試把這件事鎖成合同。
出處:codex-rs/sandboxing/src/seatbelt.rs 第 1022 至 1028 行
安全策略如果最終是一段文字,使用者可控的字串不要進這段文字。模板只含佔位符,真實路徑、域名、埠走另一份參數列。和 Seatbelt 語法無關。Python 裡同樣是模板加 argv。
只寫 subpath,第一次建立這個目錄會漏,mkdir .codex 會成功。中繼資料保護變成空殼。不加 file-write-unlink,rename 能把只讀子樹挪出挖空區。下一次若仍按原路徑授權,實際寫的是連結對面。
排除子路徑時,require-not 要寫兩條。literal 管目錄自己,subpath 管下面的內容。兩條一起放進 require-all,是並且關係。順序不敏感,少寫 literal 才是。
出處:codex-rs/sandboxing/src/seatbelt.rs 第 548 至 555 行
可寫根預設保護三個頂層名字:.git、.agents、.codex。可寫根目錄本身再釘一條 deny file-write-unlink。可寫根中間若有使用者可控的符號連結,拼裝直接報錯,不會跟著連結走。
出處:codex-rs/protocol/src/permissions.rs 第 24 至 33 行 · codex-rs/sandboxing/src/seatbelt.rs 第 504 至 509 行 · codex-rs/sandboxing/src/seatbelt.rs 第 441 至 450 行
網路是另一段動態文字。要走代理卻沒有可用埠,返回受限網路段加網路基線,不給 (allow network-outbound) 這種整網放行。網路基線還在,只是沒有 blanket outbound。
出處:codex-rs/sandboxing/src/seatbelt.rs 第 309 至 335 行
挖空一條路徑,同時擋住節點本身和它的子孫。再擋住對這個節點的 rename。三條一起生成,用測試鎖住形狀。跨平臺錯誤文案不同:macOS 常見 Operation not permitted,能被現有關鍵詞表接住。不要假設另外兩個平臺也是這句話。
出處:codex-rs/sandboxing/src/denial.rs 第 50 至 58 行
Claude Code:先放開讀,路徑 stringify 後進文字
Claude 從 (allow file-read*) 往下加 deny,再在 deny 裡 re-allow。後寫規則優先。路徑用 JSON.stringify 包一層,嵌進策略文字。包裝命令從 PATH 裡找 sandbox-exec,不寫死 /usr/bin。兩邊都要防 rename,Claude 同樣生成 deny file-write-unlink。
Codex 從 (deny default) 往上加 allow,路徑留在 -D。付的是生成器複雜度,換來使用者路徑不進入 SBPL 語法。Claude 付的是後寫順序必須按對,換來配置面更接近 allowAllExcept。
DSH 與 Grok:路徑進文字,靠轉義或直接失敗
DSH 預設 (allow default),再 (deny file-write*),只把寫權收回來。路徑經 sbplString 轉義反斜槓和雙引號後,直接嵌進 -p。沒有 -D 參數列。sandbox-exec 從 PATH 取。本機 macOS 上做不到「可寫根裡挖掉 .codex」這一粒度。
Grok 不包一層 sandbox-exec。它把 SBPL 片段交給當前行程。路徑照樣進文字,控制字元直接失敗,避免沙箱報 active、那條路徑卻沒被擋住。核心 apply 失敗時繼續跑。Codex 在 Seatbelt 準備失敗時返回錯誤,命令不會裸跑。
少一條 require-not,漏在哪裡
演示裡先開啟寫根和挖空 .codex,看 mkdir .codex 探針是紅的。再開啟「只留 subpath」,探針變綠。用自己的話解釋:subpath 管的是目錄下面,為什麼擋不住第一次建立這個節點。對照 seatbelt.rs 第 548 至 555 行的註釋即可,不要在真實倉庫裡執行破壞性命令。
-D,正文只留佔位。挖空要同時擋住節點、子孫和搬家。Seatbelt 擋得住策略裡拒絕的操作,擋不住被寫寬的 allow,也擋不住模型在可寫根裡改業務程式碼。