diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index 5d4767894..6dcac030a 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -38,6 +38,7 @@ where let network_sandbox_policy = NetworkSandboxPolicy::from(sandbox_policy); let args = create_linux_sandbox_command_args_for_policies( command, + command_cwd.as_path(), sandbox_policy, &file_system_sandbox_policy, network_sandbox_policy, @@ -76,6 +77,7 @@ pub(crate) fn allow_network_for_proxy(enforce_managed_network: bool) -> bool { #[allow(clippy::too_many_arguments)] pub(crate) fn create_linux_sandbox_command_args_for_policies( command: Vec, + command_cwd: &Path, sandbox_policy: &SandboxPolicy, file_system_sandbox_policy: &FileSystemSandboxPolicy, network_sandbox_policy: NetworkSandboxPolicy, @@ -93,10 +95,16 @@ pub(crate) fn create_linux_sandbox_command_args_for_policies( .to_str() .unwrap_or_else(|| panic!("cwd must be valid UTF-8")) .to_string(); + let command_cwd = command_cwd + .to_str() + .unwrap_or_else(|| panic!("command cwd must be valid UTF-8")) + .to_string(); let mut linux_cmd: Vec = vec![ "--sandbox-policy-cwd".to_string(), sandbox_policy_cwd, + "--command-cwd".to_string(), + command_cwd, "--sandbox-policy".to_string(), sandbox_policy_json, "--file-system-sandbox-policy".to_string(), @@ -120,16 +128,26 @@ pub(crate) fn create_linux_sandbox_command_args_for_policies( #[cfg(test)] pub(crate) fn create_linux_sandbox_command_args( command: Vec, + command_cwd: &Path, sandbox_policy_cwd: &Path, use_legacy_landlock: bool, allow_network_for_proxy: bool, ) -> Vec { + let command_cwd = command_cwd + .to_str() + .unwrap_or_else(|| panic!("command cwd must be valid UTF-8")) + .to_string(); let sandbox_policy_cwd = sandbox_policy_cwd .to_str() .unwrap_or_else(|| panic!("cwd must be valid UTF-8")) .to_string(); - let mut linux_cmd: Vec = vec!["--sandbox-policy-cwd".to_string(), sandbox_policy_cwd]; + let mut linux_cmd: Vec = vec![ + "--sandbox-policy-cwd".to_string(), + sandbox_policy_cwd, + "--command-cwd".to_string(), + command_cwd, + ]; if use_legacy_landlock { linux_cmd.push("--use-legacy-landlock".to_string()); } diff --git a/codex-rs/core/src/landlock_tests.rs b/codex-rs/core/src/landlock_tests.rs index 75b887e26..454878cd5 100644 --- a/codex-rs/core/src/landlock_tests.rs +++ b/codex-rs/core/src/landlock_tests.rs @@ -4,15 +4,17 @@ use pretty_assertions::assert_eq; #[test] fn legacy_landlock_flag_is_included_when_requested() { let command = vec!["/bin/true".to_string()]; + let command_cwd = Path::new("/tmp/link"); let cwd = Path::new("/tmp"); - let default_bwrap = create_linux_sandbox_command_args(command.clone(), cwd, false, false); + let default_bwrap = + create_linux_sandbox_command_args(command.clone(), command_cwd, cwd, false, false); assert_eq!( default_bwrap.contains(&"--use-legacy-landlock".to_string()), false ); - let legacy_landlock = create_linux_sandbox_command_args(command, cwd, true, false); + let legacy_landlock = create_linux_sandbox_command_args(command, command_cwd, cwd, true, false); assert_eq!( legacy_landlock.contains(&"--use-legacy-landlock".to_string()), true @@ -22,9 +24,10 @@ fn legacy_landlock_flag_is_included_when_requested() { #[test] fn proxy_flag_is_included_when_requested() { let command = vec!["/bin/true".to_string()]; + let command_cwd = Path::new("/tmp/link"); let cwd = Path::new("/tmp"); - let args = create_linux_sandbox_command_args(command, cwd, true, true); + let args = create_linux_sandbox_command_args(command, command_cwd, cwd, true, true); assert_eq!( args.contains(&"--allow-network-for-proxy".to_string()), true @@ -34,6 +37,7 @@ fn proxy_flag_is_included_when_requested() { #[test] fn split_policy_flags_are_included() { let command = vec!["/bin/true".to_string()]; + let command_cwd = Path::new("/tmp/link"); let cwd = Path::new("/tmp"); let sandbox_policy = SandboxPolicy::new_read_only_policy(); let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy); @@ -41,6 +45,7 @@ fn split_policy_flags_are_included() { let args = create_linux_sandbox_command_args_for_policies( command, + command_cwd, &sandbox_policy, &file_system_sandbox_policy, network_sandbox_policy, @@ -59,6 +64,11 @@ fn split_policy_flags_are_included() { .any(|window| window[0] == "--network-sandbox-policy" && window[1] == "\"restricted\""), true ); + assert_eq!( + args.windows(2) + .any(|window| window[0] == "--command-cwd" && window[1] == "/tmp/link"), + true + ); } #[test] diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index cf2382389..db8878881 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -672,6 +672,7 @@ impl SandboxManager { let allow_proxy_network = allow_network_for_proxy(enforce_managed_network); let mut args = create_linux_sandbox_command_args_for_policies( command.clone(), + spec.cwd.as_path(), &effective_policy, &effective_file_system_policy, effective_network_policy, diff --git a/codex-rs/core/src/sandboxing/mod_tests.rs b/codex-rs/core/src/sandboxing/mod_tests.rs index d252443fb..4d45dfb00 100644 --- a/codex-rs/core/src/sandboxing/mod_tests.rs +++ b/codex-rs/core/src/sandboxing/mod_tests.rs @@ -35,8 +35,15 @@ use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; use pretty_assertions::assert_eq; use std::collections::HashMap; +#[cfg(unix)] +use std::path::Path; use tempfile::TempDir; +#[cfg(unix)] +fn symlink_dir(original: &Path, link: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(original, link) +} + #[test] fn danger_full_access_defaults_to_no_sandbox_without_network_requirements() { let manager = SandboxManager::new(); @@ -217,6 +224,41 @@ fn normalize_additional_permissions_preserves_network() { ); } +#[cfg(unix)] +#[test] +fn normalize_additional_permissions_canonicalizes_symlinked_write_paths() { + let temp_dir = TempDir::new().expect("create temp dir"); + let real_root = temp_dir.path().join("real"); + let link_root = temp_dir.path().join("link"); + let write_dir = real_root.join("write"); + std::fs::create_dir_all(&write_dir).expect("create write dir"); + symlink_dir(&real_root, &link_root).expect("create symlinked root"); + + let link_write_dir = + AbsolutePathBuf::from_absolute_path(link_root.join("write")).expect("link write dir"); + let expected_write_dir = AbsolutePathBuf::from_absolute_path( + write_dir.canonicalize().expect("canonicalize write dir"), + ) + .expect("absolute canonical write dir"); + + let permissions = normalize_additional_permissions(PermissionProfile { + file_system: Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![link_write_dir]), + }), + ..Default::default() + }) + .expect("permissions"); + + assert_eq!( + permissions.file_system, + Some(FileSystemPermissions { + read: Some(vec![]), + write: Some(vec![expected_write_dir]), + }) + ); +} + #[test] fn normalize_additional_permissions_drops_empty_nested_profiles() { let permissions = normalize_additional_permissions(PermissionProfile { diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index e93bb80f1..23e503e6b 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -96,7 +96,8 @@ pub(crate) struct BwrapArgs { pub(crate) fn create_bwrap_command_args( command: Vec, file_system_sandbox_policy: &FileSystemSandboxPolicy, - cwd: &Path, + sandbox_policy_cwd: &Path, + command_cwd: &Path, options: BwrapOptions, ) -> Result { if file_system_sandbox_policy.has_full_disk_write_access() { @@ -110,7 +111,13 @@ pub(crate) fn create_bwrap_command_args( }; } - create_bwrap_flags(command, file_system_sandbox_policy, cwd, options) + create_bwrap_flags( + command, + file_system_sandbox_policy, + sandbox_policy_cwd, + command_cwd, + options, + ) } fn create_bwrap_flags_full_filesystem(command: Vec, options: BwrapOptions) -> BwrapArgs { @@ -144,13 +151,15 @@ fn create_bwrap_flags_full_filesystem(command: Vec, options: BwrapOption fn create_bwrap_flags( command: Vec, file_system_sandbox_policy: &FileSystemSandboxPolicy, - cwd: &Path, + sandbox_policy_cwd: &Path, + command_cwd: &Path, options: BwrapOptions, ) -> Result { let BwrapArgs { args: filesystem_args, preserved_files, - } = create_filesystem_args(file_system_sandbox_policy, cwd)?; + } = create_filesystem_args(file_system_sandbox_policy, sandbox_policy_cwd)?; + let normalized_command_cwd = normalize_command_cwd_for_bwrap(command_cwd); let mut args = Vec::new(); args.push("--new-session".to_string()); args.push("--die-with-parent".to_string()); @@ -168,6 +177,14 @@ fn create_bwrap_flags( args.push("--proc".to_string()); args.push("/proc".to_string()); } + if normalized_command_cwd.as_path() != command_cwd { + // Bubblewrap otherwise inherits the helper's logical cwd, which can be + // a symlink alias that disappears once the sandbox only mounts + // canonical roots. Enter the canonical command cwd explicitly so + // relative paths stay aligned with the mounted filesystem view. + args.push("--chdir".to_string()); + args.push(path_to_string(normalized_command_cwd.as_path())); + } args.push("--".to_string()); args.extend(command); Ok(BwrapArgs { @@ -393,6 +410,12 @@ fn path_depth(path: &Path) -> usize { path.components().count() } +fn normalize_command_cwd_for_bwrap(command_cwd: &Path) -> PathBuf { + command_cwd + .canonicalize() + .unwrap_or_else(|_| command_cwd.to_path_buf()) +} + fn append_mount_target_parent_dir_args(args: &mut Vec, mount_target: &Path, anchor: &Path) { let mount_target_dir = if mount_target.is_dir() { mount_target @@ -607,6 +630,7 @@ mod tests { command.clone(), &FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess), Path::new("/"), + Path::new("/"), BwrapOptions { mount_proc: true, network_mode: BwrapNetworkMode::FullAccess, @@ -624,6 +648,7 @@ mod tests { command, &FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess), Path::new("/"), + Path::new("/"), BwrapOptions { mount_proc: true, network_mode: BwrapNetworkMode::ProxyOnly, @@ -650,6 +675,62 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn restricted_policy_chdirs_to_canonical_command_cwd() { + let temp_dir = TempDir::new().expect("temp dir"); + let real_root = temp_dir.path().join("real"); + let real_subdir = real_root.join("subdir"); + let link_root = temp_dir.path().join("link"); + std::fs::create_dir_all(&real_subdir).expect("create real subdir"); + std::os::unix::fs::symlink(&real_root, &link_root).expect("create symlinked root"); + + let sandbox_policy_cwd = AbsolutePathBuf::from_absolute_path(&link_root) + .expect("absolute symlinked root") + .to_path_buf(); + let command_cwd = link_root.join("subdir"); + let canonical_command_cwd = real_subdir + .canonicalize() + .expect("canonicalize command cwd"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }, + ]); + + let args = create_bwrap_command_args( + vec!["/bin/true".to_string()], + &policy, + sandbox_policy_cwd.as_path(), + &command_cwd, + BwrapOptions::default(), + ) + .expect("create bwrap args"); + let canonical_command_cwd = path_to_string(&canonical_command_cwd); + let link_command_cwd = path_to_string(&command_cwd); + + assert!( + args.args + .windows(2) + .any(|window| { window == ["--chdir", canonical_command_cwd.as_str()] }) + ); + assert!( + !args + .args + .windows(2) + .any(|window| { window == ["--chdir", link_command_cwd.as_str()] }) + ); + } + #[test] fn mounts_dev_before_writable_dev_binds() { let sandbox_policy = SandboxPolicy::WorkspaceWrite { diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 004025c17..a6a47117e 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -31,6 +31,15 @@ pub struct LandlockCommand { #[arg(long = "sandbox-policy-cwd")] pub sandbox_policy_cwd: PathBuf, + /// The logical working directory for the command being sandboxed. + /// + /// This can intentionally differ from `sandbox_policy_cwd` when the + /// command runs from a symlinked alias of the policy workspace. Keep it + /// explicit so bubblewrap can preserve the caller's logical cwd when that + /// alias would otherwise disappear inside the sandbox namespace. + #[arg(long = "command-cwd", hide = true)] + pub command_cwd: Option, + /// Legacy compatibility policy. /// /// Newer callers pass split filesystem/network policies as well so the @@ -91,6 +100,7 @@ pub struct LandlockCommand { pub fn run_main() -> ! { let LandlockCommand { sandbox_policy_cwd, + command_cwd, sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, @@ -177,6 +187,7 @@ pub fn run_main() -> ! { }; let inner = build_inner_seccomp_command(InnerSeccompCommandArgs { sandbox_policy_cwd: &sandbox_policy_cwd, + command_cwd: command_cwd.as_deref(), sandbox_policy: &sandbox_policy, file_system_sandbox_policy: &file_system_sandbox_policy, network_sandbox_policy, @@ -186,6 +197,7 @@ pub fn run_main() -> ! { }); run_bwrap_with_proc_fallback( &sandbox_policy_cwd, + command_cwd.as_deref(), &file_system_sandbox_policy, network_sandbox_policy, inner, @@ -387,6 +399,7 @@ fn ensure_legacy_landlock_mode_supports_policy( fn run_bwrap_with_proc_fallback( sandbox_policy_cwd: &Path, + command_cwd: Option<&Path>, file_system_sandbox_policy: &FileSystemSandboxPolicy, network_sandbox_policy: NetworkSandboxPolicy, inner: Vec, @@ -395,10 +408,12 @@ fn run_bwrap_with_proc_fallback( ) -> ! { let network_mode = bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy); let mut mount_proc = mount_proc; + let command_cwd = command_cwd.unwrap_or(sandbox_policy_cwd); if mount_proc && !preflight_proc_mount_support( sandbox_policy_cwd, + command_cwd, file_system_sandbox_policy, network_mode, ) @@ -416,6 +431,7 @@ fn run_bwrap_with_proc_fallback( inner, file_system_sandbox_policy, sandbox_policy_cwd, + command_cwd, options, ); exec_vendored_bwrap(bwrap_args.args, bwrap_args.preserved_files); @@ -438,12 +454,14 @@ fn build_bwrap_argv( inner: Vec, file_system_sandbox_policy: &FileSystemSandboxPolicy, sandbox_policy_cwd: &Path, + command_cwd: &Path, options: BwrapOptions, ) -> crate::bwrap::BwrapArgs { let mut bwrap_args = create_bwrap_command_args( inner, file_system_sandbox_policy, sandbox_policy_cwd, + command_cwd, options, ) .unwrap_or_else(|err| panic!("error building bubblewrap command: {err:?}")); @@ -468,17 +486,23 @@ fn build_bwrap_argv( fn preflight_proc_mount_support( sandbox_policy_cwd: &Path, + command_cwd: &Path, file_system_sandbox_policy: &FileSystemSandboxPolicy, network_mode: BwrapNetworkMode, ) -> bool { - let preflight_argv = - build_preflight_bwrap_argv(sandbox_policy_cwd, file_system_sandbox_policy, network_mode); + let preflight_argv = build_preflight_bwrap_argv( + sandbox_policy_cwd, + command_cwd, + file_system_sandbox_policy, + network_mode, + ); let stderr = run_bwrap_in_child_capture_stderr(preflight_argv); !is_proc_mount_failure(stderr.as_str()) } fn build_preflight_bwrap_argv( sandbox_policy_cwd: &Path, + command_cwd: &Path, file_system_sandbox_policy: &FileSystemSandboxPolicy, network_mode: BwrapNetworkMode, ) -> crate::bwrap::BwrapArgs { @@ -487,6 +511,7 @@ fn build_preflight_bwrap_argv( preflight_command, file_system_sandbox_policy, sandbox_policy_cwd, + command_cwd, BwrapOptions { mount_proc: true, network_mode, @@ -591,6 +616,7 @@ fn is_proc_mount_failure(stderr: &str) -> bool { struct InnerSeccompCommandArgs<'a> { sandbox_policy_cwd: &'a Path, + command_cwd: Option<&'a Path>, sandbox_policy: &'a SandboxPolicy, file_system_sandbox_policy: &'a FileSystemSandboxPolicy, network_sandbox_policy: NetworkSandboxPolicy, @@ -603,6 +629,7 @@ struct InnerSeccompCommandArgs<'a> { fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec { let InnerSeccompCommandArgs { sandbox_policy_cwd, + command_cwd, sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, @@ -631,6 +658,12 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec current_exe.to_string_lossy().to_string(), "--sandbox-policy-cwd".to_string(), sandbox_policy_cwd.to_string_lossy().to_string(), + ]; + if let Some(command_cwd) = command_cwd { + inner.push("--command-cwd".to_string()); + inner.push(command_cwd.to_string_lossy().to_string()); + } + inner.extend([ "--sandbox-policy".to_string(), policy_json, "--file-system-sandbox-policy".to_string(), @@ -638,7 +671,7 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec "--network-sandbox-policy".to_string(), network_policy_json, "--apply-seccomp-then-exec".to_string(), - ]; + ]); if allow_network_for_proxy { inner.push("--allow-network-for-proxy".to_string()); let proxy_route_spec = proxy_route_spec diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs index c5c29b381..b42d7e552 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -44,6 +44,7 @@ fn inserts_bwrap_argv0_before_command_separator() { vec!["/bin/true".to_string()], &FileSystemSandboxPolicy::from(&sandbox_policy), Path::new("/"), + Path::new("/"), BwrapOptions { mount_proc: true, network_mode: BwrapNetworkMode::FullAccess, @@ -80,6 +81,7 @@ fn inserts_unshare_net_when_network_isolation_requested() { vec!["/bin/true".to_string()], &FileSystemSandboxPolicy::from(&sandbox_policy), Path::new("/"), + Path::new("/"), BwrapOptions { mount_proc: true, network_mode: BwrapNetworkMode::Isolated, @@ -96,6 +98,7 @@ fn inserts_unshare_net_when_proxy_only_network_mode_requested() { vec!["/bin/true".to_string()], &FileSystemSandboxPolicy::from(&sandbox_policy), Path::new("/"), + Path::new("/"), BwrapOptions { mount_proc: true, network_mode: BwrapNetworkMode::ProxyOnly, @@ -163,6 +166,7 @@ fn root_write_read_only_carveout_requires_direct_runtime_enforcement() { fn managed_proxy_preflight_argv_is_wrapped_for_full_access_policy() { let mode = bwrap_network_mode(NetworkSandboxPolicy::Enabled, true); let argv = build_preflight_bwrap_argv( + Path::new("/"), Path::new("/"), &FileSystemSandboxPolicy::from(&SandboxPolicy::DangerFullAccess), mode, @@ -176,6 +180,7 @@ fn managed_proxy_inner_command_includes_route_spec() { let sandbox_policy = SandboxPolicy::new_read_only_policy(); let args = build_inner_seccomp_command(InnerSeccompCommandArgs { sandbox_policy_cwd: Path::new("/tmp"), + command_cwd: Some(Path::new("/tmp/link")), sandbox_policy: &sandbox_policy, file_system_sandbox_policy: &FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::Restricted, @@ -193,6 +198,7 @@ fn inner_command_includes_split_policy_flags() { let sandbox_policy = SandboxPolicy::new_read_only_policy(); let args = build_inner_seccomp_command(InnerSeccompCommandArgs { sandbox_policy_cwd: Path::new("/tmp"), + command_cwd: Some(Path::new("/tmp/link")), sandbox_policy: &sandbox_policy, file_system_sandbox_policy: &FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::Restricted, @@ -203,6 +209,10 @@ fn inner_command_includes_split_policy_flags() { assert!(args.iter().any(|arg| arg == "--file-system-sandbox-policy")); assert!(args.iter().any(|arg| arg == "--network-sandbox-policy")); + assert!( + args.windows(2) + .any(|window| { window == ["--command-cwd", "/tmp/link"] }) + ); } #[test] @@ -210,6 +220,7 @@ fn non_managed_inner_command_omits_route_spec() { let sandbox_policy = SandboxPolicy::new_read_only_policy(); let args = build_inner_seccomp_command(InnerSeccompCommandArgs { sandbox_policy_cwd: Path::new("/tmp"), + command_cwd: Some(Path::new("/tmp/link")), sandbox_policy: &sandbox_policy, file_system_sandbox_policy: &FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::Restricted, @@ -227,6 +238,7 @@ fn managed_proxy_inner_command_requires_route_spec() { let sandbox_policy = SandboxPolicy::new_read_only_policy(); build_inner_seccomp_command(InnerSeccompCommandArgs { sandbox_policy_cwd: Path::new("/tmp"), + command_cwd: Some(Path::new("/tmp/link")), sandbox_policy: &sandbox_policy, file_system_sandbox_policy: &FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::Restricted, diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index a485a9c16..590fbd1fe 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -1229,6 +1229,88 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn current_working_directory_special_path_canonicalizes_symlinked_cwd() { + let cwd = TempDir::new().expect("tempdir"); + let real_root = cwd.path().join("real"); + let link_root = cwd.path().join("link"); + let blocked = real_root.join("blocked"); + let agents_dir = real_root.join(".agents"); + let codex_dir = real_root.join(".codex"); + + fs::create_dir_all(&blocked).expect("create blocked"); + fs::create_dir_all(&agents_dir).expect("create .agents"); + fs::create_dir_all(&codex_dir).expect("create .codex"); + symlink_dir(&real_root, &link_root).expect("create symlinked cwd"); + + let link_blocked = + AbsolutePathBuf::from_absolute_path(link_root.join("blocked")).expect("link blocked"); + let expected_root = AbsolutePathBuf::from_absolute_path( + real_root.canonicalize().expect("canonicalize real root"), + ) + .expect("absolute canonical root"); + let expected_blocked = AbsolutePathBuf::from_absolute_path( + blocked.canonicalize().expect("canonicalize blocked"), + ) + .expect("absolute canonical blocked"); + let expected_agents = AbsolutePathBuf::from_absolute_path( + agents_dir.canonicalize().expect("canonicalize .agents"), + ) + .expect("absolute canonical .agents"); + let expected_codex = AbsolutePathBuf::from_absolute_path( + codex_dir.canonicalize().expect("canonicalize .codex"), + ) + .expect("absolute canonical .codex"); + + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: link_blocked }, + access: FileSystemAccessMode::None, + }, + ]); + + assert_eq!( + policy.get_readable_roots_with_cwd(&link_root), + vec![expected_root.clone()] + ); + assert_eq!( + policy.get_unreadable_roots_with_cwd(&link_root), + vec![expected_blocked.clone()] + ); + + let writable_roots = policy.get_writable_roots_with_cwd(&link_root); + assert_eq!(writable_roots.len(), 1); + assert_eq!(writable_roots[0].root, expected_root); + assert!( + writable_roots[0] + .read_only_subpaths + .contains(&expected_blocked) + ); + assert!( + writable_roots[0] + .read_only_subpaths + .contains(&expected_agents) + ); + assert!( + writable_roots[0] + .read_only_subpaths + .contains(&expected_codex) + ); + } + #[cfg(unix)] #[test] fn writable_roots_preserve_symlinked_protected_subpaths() {