mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
Add macOS Seatbelt filesystem integration tests (#39665)
## What changed Add macOS-only integration coverage that runs commands under the production Seatbelt policy and verifies: - deny globs protect matching files, directory ancestors, symlink aliases, and paths created after policy application; - protected directories cannot be moved, replaced, or exchanged across writable roots; and - standard read-only and workspace-write profiles continue to allow expected filesystem operations. GitOrigin-RevId: 1f666835ee402bedd1d32e87f1082eaaf7b7bf6a
This commit is contained in:
@@ -14,4 +14,6 @@ mod output_schema;
|
||||
mod prompt_stdin;
|
||||
mod resume;
|
||||
mod sandbox;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod seatbelt;
|
||||
mod server_error_exit;
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::process::ExitStatus;
|
||||
use tokio::fs::create_dir_all;
|
||||
use tokio::process::Child;
|
||||
|
||||
async fn spawn_command_under_sandbox(
|
||||
pub(super) async fn spawn_command_under_sandbox(
|
||||
command: Vec<String>,
|
||||
command_cwd: AbsolutePathBuf,
|
||||
permission_profile: &PermissionProfile,
|
||||
|
||||
776
codex-rs/exec/tests/suite/seatbelt.rs
Normal file
776
codex-rs/exec/tests/suite/seatbelt.rs
Normal file
@@ -0,0 +1,776 @@
|
||||
use super::sandbox::spawn_command_under_sandbox;
|
||||
use codex_core::spawn::StdioPolicy;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::symlink;
|
||||
use std::process::Output;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn workspace_fixture() -> (TempDir, AbsolutePathBuf) {
|
||||
let temp_dir = TempDir::new().expect("create temporary directory");
|
||||
let workspace = temp_dir.path().join("workspace");
|
||||
fs::create_dir(&workspace).expect("create workspace");
|
||||
let workspace = AbsolutePathBuf::from_absolute_path(
|
||||
workspace.canonicalize().expect("canonicalize workspace"),
|
||||
)
|
||||
.expect("workspace is absolute");
|
||||
(temp_dir, workspace)
|
||||
}
|
||||
|
||||
fn permission_profile(
|
||||
workspace: &AbsolutePathBuf,
|
||||
restricted_paths: Vec<(FileSystemPath, FileSystemAccessMode)>,
|
||||
) -> PermissionProfile {
|
||||
let mut file_system_policy = FileSystemSandboxPolicy::read_only();
|
||||
file_system_policy.entries.push(FileSystemSandboxEntry::new(
|
||||
workspace.clone().into(),
|
||||
FileSystemAccessMode::Write,
|
||||
));
|
||||
file_system_policy.entries.extend(
|
||||
restricted_paths
|
||||
.into_iter()
|
||||
.map(|(path, access)| FileSystemSandboxEntry::new(path, access)),
|
||||
);
|
||||
|
||||
PermissionProfile::from_runtime_permissions(
|
||||
&file_system_policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
)
|
||||
}
|
||||
|
||||
fn denied_glob_profile(workspace: &AbsolutePathBuf, pattern: String) -> PermissionProfile {
|
||||
permission_profile(
|
||||
workspace,
|
||||
vec![(
|
||||
FileSystemPath::GlobPattern { pattern },
|
||||
FileSystemAccessMode::Deny,
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
async fn run_sandboxed_command(
|
||||
command: Vec<String>,
|
||||
workspace: &AbsolutePathBuf,
|
||||
permission_profile: &PermissionProfile,
|
||||
) -> Output {
|
||||
spawn_command_under_sandbox(
|
||||
command,
|
||||
workspace.clone(),
|
||||
permission_profile,
|
||||
workspace,
|
||||
StdioPolicy::RedirectForShellTool,
|
||||
HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.expect("spawn command under production Seatbelt policy")
|
||||
.wait_with_output()
|
||||
.await
|
||||
.expect("wait for sandboxed command")
|
||||
}
|
||||
|
||||
async fn run_path_command(
|
||||
executable: &str,
|
||||
path: &AbsolutePathBuf,
|
||||
workspace: &AbsolutePathBuf,
|
||||
profile: &PermissionProfile,
|
||||
) -> Output {
|
||||
run_sandboxed_command(
|
||||
vec![executable.to_string(), path.display().to_string()],
|
||||
workspace,
|
||||
profile,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn assert_denied_read(
|
||||
path: &AbsolutePathBuf,
|
||||
workspace: &AbsolutePathBuf,
|
||||
profile: &PermissionProfile,
|
||||
) {
|
||||
let output = run_path_command("/bin/cat", path, workspace, profile).await;
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Seatbelt allowed reading {}: {output:?}",
|
||||
path.display()
|
||||
);
|
||||
assert!(output.stdout.is_empty(), "protected contents leaked");
|
||||
}
|
||||
|
||||
fn assert_file_contents(path: &AbsolutePathBuf, contents: &str) {
|
||||
assert_eq!(fs::read_to_string(path).expect("read fixture"), contents);
|
||||
}
|
||||
|
||||
async fn run_shell_command(
|
||||
script: &str,
|
||||
path: &AbsolutePathBuf,
|
||||
workspace: &AbsolutePathBuf,
|
||||
profile: &PermissionProfile,
|
||||
) -> Output {
|
||||
run_sandboxed_command(
|
||||
vec![
|
||||
"/bin/sh".to_string(),
|
||||
"-c".to_string(),
|
||||
script.to_string(),
|
||||
"sh".to_string(),
|
||||
path.display().to_string(),
|
||||
],
|
||||
workspace,
|
||||
profile,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_after_policy_applied(
|
||||
operation: &str,
|
||||
target: &AbsolutePathBuf,
|
||||
extra_args: Vec<String>,
|
||||
workspace: &AbsolutePathBuf,
|
||||
profile: &PermissionProfile,
|
||||
setup: impl FnOnce(),
|
||||
) -> Output {
|
||||
let label = target
|
||||
.as_path()
|
||||
.file_name()
|
||||
.expect("filename")
|
||||
.to_string_lossy();
|
||||
let ready = workspace.join(format!("{label}.ready.txt"));
|
||||
let proceed = workspace.join(format!("{label}.proceed.txt"));
|
||||
let script = format!(
|
||||
"printf ready > \"$2\"; while [ ! -e \"$3\" ]; do /bin/sleep 0.01; done; {operation}"
|
||||
);
|
||||
let mut command = vec![
|
||||
"/bin/sh".to_string(),
|
||||
"-c".to_string(),
|
||||
script,
|
||||
"sh".to_string(),
|
||||
target.display().to_string(),
|
||||
ready.display().to_string(),
|
||||
proceed.display().to_string(),
|
||||
];
|
||||
command.extend(extra_args);
|
||||
let child = spawn_command_under_sandbox(
|
||||
command,
|
||||
workspace.clone(),
|
||||
profile,
|
||||
workspace,
|
||||
StdioPolicy::RedirectForShellTool,
|
||||
HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.expect("spawn command before creating protected fixture");
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !ready.as_path().exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("sandboxed command did not signal policy application");
|
||||
setup();
|
||||
fs::write(&proceed, "proceed").expect("release sandboxed command");
|
||||
child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.expect("wait for sandboxed command")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seatbelt_enforces_brace_alternation_and_escaped_deny_globs() {
|
||||
core_test_support::skip_if_sandbox!();
|
||||
|
||||
let (temp_dir, workspace) = workspace_fixture();
|
||||
let dotenv = workspace.join(".env");
|
||||
let secrets = workspace.join("secrets.yml");
|
||||
let escaped = workspace.join("config?.env");
|
||||
let allowed = workspace.join("notes.txt");
|
||||
for path in [&dotenv, &secrets, &escaped] {
|
||||
fs::write(path, "protected contents").expect("write protected fixture");
|
||||
}
|
||||
fs::write(&allowed, "allowed contents").expect("write allowed fixture");
|
||||
|
||||
let config = format!(
|
||||
r#"default_permissions = "test"
|
||||
[permissions.test]
|
||||
extends = ":read-only"
|
||||
[permissions.test.filesystem]
|
||||
"{workspace}" = "write"
|
||||
"{workspace}/{{.env,secrets.yml}}*" = "deny"
|
||||
'{workspace}/config\?.env' = "deny"
|
||||
"#,
|
||||
workspace = workspace.display()
|
||||
);
|
||||
fs::write(temp_dir.path().join("config.toml"), config).expect("write permissions config");
|
||||
let profile = core_test_support::load_default_config_for_test(&temp_dir)
|
||||
.await
|
||||
.permissions
|
||||
.effective_permission_profile();
|
||||
|
||||
for denied_path in [&dotenv, &secrets, &escaped] {
|
||||
assert_denied_read(denied_path, &workspace, &profile).await;
|
||||
}
|
||||
|
||||
let output = run_path_command("/bin/cat", &allowed, &workspace, &profile).await;
|
||||
assert!(output.status.success(), "allowed read failed: {output:?}");
|
||||
assert_eq!(output.stdout, b"allowed contents");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seatbelt_blocks_renaming_ancestors_of_explicit_protected_files() {
|
||||
core_test_support::skip_if_sandbox!();
|
||||
|
||||
for access in [FileSystemAccessMode::Read, FileSystemAccessMode::Deny] {
|
||||
let (_temp_dir, workspace) = workspace_fixture();
|
||||
let private = workspace.join("private");
|
||||
let protected = private.join("secret.env");
|
||||
let renamed = workspace.join("exposed");
|
||||
fs::create_dir(&private).expect("create protected directory");
|
||||
fs::write(&protected, "protected contents").expect("write protected fixture");
|
||||
|
||||
let profile = permission_profile(&workspace, vec![(protected.clone().into(), access)]);
|
||||
let command = vec![
|
||||
"/bin/mv".to_string(),
|
||||
private.display().to_string(),
|
||||
renamed.display().to_string(),
|
||||
];
|
||||
let output = run_sandboxed_command(command.clone(), &workspace, &profile).await;
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Seatbelt allowed renaming an ancestor of a {access:?} path: {output:?}"
|
||||
);
|
||||
assert_file_contents(&protected, "protected contents");
|
||||
assert!(!renamed.as_path().exists(), "rename unexpectedly succeeded");
|
||||
|
||||
let unprotected = permission_profile(&workspace, Vec::new());
|
||||
let control = run_sandboxed_command(command, &workspace, &unprotected).await;
|
||||
assert!(
|
||||
control.status.success(),
|
||||
"rename without a protected descendant should succeed: {control:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seatbelt_blocks_renaming_dynamic_glob_ancestors() {
|
||||
core_test_support::skip_if_sandbox!();
|
||||
|
||||
for component in [".githu?", "{.github,.gitlab}", r".githu\b", "*"] {
|
||||
let (_temp_dir, workspace) = workspace_fixture();
|
||||
let github = workspace.join(".github");
|
||||
let workflows = github.join("workflows");
|
||||
let protected = workflows.join("release.yml");
|
||||
let renamed = workspace.join("exposed");
|
||||
fs::create_dir_all(&workflows).expect("create protected directory");
|
||||
fs::write(&protected, "protected workflow").expect("write protected fixture");
|
||||
|
||||
let profile = denied_glob_profile(
|
||||
&workspace,
|
||||
format!("{}/{component}/workflows/*.yml", workspace.display()),
|
||||
);
|
||||
let command = vec![
|
||||
"/bin/mv".to_string(),
|
||||
github.display().to_string(),
|
||||
renamed.display().to_string(),
|
||||
];
|
||||
let output = run_sandboxed_command(command.clone(), &workspace, &profile).await;
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Seatbelt allowed renaming ancestor matched by {component:?}: {output:?}"
|
||||
);
|
||||
assert_file_contents(&protected, "protected workflow");
|
||||
|
||||
let unprotected = permission_profile(&workspace, Vec::new());
|
||||
let control = run_sandboxed_command(command, &workspace, &unprotected).await;
|
||||
assert!(
|
||||
control.status.success(),
|
||||
"rename without the deny glob should succeed: {control:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seatbelt_blocks_renaming_protected_workspace_into_another_writable_root() {
|
||||
core_test_support::skip_if_sandbox!();
|
||||
|
||||
let (_temp_dir, workspace) = workspace_fixture();
|
||||
let destination = workspace
|
||||
.parent()
|
||||
.expect("workspace has a parent")
|
||||
.join("destination");
|
||||
let moved_workspace = destination.join("moved");
|
||||
let protected = workspace.join("secret.env");
|
||||
fs::create_dir(&destination).expect("create writable destination");
|
||||
fs::write(&protected, "protected contents").expect("write protected fixture");
|
||||
|
||||
let profile = permission_profile(
|
||||
&workspace,
|
||||
vec![
|
||||
(destination.clone().into(), FileSystemAccessMode::Write),
|
||||
(protected.clone().into(), FileSystemAccessMode::Read),
|
||||
],
|
||||
);
|
||||
let output = run_sandboxed_command(
|
||||
vec![
|
||||
"/bin/mv".to_string(),
|
||||
workspace.display().to_string(),
|
||||
moved_workspace.display().to_string(),
|
||||
],
|
||||
&workspace,
|
||||
&profile,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Seatbelt allowed moving the workspace into another writable root: {output:?}"
|
||||
);
|
||||
assert_file_contents(&protected, "protected contents");
|
||||
assert!(
|
||||
!moved_workspace.as_path().exists(),
|
||||
"workspace unexpectedly moved"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seatbelt_ancestor_protection_does_not_block_unlinking_regular_files() {
|
||||
core_test_support::skip_if_sandbox!();
|
||||
|
||||
let (_temp_dir, workspace) = workspace_fixture();
|
||||
let regular_file = workspace.join("regular-file");
|
||||
let hypothetical_descendant = regular_file.join("secret.env");
|
||||
fs::write(®ular_file, "replaceable").expect("write regular file");
|
||||
let profile = permission_profile(
|
||||
&workspace,
|
||||
vec![(hypothetical_descendant.into(), FileSystemAccessMode::Read)],
|
||||
);
|
||||
|
||||
let output = run_path_command("/bin/rm", ®ular_file, &workspace, &profile).await;
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"directory-only ancestor protection blocked a regular file: {output:?}"
|
||||
);
|
||||
assert!(
|
||||
!regular_file.as_path().exists(),
|
||||
"regular file was not removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seatbelt_deny_globs_block_writes_to_existing_and_new_files() {
|
||||
core_test_support::skip_if_sandbox!();
|
||||
|
||||
let (_temp_dir, workspace) = workspace_fixture();
|
||||
let protected = workspace.join("secret.env");
|
||||
let allowed = workspace.join("notes.txt");
|
||||
fs::write(&protected, "original").expect("write protected fixture");
|
||||
let profile = denied_glob_profile(&workspace, format!("{}/*.env", workspace.display()));
|
||||
|
||||
for (operation, script) in [
|
||||
("overwrite", "printf changed >> \"$1\""),
|
||||
("truncate", ": > \"$1\""),
|
||||
] {
|
||||
let output = run_shell_command(script, &protected, &workspace, &profile).await;
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Seatbelt allowed {operation} on a denied file: {output:?}"
|
||||
);
|
||||
assert_file_contents(&protected, "original");
|
||||
}
|
||||
|
||||
let unlink = run_path_command("/bin/rm", &protected, &workspace, &profile).await;
|
||||
assert!(
|
||||
!unlink.status.success(),
|
||||
"Seatbelt unexpectedly allowed unlinking a denied glob: {unlink:?}"
|
||||
);
|
||||
assert_file_contents(&protected, "original");
|
||||
|
||||
for (operation, write_script) in [
|
||||
("overwrite", "printf changed >> \"$1\""),
|
||||
("truncate", ": > \"$1\""),
|
||||
] {
|
||||
let late_protected = workspace.join(format!("late-{operation}.env"));
|
||||
let output = run_after_policy_applied(
|
||||
write_script,
|
||||
&late_protected,
|
||||
Vec::new(),
|
||||
&workspace,
|
||||
&profile,
|
||||
|| {
|
||||
fs::write(&late_protected, "created after policy generation")
|
||||
.expect("create protected fixture after sandbox launch");
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Seatbelt allowed {operation} on a file created after policy generation: {output:?}"
|
||||
);
|
||||
assert_file_contents(&late_protected, "created after policy generation");
|
||||
}
|
||||
|
||||
let allowed_write =
|
||||
run_shell_command("printf allowed > \"$1\"", &allowed, &workspace, &profile).await;
|
||||
assert!(
|
||||
allowed_write.status.success(),
|
||||
"unrelated write should remain allowed: {allowed_write:?}"
|
||||
);
|
||||
assert_file_contents(&allowed, "allowed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seatbelt_standard_profiles_preserve_expected_filesystem_access() {
|
||||
core_test_support::skip_if_sandbox!();
|
||||
let (_temp_dir, workspace) = workspace_fixture();
|
||||
let inside = workspace.join("notes.txt");
|
||||
let outside = workspace.parent().expect("parent").join("outside.txt");
|
||||
fs::write(&inside, "original").expect("write workspace fixture");
|
||||
let profile = permission_profile(&workspace, Vec::new());
|
||||
let script = "set -e; /bin/cat \"$1\" >/dev/null; printf changed >> \"$1\"; /bin/mkdir \"$1.dir\"; /bin/mv \"$1\" \"$1.dir/moved\"; /bin/rm \"$1.dir/moved\"; /bin/rmdir \"$1.dir\"";
|
||||
let inside_output = run_shell_command(script, &inside, &workspace, &profile).await;
|
||||
assert!(
|
||||
inside_output.status.success(),
|
||||
"ordinary workspace operations failed: {inside_output:?}"
|
||||
);
|
||||
|
||||
let outside_output =
|
||||
run_shell_command("printf denied > \"$1\"", &outside, &workspace, &profile).await;
|
||||
assert!(
|
||||
!outside_output.status.success(),
|
||||
"write outside the workspace unexpectedly succeeded: {outside_output:?}"
|
||||
);
|
||||
assert!(!outside.as_path().exists(), "outside file was created");
|
||||
let readable = workspace.join("readable.txt");
|
||||
fs::write(&readable, "readable contents").expect("write read-only fixture");
|
||||
let profile = PermissionProfile::read_only();
|
||||
let read = run_path_command("/bin/cat", &readable, &workspace, &profile).await;
|
||||
assert!(read.status.success(), "ordinary read failed: {read:?}");
|
||||
assert_eq!(read.stdout, b"readable contents");
|
||||
|
||||
let write = run_shell_command("printf changed > \"$1\"", &readable, &workspace, &profile).await;
|
||||
assert!(
|
||||
!write.status.success(),
|
||||
"read-only write succeeded: {write:?}"
|
||||
);
|
||||
assert_file_contents(&readable, "readable contents");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seatbelt_blocks_protected_reads_and_writes_through_symlink_aliases() {
|
||||
core_test_support::skip_if_sandbox!();
|
||||
|
||||
let (_temp_dir, workspace) = workspace_fixture();
|
||||
let private = workspace.join("private");
|
||||
let alias = workspace.join("alias");
|
||||
let protected = private.join("secret.env");
|
||||
let alias_path = alias.join("secret.env");
|
||||
fs::create_dir(&private).expect("create protected directory");
|
||||
fs::write(&protected, "protected contents").expect("write protected fixture");
|
||||
symlink(private.as_path(), alias.as_path()).expect("create directory symlink");
|
||||
|
||||
for (restricted_path, accessed_path) in [
|
||||
(FileSystemPath::from(protected.clone()), alias_path.clone()),
|
||||
(
|
||||
FileSystemPath::GlobPattern {
|
||||
pattern: format!("{}/*.env", private.display()),
|
||||
},
|
||||
alias_path.clone(),
|
||||
),
|
||||
(
|
||||
FileSystemPath::GlobPattern {
|
||||
pattern: format!("{}/*.env", alias.display()),
|
||||
},
|
||||
protected.clone(),
|
||||
),
|
||||
] {
|
||||
let profile = permission_profile(
|
||||
&workspace,
|
||||
vec![(restricted_path, FileSystemAccessMode::Deny)],
|
||||
);
|
||||
assert_denied_read(&accessed_path, &workspace, &profile).await;
|
||||
|
||||
let write = run_shell_command(
|
||||
"printf changed > \"$1\"",
|
||||
&accessed_path,
|
||||
&workspace,
|
||||
&profile,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!write.status.success(),
|
||||
"Seatbelt allowed writing through protected symlink alias: {write:?}"
|
||||
);
|
||||
assert_file_contents(&protected, "protected contents");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seatbelt_globstar_protects_files_and_ancestors_at_every_depth() {
|
||||
core_test_support::skip_if_sandbox!();
|
||||
|
||||
let (_temp_dir, workspace) = workspace_fixture();
|
||||
let first = workspace.join("first");
|
||||
let second = first.join("second");
|
||||
fs::create_dir_all(&second).expect("create nested fixture directories");
|
||||
let protected_paths = [
|
||||
workspace.join("root.env"),
|
||||
first.join("first.env"),
|
||||
second.join("second.env"),
|
||||
];
|
||||
for path in &protected_paths {
|
||||
fs::write(path, "protected contents").expect("write protected fixture");
|
||||
}
|
||||
let profile = denied_glob_profile(&workspace, format!("{}/**/*.env", workspace.display()));
|
||||
|
||||
for path in &protected_paths {
|
||||
assert_denied_read(path, &workspace, &profile).await;
|
||||
}
|
||||
|
||||
for ancestor in [&first, &second] {
|
||||
let destination = ancestor.with_extension("renamed");
|
||||
let output = run_sandboxed_command(
|
||||
vec![
|
||||
"/bin/mv".to_string(),
|
||||
ancestor.display().to_string(),
|
||||
destination.display().to_string(),
|
||||
],
|
||||
&workspace,
|
||||
&profile,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"globstar allowed renaming protected ancestor {}: {output:?}",
|
||||
ancestor.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seatbelt_brace_alternatives_with_slashes_protect_each_branch() {
|
||||
core_test_support::skip_if_sandbox!();
|
||||
|
||||
let (_temp_dir, workspace) = workspace_fixture();
|
||||
let nested = workspace.join("private/nested");
|
||||
let other = workspace.join("other");
|
||||
let allowed = workspace.join("notes.txt");
|
||||
fs::create_dir_all(&nested).expect("create nested protected directory");
|
||||
fs::create_dir(&other).expect("create alternate protected directory");
|
||||
let protected_paths = [nested.join("secret.env"), other.join("secret.env")];
|
||||
for path in &protected_paths {
|
||||
fs::write(path, "protected contents").expect("write protected fixture");
|
||||
}
|
||||
fs::write(&allowed, "allowed contents").expect("write allowed fixture");
|
||||
let profile = denied_glob_profile(
|
||||
&workspace,
|
||||
format!("{}/{{private/nested,other}}/*.env", workspace.display()),
|
||||
);
|
||||
|
||||
for path in &protected_paths {
|
||||
assert_denied_read(path, &workspace, &profile).await;
|
||||
}
|
||||
|
||||
for ancestor in [&workspace.join("private"), &nested, &other] {
|
||||
let destination = ancestor.with_extension("renamed");
|
||||
let output = run_sandboxed_command(
|
||||
vec![
|
||||
"/bin/mv".to_string(),
|
||||
ancestor.display().to_string(),
|
||||
destination.display().to_string(),
|
||||
],
|
||||
&workspace,
|
||||
&profile,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"brace alternative allowed renaming {}: {output:?}",
|
||||
ancestor.display()
|
||||
);
|
||||
}
|
||||
|
||||
let output = run_path_command("/bin/cat", &allowed, &workspace, &profile).await;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"brace alternatives produced an invalid or overly broad policy: {output:?}"
|
||||
);
|
||||
assert_eq!(output.stdout, b"allowed contents");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seatbelt_protects_directory_trees_created_after_policy_application() {
|
||||
core_test_support::skip_if_sandbox!();
|
||||
|
||||
for operation in ["read", "rename"] {
|
||||
let (_temp_dir, workspace) = workspace_fixture();
|
||||
let late_directory = workspace.join("late/deep");
|
||||
let protected = late_directory.join("secret.env");
|
||||
let destination = workspace.join("exposed");
|
||||
let profile = denied_glob_profile(&workspace, format!("{}/**/*.env", workspace.display()));
|
||||
let operation_script = match operation {
|
||||
"read" => "/bin/cat \"$1\"",
|
||||
"rename" => "/bin/mv \"$4\" \"$5\"",
|
||||
_ => unreachable!("unknown operation"),
|
||||
};
|
||||
let output = run_after_policy_applied(
|
||||
operation_script,
|
||||
&protected,
|
||||
vec![
|
||||
workspace.join("late").display().to_string(),
|
||||
destination.display().to_string(),
|
||||
],
|
||||
&workspace,
|
||||
&profile,
|
||||
|| {
|
||||
fs::create_dir_all(&late_directory)
|
||||
.expect("create protected directory after policy");
|
||||
fs::write(&protected, "protected contents").expect("write protected fixture");
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Seatbelt allowed {operation} after protected directory appeared: {output:?}"
|
||||
);
|
||||
assert_file_contents(&protected, "protected contents");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seatbelt_blocks_atomic_rename_exchange_and_destination_replacement() {
|
||||
core_test_support::skip_if_sandbox!();
|
||||
|
||||
for (operation, flags) in [("exchange", libc::RENAME_SWAP), ("replace", 0)] {
|
||||
let (_temp_dir, workspace) = workspace_fixture();
|
||||
let private = workspace.join("private");
|
||||
let destination = workspace.join("destination");
|
||||
let protected = private.join("secret.env");
|
||||
fs::create_dir(&private).expect("create protected directory");
|
||||
fs::create_dir(&destination).expect("create replacement directory");
|
||||
fs::write(&protected, "protected contents").expect("write protected fixture");
|
||||
let profile = permission_profile(
|
||||
&workspace,
|
||||
vec![(protected.clone().into(), FileSystemAccessMode::Read)],
|
||||
);
|
||||
let script = "import ctypes, os, sys; libc = ctypes.CDLL(None, use_errno=True); result = libc.renameatx_np(-2, os.fsencode(sys.argv[1]), -2, os.fsencode(sys.argv[2]), int(sys.argv[3])); sys.exit(0 if result == 0 else ctypes.get_errno())";
|
||||
let command = vec![
|
||||
"/usr/bin/python3".to_string(),
|
||||
"-c".to_string(),
|
||||
script.to_string(),
|
||||
private.display().to_string(),
|
||||
destination.display().to_string(),
|
||||
flags.to_string(),
|
||||
];
|
||||
let output = run_sandboxed_command(command.clone(), &workspace, &profile).await;
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Seatbelt allowed atomic {operation} of a protected ancestor: {output:?}"
|
||||
);
|
||||
assert_file_contents(&protected, "protected contents");
|
||||
|
||||
let unprotected = permission_profile(&workspace, Vec::new());
|
||||
let control = run_sandboxed_command(command, &workspace, &unprotected).await;
|
||||
assert!(
|
||||
control.status.success(),
|
||||
"unprotected atomic {operation} should succeed: {control:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seatbelt_blocks_protected_ancestor_moves_across_writable_roots() {
|
||||
core_test_support::skip_if_sandbox!();
|
||||
|
||||
for destination_is_nested in [false, true] {
|
||||
for use_glob in [false, true] {
|
||||
let (_temp_dir, workspace) = workspace_fixture();
|
||||
let private = workspace.join("private");
|
||||
let protected = private.join("secret.env");
|
||||
let destination = if destination_is_nested {
|
||||
workspace.join("destination")
|
||||
} else {
|
||||
workspace
|
||||
.parent()
|
||||
.expect("workspace has a parent")
|
||||
.join("destination")
|
||||
};
|
||||
let moved = destination.join("moved");
|
||||
fs::create_dir(&private).expect("create protected directory");
|
||||
fs::create_dir(&destination).expect("create writable destination");
|
||||
fs::write(&protected, "protected contents").expect("write protected fixture");
|
||||
let protected_path = if use_glob {
|
||||
FileSystemPath::GlobPattern {
|
||||
pattern: format!("{}/*.env", private.display()),
|
||||
}
|
||||
} else {
|
||||
protected.clone().into()
|
||||
};
|
||||
let profile = permission_profile(
|
||||
&workspace,
|
||||
vec![
|
||||
(destination.clone().into(), FileSystemAccessMode::Write),
|
||||
(protected_path, FileSystemAccessMode::Deny),
|
||||
],
|
||||
);
|
||||
|
||||
let output = run_sandboxed_command(
|
||||
vec![
|
||||
"/bin/mv".to_string(),
|
||||
private.display().to_string(),
|
||||
moved.display().to_string(),
|
||||
],
|
||||
&workspace,
|
||||
&profile,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"protected ancestor crossed writable roots: nested={destination_is_nested}, glob={use_glob}, output={output:?}"
|
||||
);
|
||||
assert_file_contents(&protected, "protected contents");
|
||||
assert!(!moved.as_path().exists(), "protected ancestor was moved");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seatbelt_blocks_creating_files_matched_by_denied_globs() {
|
||||
core_test_support::skip_if_sandbox!();
|
||||
|
||||
let (_temp_dir, workspace) = workspace_fixture();
|
||||
let nested = workspace.join("nested");
|
||||
fs::create_dir(&nested).expect("create nested directory");
|
||||
let profile = denied_glob_profile(&workspace, format!("{}/**/*.env", workspace.display()));
|
||||
|
||||
for path in [workspace.join("created.env"), nested.join("created.env")] {
|
||||
let output =
|
||||
run_shell_command("printf created > \"$1\"", &path, &workspace, &profile).await;
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Seatbelt allowed creation at a denied path: {output:?}"
|
||||
);
|
||||
assert!(!path.as_path().exists(), "denied file was created");
|
||||
}
|
||||
|
||||
let allowed = nested.join("created.txt");
|
||||
let output = run_path_command("/usr/bin/touch", &allowed, &workspace, &profile).await;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"unrelated file creation should succeed: {output:?}"
|
||||
);
|
||||
assert!(allowed.as_path().exists(), "unrelated file was not created");
|
||||
}
|
||||
Reference in New Issue
Block a user