seatbelt: honor split filesystem sandbox policies (#13448)

## Why

After `#13440` and `#13445`, macOS Seatbelt policy generation was still
deriving filesystem and network behavior from the legacy `SandboxPolicy`
projection.

That projection loses explicit unreadable carveouts and conflates split
network decisions, so the generated Seatbelt policy could still be wider
than the split policy that Codex had already computed.

## What changed

- added Seatbelt entrypoints that accept `FileSystemSandboxPolicy` and
`NetworkSandboxPolicy` directly
- built read and write policy stanzas from access roots plus excluded
subpaths so explicit unreadable carveouts survive into the generated
Seatbelt policy
- switched network policy generation to consult `NetworkSandboxPolicy`
directly
- failed closed when managed-network or proxy-constrained sessions do
not yield usable loopback proxy endpoints
- updated the macOS callers and test helpers that now need to carry the
split policies explicitly

## Verification

- added regression coverage in `core/src/seatbelt.rs` for unreadable
carveouts under both full-disk and scoped-readable policies
- verified the current PR state with `just clippy`




---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/13448).
* #13453
* #13452
* #13451
* #13449
* __->__ #13448
* #13445
* #13440
* #13439

---------

Co-authored-by: viyatb-oai <viyatb@openai.com>
This commit is contained in:
Michael Bolin
2026-03-07 16:35:19 -08:00
committed by GitHub
parent e8d7ede83c
commit bf5c2f48a5
4 changed files with 374 additions and 151 deletions

View File

@@ -208,14 +208,17 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res
});
let test = builder.build(&server).await?;
let requested_write = test.workspace_path("requested-but-unused.txt");
let requested_dir = test.workspace_path("requested-dir");
fs::create_dir_all(&requested_dir)?;
let requested_dir_canonical = requested_dir.canonicalize()?;
let requested_write = requested_dir.join("requested-but-unused.txt");
let _ = fs::remove_file(&requested_write);
let call_id = "request_permissions_skip_approval";
let command = "touch requested-but-unused.txt";
let command = "touch requested-dir/requested-but-unused.txt";
let requested_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: Some(vec![]),
write: Some(vec![absolute_path(&requested_write)]),
write: Some(vec![absolute_path(&requested_dir_canonical)]),
}),
..Default::default()
};
@@ -292,6 +295,7 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul
let nested_dir = test.workspace_path("nested");
fs::create_dir_all(&nested_dir)?;
let nested_dir_canonical = nested_dir.canonicalize()?;
let requested_write = nested_dir.join("relative-write.txt");
let _ = fs::remove_file(&requested_write);
@@ -300,7 +304,7 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul
let expected_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
read: None,
write: Some(vec![absolute_path(&requested_write)]),
write: Some(vec![absolute_path(&nested_dir_canonical)]),
}),
..Default::default()
};
@@ -310,7 +314,7 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul
Some("nested"),
json!({
"file_system": {
"write": ["./relative-write.txt"],
"write": ["."],
},
}),
)?;
@@ -366,7 +370,8 @@ async fn relative_additional_permissions_resolve_against_tool_workdir() -> Resul
#[tokio::test(flavor = "current_thread")]
#[cfg(target_os = "macos")]
async fn read_only_with_additional_permissions_widens_to_unrequested_cwd_write() -> Result<()> {
async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd_write()
-> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
@@ -440,16 +445,18 @@ async fn read_only_with_additional_permissions_widens_to_unrequested_cwd_write()
let result = parse_result(&results.single_request().function_call_output(call_id));
assert!(
result.exit_code.is_none() || result.exit_code == Some(0),
"unexpected exit code/output: {:?} {}",
result.exit_code != Some(0),
"unrequested cwd write should stay denied: {:?} {}",
result.exit_code,
result.stdout
);
assert!(result.stdout.contains("cwd-widened"));
assert_eq!(fs::read_to_string(&unrequested_write)?, "cwd-widened");
assert!(
!requested_write.exists(),
"only the unrequested cwd path should have been written"
"requested path should remain untouched when the command targets an unrequested file"
);
assert!(
!unrequested_write.exists(),
"unrequested cwd write should remain blocked"
);
let _ = fs::remove_file(unrequested_write);
@@ -459,7 +466,8 @@ async fn read_only_with_additional_permissions_widens_to_unrequested_cwd_write()
#[tokio::test(flavor = "current_thread")]
#[cfg(target_os = "macos")]
async fn read_only_with_additional_permissions_widens_to_unrequested_tmp_write() -> Result<()> {
async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp_write()
-> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
@@ -534,16 +542,18 @@ async fn read_only_with_additional_permissions_widens_to_unrequested_tmp_write()
let result = parse_result(&results.single_request().function_call_output(call_id));
assert!(
result.exit_code.is_none() || result.exit_code == Some(0),
"unexpected exit code/output: {:?} {}",
result.exit_code != Some(0),
"unrequested tmp write should stay denied: {:?} {}",
result.exit_code,
result.stdout
);
assert!(result.stdout.contains("tmp-widened"));
assert_eq!(fs::read_to_string(&tmp_write)?, "tmp-widened");
assert!(
!requested_write.exists(),
"only the unrequested tmp path should have been written"
"requested path should remain untouched when the command targets an unrequested file"
);
assert!(
!tmp_write.exists(),
"unrequested tmp write should remain blocked"
);
let _ = fs::remove_file(tmp_write);