mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
## 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
777 lines
27 KiB
Rust
777 lines
27 KiB
Rust
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");
|
|
}
|