diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a3000c9ddc..2f4ba2533a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3305,6 +3305,7 @@ dependencies = [ "landlock", "libc", "pretty_assertions", + "rustix 1.1.4", "seccompiler", "serde", "serde_json", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index aa755491ee..1b17115f89 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -381,6 +381,7 @@ regex-lite = "0.1.8" reqwest = { version = "0.12", features = ["cookies"] } rmcp = { version = "1.8.0", default-features = false } runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" } +rustix = { version = "1.1.4", features = ["thread"] } rustls = { version = "0.23", default-features = false, features = [ "aws_lc_rs", "std", diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index 5c91d2edc9..9502f9113a 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -29,6 +29,7 @@ globset = { workspace = true } hickory-proto = { workspace = true } landlock = { workspace = true } libc = { workspace = true } +rustix = { workspace = true } seccompiler = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/linux-sandbox/README.md b/codex-rs/linux-sandbox/README.md index 07c6796470..ce2b6fbe93 100644 --- a/codex-rs/linux-sandbox/README.md +++ b/codex-rs/linux-sandbox/README.md @@ -88,6 +88,10 @@ commands that would enter the bubblewrap path. - In managed proxy mode, the helper uses `--unshare-net` plus an internal TCP->UDS->TCP routing bridge so tool traffic reaches only configured proxy endpoints. +- When a managed domain policy is active, a loopback resolver answers only + permitted names through the host resolver while direct DNS egress remains + isolated. The policy is snapshotted when the command starts, and canonical + names are returned only when they are also permitted. - In managed proxy mode, after the bridge is live, seccomp blocks new AF_UNIX/socketpair creation for the user command. - When bubblewrap is active, it mounts a fresh `/proc` via `--proc /proc` by default, but diff --git a/codex-rs/linux-sandbox/src/dns_setup.rs b/codex-rs/linux-sandbox/src/dns_setup.rs new file mode 100644 index 0000000000..9066e582bb --- /dev/null +++ b/codex-rs/linux-sandbox/src/dns_setup.rs @@ -0,0 +1,162 @@ +use crate::bwrap::BwrapArgs; +use crate::proxy_routing::LoaderEnvironment; +use codex_protocol::permissions::ReadDenyMatcher; +use codex_protocol::protocol::FileSystemSandboxPolicy; +use rustix::thread::CapabilitySet; +use rustix::thread::CapabilitySets; +use rustix::thread::capabilities; +use rustix::thread::capability_is_in_ambient_set; +use rustix::thread::capability_is_in_bounding_set; +use rustix::thread::clear_ambient_capability_set; +use rustix::thread::remove_capability_from_bounding_set; +use rustix::thread::set_capabilities; +use std::fs::File; +use std::io; +use std::io::Seek; +use std::io::SeekFrom; +use std::io::Write; +use std::os::fd::AsRawFd; +use std::os::fd::FromRawFd; +use std::path::Path; +use std::path::PathBuf; + +const RESOLV_CONF_PATH: &str = "/etc/resolv.conf"; +const LOADER_ENV_KEYS: &[&str] = &["LD_AUDIT", "LD_LIBRARY_PATH", "LD_PRELOAD"]; + +#[derive(Debug)] +pub(crate) struct ResolvConfMount { + pub(crate) file: File, + pub(crate) path: PathBuf, +} + +pub(crate) fn create_resolv_conf_file() -> io::Result { + let fd = unsafe { libc::memfd_create(c"codex-resolv-conf".as_ptr(), libc::MFD_CLOEXEC) }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + let mut file = unsafe { File::from_raw_fd(fd) }; + file.write_all(b"nameserver 127.0.0.1\n")?; + file.seek(SeekFrom::Start(0))?; + Ok(file) +} + +pub(crate) fn resolv_conf_mount_path( + policy: &FileSystemSandboxPolicy, + cwd: &Path, +) -> io::Result { + let logical_path = Path::new(RESOLV_CONF_PATH); + let target_path = logical_path.canonicalize()?; + ensure_resolver_paths_allowed(policy, cwd, logical_path, &target_path)?; + Ok(target_path) +} + +pub(crate) fn ensure_resolver_paths_allowed( + policy: &FileSystemSandboxPolicy, + cwd: &Path, + logical_path: &Path, + target_path: &Path, +) -> io::Result<()> { + let deny_matcher = ReadDenyMatcher::new(policy, cwd); + if [logical_path, target_path].into_iter().any(|path| { + !policy.can_read_path_with_cwd(path, cwd) + || deny_matcher + .as_ref() + .is_some_and(|deny| deny.is_read_denied(path)) + }) { + return Err(io::Error::from(io::ErrorKind::PermissionDenied)); + } + Ok(()) +} + +pub(crate) fn capture_loader_environment() -> LoaderEnvironment { + LOADER_ENV_KEYS + .iter() + .filter_map(|&key| { + std::env::var_os(key).map(|value| { + ( + key.to_string(), + value + .into_string() + .unwrap_or_else(|_| panic!("{key} must contain valid UTF-8")), + ) + }) + }) + .collect() +} + +pub(crate) fn restore_loader_environment(environment: LoaderEnvironment) { + for (key, value) in environment { + // SAFETY: the setup process is single-threaded and has dropped all capabilities. + unsafe { std::env::set_var(key, value) }; + } +} + +pub(crate) fn append_bwrap_args(bwrap_args: &mut BwrapArgs, mount: ResolvConfMount) { + let separator = bwrap_args + .args + .iter() + .position(|arg| arg == "--") + .unwrap_or_else(|| panic!("bubblewrap argv is missing command separator '--'")); + let Some(parent) = mount.path.parent() else { + panic!("resolver configuration path has no parent"); + }; + let fd = mount.file.as_raw_fd().to_string(); + bwrap_args.args.splice( + separator..separator, + LOADER_ENV_KEYS + .iter() + .flat_map(|key| ["--unsetenv".to_string(), (*key).to_string()]) + .chain([ + "--cap-drop".to_string(), + "ALL".to_string(), + "--cap-add".to_string(), + "CAP_NET_BIND_SERVICE".to_string(), + "--cap-add".to_string(), + "CAP_SETPCAP".to_string(), + "--dir".to_string(), + parent.to_string_lossy().into_owned(), + "--perms".to_string(), + "444".to_string(), + "--ro-bind-data".to_string(), + fd, + mount.path.to_string_lossy().into_owned(), + ]), + ); + bwrap_args.preserved_files.push(mount.file); +} + +pub(crate) fn drop_and_verify_capabilities() -> io::Result<()> { + let ignore_unknown = |result: rustix::io::Result<()>| match result { + Ok(()) | Err(rustix::io::Errno::INVAL) => Ok(()), + Err(err) => Err(io::Error::from_raw_os_error(err.raw_os_error())), + }; + ignore_unknown(clear_ambient_capability_set())?; + for bit in 0..u64::BITS { + let capability = CapabilitySet::from_bits_retain(1_u64 << bit); + match capability_is_in_ambient_set(capability) { + Ok(false) | Err(rustix::io::Errno::INVAL) => {} + Ok(true) => return Err(io::Error::other("capability remained in ambient set")), + Err(err) => return Err(io::Error::from_raw_os_error(err.raw_os_error())), + } + ignore_unknown(remove_capability_from_bounding_set(capability))?; + match capability_is_in_bounding_set(capability) { + Ok(false) | Err(rustix::io::Errno::INVAL) => {} + Ok(true) => return Err(io::Error::other("capability remained in bounding set")), + Err(err) => return Err(io::Error::from_raw_os_error(err.raw_os_error())), + } + } + let empty = CapabilitySets { + effective: CapabilitySet::empty(), + permitted: CapabilitySet::empty(), + inheritable: CapabilitySet::empty(), + }; + set_capabilities(None, empty).map_err(io::Error::from)?; + if capabilities(None).map_err(io::Error::from)? != empty { + return Err(io::Error::other("capabilities remained after DNS setup")); + } + Ok(()) +} + +#[cfg(test)] +#[path = "dns_setup_tests.rs"] +mod tests; diff --git a/codex-rs/linux-sandbox/src/dns_setup_tests.rs b/codex-rs/linux-sandbox/src/dns_setup_tests.rs new file mode 100644 index 0000000000..76e5bafe08 --- /dev/null +++ b/codex-rs/linux-sandbox/src/dns_setup_tests.rs @@ -0,0 +1,76 @@ +use super::*; +use crate::bwrap::BwrapArgs; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; + +#[test] +fn unsets_loader_environment_before_inner_command() { + let mut bwrap_args = BwrapArgs { + args: vec![ + "bwrap".to_string(), + "--".to_string(), + "/bin/true".to_string(), + ], + preserved_files: Vec::new(), + synthetic_mount_targets: Vec::new(), + protected_create_targets: Vec::new(), + }; + append_bwrap_args( + &mut bwrap_args, + ResolvConfMount { + file: tempfile::tempfile().expect("temporary resolver configuration"), + path: PathBuf::from("/etc/resolv.conf"), + }, + ); + + let separator = bwrap_args + .args + .iter() + .position(|arg| arg == "--") + .expect("command separator"); + let setup_args = &bwrap_args.args[..separator]; + for key in ["LD_AUDIT", "LD_LIBRARY_PATH", "LD_PRELOAD"] { + assert!( + setup_args + .windows(2) + .any(|args| args == ["--unsetenv", key]), + "{key} must be unset before the privileged DNS setup command" + ); + } +} + +#[test] +fn rejects_read_denied_resolver_paths() { + let logical_path = PathBuf::from("/etc/resolv.conf"); + let target_path = PathBuf::from("/run/systemd/resolve/stub-resolv.conf"); + for denied_path in [&logical_path, &target_path] { + let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: AbsolutePathBuf::from_absolute_path(denied_path.clone()) + .expect("absolute resolver configuration"), + }, + access: FileSystemAccessMode::Deny, + }, + ]); + + let err = ensure_resolver_paths_allowed( + &file_system_sandbox_policy, + Path::new("/"), + &logical_path, + &target_path, + ) + .expect_err("read-denied resolver configuration must be rejected"); + assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); + } +} diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs index 3d6820ca56..488ff40e60 100644 --- a/codex-rs/linux-sandbox/src/lib.rs +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -12,6 +12,8 @@ mod bwrap; #[cfg(target_os = "linux")] mod dns_routing; #[cfg(target_os = "linux")] +mod dns_setup; +#[cfg(target_os = "linux")] mod exec_util; #[cfg(target_os = "linux")] mod landlock; diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 346b1f14c0..5568d72693 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -5,7 +5,6 @@ use std::fs; use std::fs::File; use std::fs::OpenOptions; use std::io::Read; -use std::os::fd::AsRawFd; use std::os::fd::FromRawFd; use std::os::unix::ffi::OsStrExt; use std::path::Path; @@ -20,11 +19,23 @@ use std::time::Duration; use crate::bwrap::BwrapNetworkMode; use crate::bwrap::BwrapOptions; use crate::bwrap::create_bwrap_command_args; +use crate::dns_setup::ResolvConfMount; +use crate::dns_setup::append_bwrap_args as append_dns_setup_bwrap_args; +use crate::dns_setup::capture_loader_environment; +use crate::dns_setup::create_resolv_conf_file; +use crate::dns_setup::drop_and_verify_capabilities; +use crate::dns_setup::resolv_conf_mount_path; +use crate::dns_setup::restore_loader_environment as apply_loader_environment; use crate::landlock::apply_permission_profile_to_current_thread; use crate::launcher::exec_bwrap; use crate::launcher::preferred_bwrap_supports_argv0; +use crate::proxy_routing::DnsRouteConfig; use crate::proxy_routing::activate_proxy_routes_in_netns; +use crate::proxy_routing::activate_proxy_routes_with_dns_in_netns; +use crate::proxy_routing::bind_dns_route_in_netns; use crate::proxy_routing::prepare_host_proxy_route_spec; +use crate::proxy_routing::prepare_host_proxy_routes; +use codex_network_proxy::ManagedNetworkDomainPolicy; use codex_protocol::error::Result as CodexResult; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::FileSystemSandboxPolicy; @@ -122,6 +133,14 @@ pub struct LandlockCommand { #[arg(long = "allow-network-for-proxy", hide = true, default_value_t = false)] pub allow_network_for_proxy: bool, + /// Internal domain policy for DNS routed through the managed proxy bridge. + #[arg( + long = "dns-domain-policy", + hide = true, + value_parser = parse_dns_domain_policy + )] + pub dns_domain_policy: Option, + /// Internal route spec used for managed proxy routing in bwrap mode. #[arg(long = "proxy-route-spec", hide = true)] pub proxy_route_spec: Option, @@ -152,6 +171,7 @@ pub fn run_main() -> ! { use_legacy_landlock, apply_seccomp_then_exec, allow_network_for_proxy, + dns_domain_policy, proxy_route_spec, no_proc, command, @@ -160,6 +180,9 @@ pub fn run_main() -> ! { if command.is_empty() { panic!("No command specified to execute."); } + if dns_domain_policy.is_some() && (!allow_network_for_proxy || use_legacy_landlock) { + panic!("--dns-domain-policy requires managed proxy mode with bubblewrap"); + } ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_legacy_landlock); let EffectivePermissions { permission_profile, @@ -180,7 +203,20 @@ pub fn run_main() -> ! { let spec = proxy_route_spec .as_deref() .unwrap_or_else(|| panic!("managed proxy mode requires --proxy-route-spec")); - if let Err(err) = activate_proxy_routes_in_netns(spec) { + let bound_dns = bind_dns_route_in_netns(spec) + .unwrap_or_else(|err| panic!("error binding Linux DNS bridge: {err}")); + let bound_dns_stub = bound_dns.map(|route| { + drop_and_verify_capabilities().unwrap_or_else(|err| { + panic!("error dropping Linux DNS setup capabilities: {err}") + }); + apply_loader_environment(route.loader_environment); + route.stub + }); + let activation = match bound_dns_stub { + Some(stub) => activate_proxy_routes_with_dns_in_netns(spec, Some(stub)), + None => activate_proxy_routes_in_netns(spec), + }; + if let Err(err) = activation { panic!("error activating Linux proxy routing bridge: {err}"); } } @@ -214,14 +250,36 @@ pub fn run_main() -> ! { // Outer stage: bubblewrap first, then re-enter this binary in the // sandboxed environment to apply seccomp. This path never falls back // to legacy Landlock on failure. - let proxy_route_spec = - if allow_network_for_proxy { - Some(prepare_host_proxy_route_spec().unwrap_or_else(|err| { - panic!("failed to prepare host proxy routing bridge: {err}") + let dns_resolv_conf = dns_domain_policy.as_ref().map(|_| ResolvConfMount { + file: create_resolv_conf_file() + .unwrap_or_else(|err| panic!("failed to create resolver configuration: {err}")), + path: resolv_conf_mount_path(&file_system_sandbox_policy, &sandbox_policy_cwd) + .unwrap_or_else(|err| panic!("failed to prepare resolver configuration: {err}")), + }); + let prepared_proxy_routes = allow_network_for_proxy.then(|| { + if let Some(policy) = dns_domain_policy.as_ref() { + prepare_host_proxy_routes(Some(DnsRouteConfig { + policy, + loader_environment: capture_loader_environment(), })) } else { - None - }; + prepare_host_proxy_route_spec().map(|serialized_spec| { + crate::proxy_routing::PreparedProxyRoutes { + serialized_spec, + dns_relay_files: Vec::new(), + } + }) + } + .unwrap_or_else(|err| panic!("failed to prepare host proxy routing bridge: {err}")) + }); + let (proxy_route_spec, dns_resolv_conf, dns_relay_files) = match prepared_proxy_routes { + Some(prepared) => ( + Some(prepared.serialized_spec), + dns_resolv_conf, + prepared.dns_relay_files, + ), + None => (None, dns_resolv_conf, Vec::new()), + }; let inner = build_inner_seccomp_command(InnerSeccompCommandArgs { sandbox_policy_cwd: &sandbox_policy_cwd, command_cwd: command_cwd.as_deref(), @@ -238,6 +296,8 @@ pub fn run_main() -> ! { inner, !no_proc, allow_network_for_proxy, + dns_resolv_conf, + dns_relay_files, ); } @@ -278,6 +338,10 @@ fn parse_permission_profile(value: &str) -> std::result::Result std::result::Result { + serde_json::from_str(value).map_err(|err| format!("invalid DNS domain policy JSON: {err}")) +} + fn resolve_permission_profile( permission_profile: Option, ) -> Result { @@ -322,6 +386,8 @@ fn run_bwrap_with_proc_fallback( inner: Vec, mount_proc: bool, allow_network_for_proxy: bool, + dns_resolv_conf: Option, + dns_relay_files: Vec, ) -> ! { let network_mode = bwrap_network_mode(network_sandbox_policy, allow_network_for_proxy); let mut mount_proc = mount_proc; @@ -354,6 +420,10 @@ fn run_bwrap_with_proc_fallback( options, ) .unwrap_or_else(|err| exit_with_bwrap_build_error(err)); + if let Some(resolv_conf) = dns_resolv_conf { + append_dns_setup_bwrap_args(&mut bwrap_args, resolv_conf); + } + bwrap_args.preserved_files.extend(dns_relay_files); apply_inner_command_argv0(&mut bwrap_args.args); run_or_exec_bwrap(bwrap_args); } diff --git a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs index 6c9a6a0c67..a860db5cc4 100644 --- a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs +++ b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs @@ -6,6 +6,7 @@ use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::models::PermissionProfile; use pretty_assertions::assert_eq; use std::collections::HashMap; +use std::fs; use std::io::Read; use std::io::Write; use std::net::Ipv4Addr; @@ -59,6 +60,18 @@ fn is_bwrap_unavailable_output(output: &Output) -> bool { String::from_utf8_lossy(&output.stderr).contains(BWRAP_UNAVAILABLE_ERR) } +async fn command_available(program: &str) -> bool { + matches!( + Command::new(program) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await, + Ok(status) if status.success() + ) +} + async fn should_skip_bwrap_tests() -> bool { let mut env = create_env_from_core_vars(); strip_proxy_env(&mut env); @@ -67,6 +80,7 @@ async fn should_skip_bwrap_tests() -> bool { &["bash", "-c", "true"], &PermissionProfile::read_only(), /*allow_network_for_proxy*/ false, + /*dns_domain_policy*/ None, env, NETWORK_TIMEOUT_MS, ) @@ -93,6 +107,7 @@ async fn managed_proxy_skip_reason() -> Option { &["bash", "-c", "true"], &PermissionProfile::Disabled, /*allow_network_for_proxy*/ true, + /*dns_domain_policy*/ None, env, NETWORK_TIMEOUT_MS, ) @@ -116,6 +131,7 @@ async fn run_linux_sandbox_direct( command: &[&str], permission_profile: &PermissionProfile, allow_network_for_proxy: bool, + dns_domain_policy: Option<&str>, env: HashMap, timeout_ms: u64, ) -> Output { @@ -132,6 +148,9 @@ async fn run_linux_sandbox_direct( if allow_network_for_proxy { args.push("--allow-network-for-proxy".to_string()); } + if let Some(policy) = dns_domain_policy { + args.extend(["--dns-domain-policy".to_string(), policy.to_string()]); + } args.push("--".to_string()); args.extend(command.iter().map(|entry| (*entry).to_string())); @@ -163,6 +182,7 @@ async fn managed_proxy_mode_fails_closed_without_proxy_env() { &["bash", "-c", "true"], &PermissionProfile::Disabled, /*allow_network_for_proxy*/ true, + /*dns_domain_policy*/ None, env, NETWORK_TIMEOUT_MS, ) @@ -218,6 +238,7 @@ async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() { ], &PermissionProfile::Disabled, /*allow_network_for_proxy*/ true, + /*dns_domain_policy*/ None, env.clone(), NETWORK_TIMEOUT_MS, ) @@ -249,6 +270,7 @@ async fn managed_proxy_mode_routes_through_bridge_and_blocks_direct_egress() { &["bash", "-c", "echo hi > /dev/tcp/192.0.2.1/80"], &PermissionProfile::Disabled, /*allow_network_for_proxy*/ true, + /*dns_domain_policy*/ None, env, NETWORK_TIMEOUT_MS, ) @@ -263,14 +285,7 @@ async fn managed_proxy_mode_denies_af_unix_socket_but_allows_socketpair() { return; } - let python_available = Command::new("bash") - .arg("-c") - .arg("command -v python3 >/dev/null") - .status() - .await - .expect("python3 probe should execute") - .success(); - if !python_available { + if !command_available("python3").await { eprintln!("skipping managed proxy AF_UNIX test: python3 is unavailable"); return; } @@ -287,6 +302,7 @@ async fn managed_proxy_mode_denies_af_unix_socket_but_allows_socketpair() { ], &PermissionProfile::Disabled, /*allow_network_for_proxy*/ true, + /*dns_domain_policy*/ None, env, NETWORK_TIMEOUT_MS, ) @@ -301,3 +317,198 @@ async fn managed_proxy_mode_denies_af_unix_socket_but_allows_socketpair() { String::from_utf8_lossy(&output.stderr) ); } + +#[tokio::test] +async fn managed_dns_applies_domain_policy_and_drops_setup_capabilities() { + if managed_proxy_skip_reason().await.is_some() + || !command_available("python3").await + || !command_available("cc").await + { + return; + } + + let temp = tempfile::tempdir().expect("temporary loader-hook directory"); + let source = temp.path().join("cap_hook.c"); + let library = temp.path().join("cap_hook.so"); + let hook_log = temp.path().join("cap_hook.log"); + fs::write( + &source, + r#"#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +typedef int (*getaddrinfo_fn)(const char *, const char *, const struct addrinfo *, struct addrinfo **); +static int dns_name_equal(const char *node, const char *fixture) { + size_t fixture_len; + if (!node || !fixture) return 0; + fixture_len = strlen(fixture); + return !strcmp(node, fixture) || + (!strncmp(node, fixture, fixture_len) && node[fixture_len] == '.' && !node[fixture_len + 1]); +} +static const char *resolver_fixture_address(const char *node) { + const char *fixture4 = getenv("DNS_RESOLVER_FIXTURE4"); + const char *fixture6 = getenv("DNS_RESOLVER_FIXTURE6"); + const char *denied_canonical = getenv("DNS_RESOLVER_FIXTURE_DENIED_CANONICAL"); + char exe[4096]; + ssize_t len; + if (!dns_name_equal(node, fixture4) && !dns_name_equal(node, fixture6) && + !dns_name_equal(node, denied_canonical)) return NULL; + len = readlink("/proc/self/exe", exe, sizeof(exe) - 1); + if (len < 0) return NULL; + exe[len] = 0; + if (!strstr(exe, "codex-linux-sandbox")) return NULL; + if (dns_name_equal(node, fixture4)) return "192.0.2.53"; + if (dns_name_equal(node, fixture6)) return "2001:db8::53"; + return "192.0.2.54"; +} +int getaddrinfo(const char *node, const char *service, const struct addrinfo *hints, struct addrinfo **res) { + getaddrinfo_fn real = (getaddrinfo_fn)dlsym(RTLD_NEXT, "getaddrinfo"); + const char *address = resolver_fixture_address(node); + struct addrinfo numeric = {0}; + if (!real) return EAI_SYSTEM; + if (!address) return real(node, service, hints, res); + if (hints) { + numeric.ai_socktype = hints->ai_socktype; + numeric.ai_protocol = hints->ai_protocol; + } + numeric.ai_flags = AI_NUMERICHOST; + numeric.ai_family = strchr(address, ':') ? AF_INET6 : AF_INET; + int status = real(address, service, &numeric, res); + if (!status && hints && (hints->ai_flags & AI_CANONNAME) && *res) { + const char *canonical = dns_name_equal(node, getenv("DNS_RESOLVER_FIXTURE_DENIED_CANONICAL")) + ? "blocked.fixture.test" + : (strchr(address, ':') ? "canonical6.fixture.test" : "canonical.fixture.test"); + char *copy = strdup(canonical); + if (!copy) { freeaddrinfo(*res); *res = NULL; return EAI_MEMORY; } + (*res)->ai_canonname = copy; + } + return status; +} +__attribute__((constructor)) static void record_caps(void) { + const char *path = getenv("DNS_CAP_LOG"); + if (!path) return; + FILE *status = fopen("/proc/self/status", "r"); + char line[256], exe[4096]; + unsigned long long caps = 0; + while (status && fgets(line, sizeof(line), status)) + if (!strncmp(line, "Cap", 3)) caps |= strtoull(strchr(line, '\t') + 1, NULL, 16); + if (status) fclose(status); + ssize_t len = readlink("/proc/self/exe", exe, sizeof(exe) - 1); + if (len < 0) return; + exe[len] = 0; + FILE *out = fopen(path, "a"); + if (out) { fprintf(out, "%s %llx\n", exe, caps); fclose(out); } +} +"#, + ) + .expect("write loader hook"); + let build = Command::new("cc") + .args(["-shared", "-fPIC", "-o"]) + .arg(&library) + .arg(&source) + .arg("-ldl") + .output() + .await + .expect("compile loader hook"); + assert!( + build.status.success(), + "{}", + String::from_utf8_lossy(&build.stderr) + ); + + let mut env = create_env_from_core_vars(); + strip_proxy_env(&mut env); + env.insert("HTTP_PROXY".to_string(), "http://127.0.0.1:9".to_string()); + env.insert("LD_PRELOAD".to_string(), library.display().to_string()); + env.insert("DNS_CAP_LOG".to_string(), hook_log.display().to_string()); + env.insert( + "DNS_RESOLVER_FIXTURE4".to_string(), + "resolver.fixture.test".to_string(), + ); + env.insert( + "DNS_RESOLVER_FIXTURE6".to_string(), + "resolver6.fixture.test".to_string(), + ); + env.insert( + "DNS_RESOLVER_FIXTURE_DENIED_CANONICAL".to_string(), + "denied-canonical.fixture.test".to_string(), + ); + let policy = r#"{"allowedDomains":["localhost","**.fixture.test"],"deniedDomains":["blocked.fixture.test"]}"#; + let script = r#" +import socket, struct +assert open('/etc/resolv.conf').read() == 'nameserver 127.0.0.1\n' +def skip_name(wire, offset): + while True: + size = wire[offset] + offset += 1 + if size == 0: return offset + if size & 0xc0 == 0xc0: return offset + 1 + offset += size +def query(name, qtype=1, tcp=False): + labels = b''.join(bytes([len(part)]) + part.encode() for part in name.split('.')) + b'\0' + wire = struct.pack('!HHHHHH', 1, 0x100, 1, 0, 0, 0) + labels + struct.pack('!HH', qtype, 1) + if tcp: + with socket.create_connection(('127.0.0.1', 53)) as sock: + stream = sock.makefile('rwb') + stream.write(struct.pack('!H', len(wire)) + wire); stream.flush() + size = struct.unpack('!H', stream.read(2))[0] + reply = stream.read(size) + assert len(reply) == size + else: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.sendto(wire, ('127.0.0.1', 53)); reply = sock.recv(65535) + question_count, answer_count = struct.unpack('!HH', reply[4:8]) + offset = 12 + for _ in range(question_count): offset = skip_name(reply, offset) + 4 + answer_types = [] + for _ in range(answer_count): + offset = skip_name(reply, offset) + answer_types.append(struct.unpack('!H', reply[offset:offset + 2])[0]) + data_length = struct.unpack('!H', reply[offset + 8:offset + 10])[0] + offset += 10 + data_length + return reply[3] & 15, answer_types +assert query('resolver.fixture.test') == (0, [5, 1]) +assert query('resolver6.fixture.test', qtype=28) == (0, [5, 28]) +assert query('resolver.fixture.test', qtype=5) == (0, [5]) +assert query('resolver.fixture.test', tcp=True) == (0, [5, 1]) +assert query('denied-canonical.fixture.test') == (0, [1]) +assert query('denied-canonical.fixture.test', qtype=5) == (5, []) +assert query('blocked.fixture.test') == (5, []) +resolved = socket.getaddrinfo('resolver.fixture.test', 0, socket.AF_INET, 0, 0, socket.AI_CANONNAME) +assert {entry[4][0] for entry in resolved} == {'192.0.2.53'} +assert resolved[0][3] == 'canonical.fixture.test' +try: socket.getaddrinfo('blocked.fixture.test', 0) +except socket.gaierror: pass +else: raise AssertionError('denied name resolved') +with open('/proc/self/status') as status: + caps = [int(line.split()[1], 16) for line in status if line.startswith(('CapInh:', 'CapPrm:', 'CapEff:', 'CapBnd:', 'CapAmb:'))] +assert caps == [0] * 5, caps +"#; + let output = run_linux_sandbox_direct( + &["python3", "-c", script], + &PermissionProfile::Disabled, + /*allow_network_for_proxy*/ true, + /*dns_domain_policy*/ Some(policy), + env, + NETWORK_TIMEOUT_MS, + ) + .await; + + assert!( + output.status.success(), + "expected policy-checked DNS with no residual capabilities: {}", + String::from_utf8_lossy(&output.stderr) + ); + let hook_log = fs::read_to_string(&hook_log).expect("read loader-hook log"); + assert!( + hook_log.lines().any(|line| line.contains("python")), + "{hook_log}" + ); + assert!( + hook_log.lines().all(|line| line.ends_with(" 0")), + "loader hook ran with capabilities: {hook_log}" + ); +}