Codex Handbook
exec-server/src/fs_sandbox.rs 685 lines
use std::collections::HashMap;use codex_app_server_protocol::JSONRPCErrorError;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::FileSystemSpecialPath;use codex_protocol::permissions::NetworkSandboxPolicy;use codex_sandboxing::SandboxCommand;use codex_sandboxing::SandboxExecRequest;use codex_sandboxing::SandboxManager;use codex_sandboxing::SandboxTransformRequest;use codex_sandboxing::SandboxablePreference;use codex_utils_absolute_path::AbsolutePathBuf;use codex_utils_absolute_path::canonicalize_preserving_symlinks;use codex_utils_path_uri::PathUri;use tokio::io::AsyncWriteExt;use tokio::process::Command;use crate::ExecServerRuntimePaths;use crate::FileSystemSandboxContext;use crate::fs_helper::CODEX_FS_HELPER_ARG1;use crate::fs_helper::FsHelperPayload;use crate::fs_helper::FsHelperRequest;use crate::fs_helper::FsHelperResponse;use crate::local_file_system::current_sandbox_cwd;use crate::rpc::internal_error;use crate::rpc::invalid_request;const FS_HELPER_ENV_ALLOWLIST: &[&str] = &["PATH", "TMPDIR", "TMP", "TEMP"];#[cfg(debug_assertions)]const FS_HELPER_BAZEL_BWRAP_ENV_ALLOWLIST: &[&str] = &[    "CARGO_BIN_EXE_bwrap",    "RUNFILES_DIR",    "RUNFILES_MANIFEST_FILE",    "RUNFILES_MANIFEST_ONLY",    "TEST_SRCDIR",    "TEST_WORKSPACE",];#[derive(Debug, PartialEq, Eq)]struct SandboxCwd {    uri: PathUri,    native: AbsolutePathBuf,}#[derive(Clone, Debug)]pub(crate) struct FileSystemSandboxRunner {    runtime_paths: ExecServerRuntimePaths,    helper_env: HashMap<String, String>,}impl FileSystemSandboxRunner {    pub(crate) fn new(runtime_paths: ExecServerRuntimePaths) -> Self {        Self {            runtime_paths,            helper_env: helper_env(),        }    }    pub(crate) async fn run(        &self,        sandbox: &FileSystemSandboxContext,        request: FsHelperRequest,    ) -> Result<FsHelperPayload, JSONRPCErrorError> {        let cwd = sandbox_cwd(sandbox)?;        let native_permissions: PermissionProfile =            sandbox.permissions.clone().try_into().map_err(|err| {                invalid_request(format!("invalid sandbox permission path URI: {err}"))            })?;        let mut file_system_policy = native_permissions.file_system_sandbox_policy();        let helper_read_roots = if sandbox.use_legacy_landlock {            Vec::new()        } else {            helper_read_roots(&self.runtime_paths)        };        add_helper_runtime_permissions(            &mut file_system_policy,            &helper_read_roots,            cwd.native.as_path(),        );        normalize_file_system_policy_root_aliases(&mut file_system_policy);        let network_policy = NetworkSandboxPolicy::Restricted;        let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement(            native_permissions.enforcement(),            &file_system_policy,            network_policy,        );        let command = self.sandbox_exec_request(&permission_profile, &cwd.uri, sandbox)?;        let request_json = serde_json::to_vec(&request).map_err(json_error)?;        run_command(command, request_json).await    }    fn sandbox_exec_request(        &self,        permission_profile: &PermissionProfile,        cwd: &PathUri,        sandbox_context: &FileSystemSandboxContext,    ) -> Result<SandboxExecRequest, JSONRPCErrorError> {        let helper = &self.runtime_paths.codex_self_exe;        let sandbox_manager = SandboxManager::new();        let (file_system_policy, network_policy) = permission_profile.to_runtime_permissions();        let sandbox = sandbox_manager.select_initial(            &file_system_policy,            network_policy,            SandboxablePreference::Auto,            sandbox_context.windows_sandbox_level,            /*has_managed_network_requirements*/ false,        );        let command = SandboxCommand {            program: helper.as_path().as_os_str().to_owned(),            args: vec![CODEX_FS_HELPER_ARG1.to_string()],            cwd: cwd.clone(),            env: self.helper_env.clone(),            additional_permissions: None,        };        sandbox_manager            .transform(SandboxTransformRequest {                command,                permissions: permission_profile,                sandbox,                enforce_managed_network: false,                network: None,                sandbox_policy_cwd: cwd,                codex_linux_sandbox_exe: self.runtime_paths.codex_linux_sandbox_exe.as_deref(),                use_legacy_landlock: sandbox_context.use_legacy_landlock,                windows_sandbox_level: sandbox_context.windows_sandbox_level,                windows_sandbox_private_desktop: sandbox_context.windows_sandbox_private_desktop,            })            .map_err(|err| invalid_request(format!("failed to prepare fs sandbox: {err}")))    }}fn sandbox_cwd(sandbox: &FileSystemSandboxContext) -> Result<SandboxCwd, JSONRPCErrorError> {    if let Some(uri) = &sandbox.cwd {        return Ok(SandboxCwd {            native: native_sandbox_cwd(uri)?,            uri: uri.clone(),        });    }    if sandbox.has_cwd_dependent_permissions() {        return Err(invalid_request(            "file system sandbox context with dynamic permissions requires cwd".to_string(),        ));    }    let native = AbsolutePathBuf::from_absolute_path(current_sandbox_cwd().map_err(io_error)?)        .map_err(|err| invalid_request(format!("current directory is not absolute: {err}")))?;    let uri = PathUri::from_abs_path(&native);    Ok(SandboxCwd { uri, native })}fn native_sandbox_cwd(cwd: &PathUri) -> Result<AbsolutePathBuf, JSONRPCErrorError> {    cwd.to_abs_path()        .map_err(|err| invalid_request(err.to_string()))}fn helper_read_roots(runtime_paths: &ExecServerRuntimePaths) -> Vec<AbsolutePathBuf> {    let mut roots = Vec::new();    for path in std::iter::once(runtime_paths.codex_self_exe.as_path())        .chain(runtime_paths.codex_linux_sandbox_exe.as_deref())    {        if let Some(parent) = path.parent()            && let Ok(root) = AbsolutePathBuf::from_absolute_path(parent)            && !roots.contains(&root)        {            roots.push(root);        }    }    roots}fn add_helper_runtime_permissions(    file_system_policy: &mut FileSystemSandboxPolicy,    helper_read_roots: &[AbsolutePathBuf],    cwd: &std::path::Path,) {    if !file_system_policy.has_full_disk_read_access() {        let minimal_read_entry = FileSystemSandboxEntry {            path: FileSystemPath::Special {                value: FileSystemSpecialPath::Minimal,            },            access: FileSystemAccessMode::Read,        };        if !file_system_policy.entries.contains(&minimal_read_entry) {            file_system_policy.entries.push(minimal_read_entry);        }    }    for helper_read_root in helper_read_roots {        if file_system_policy.can_read_path_with_cwd(helper_read_root.as_path(), cwd) {            continue;        }        file_system_policy.entries.push(FileSystemSandboxEntry {            path: FileSystemPath::Path {                path: helper_read_root.clone(),            },            access: FileSystemAccessMode::Read,        });    }}fn normalize_file_system_policy_root_aliases(file_system_policy: &mut FileSystemSandboxPolicy) {    for entry in &mut file_system_policy.entries {        if let FileSystemPath::Path { path } = &mut entry.path {            *path = normalize_top_level_alias(path.clone());        }    }}fn normalize_top_level_alias(path: AbsolutePathBuf) -> AbsolutePathBuf {    let raw_path = path.to_path_buf();    for ancestor in raw_path.ancestors() {        if std::fs::symlink_metadata(ancestor).is_err() {            continue;        }        let Ok(normalized_ancestor) = canonicalize_preserving_symlinks(ancestor) else {            continue;        };        if normalized_ancestor == ancestor {            continue;        }        let Ok(suffix) = raw_path.strip_prefix(ancestor) else {            continue;        };        if let Ok(normalized_path) =            AbsolutePathBuf::from_absolute_path(normalized_ancestor.join(suffix))        {            return normalized_path;        }    }    path}fn helper_env() -> HashMap<String, String> {    helper_env_from_vars(std::env::vars_os())}fn helper_env_from_vars(    vars: impl IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,) -> HashMap<String, String> {    vars.into_iter()        .filter_map(|(key, value)| {            let key = key.to_string_lossy();            helper_env_key_is_allowed(&key)                .then(|| (key.into_owned(), value.to_string_lossy().into_owned()))        })        .collect()}fn helper_env_key_is_allowed(key: &str) -> bool {    FS_HELPER_ENV_ALLOWLIST.contains(&key)        // CoreFoundation consults this before falling back to user lookup during helper startup.        || (cfg!(target_os = "macos") && key == "__CF_USER_TEXT_ENCODING")        || bazel_bwrap_env_key_is_allowed(key)        || (cfg!(windows) && key.eq_ignore_ascii_case("PATH"))}#[cfg(debug_assertions)]fn bazel_bwrap_env_key_is_allowed(key: &str) -> bool {    option_env!("BAZEL_PACKAGE").is_some() && FS_HELPER_BAZEL_BWRAP_ENV_ALLOWLIST.contains(&key)}#[cfg(not(debug_assertions))]fn bazel_bwrap_env_key_is_allowed(_key: &str) -> bool {    false}async fn run_command(    command: SandboxExecRequest,    request_json: Vec<u8>,) -> Result<FsHelperPayload, JSONRPCErrorError> {    let mut child = spawn_command(command)?;    let mut stdin = child        .stdin        .take()        .ok_or_else(|| internal_error("failed to open fs sandbox helper stdin".to_string()))?;    stdin.write_all(&request_json).await.map_err(io_error)?;    stdin.shutdown().await.map_err(io_error)?;    drop(stdin);    let output = child.wait_with_output().await.map_err(io_error)?;    if !output.status.success() {        return Err(internal_error(format!(            "fs sandbox helper failed with status {status}: {stderr}",            status = output.status,            stderr = String::from_utf8_lossy(&output.stderr).trim()        )));    }    let response: FsHelperResponse = serde_json::from_slice(&output.stdout).map_err(json_error)?;    match response {        FsHelperResponse::Ok(payload) => Ok(payload),        FsHelperResponse::Error(error) => Err(error),    }}fn spawn_command(    SandboxExecRequest {        command: argv,        cwd,        env,        arg0,        ..    }: SandboxExecRequest,) -> Result<tokio::process::Child, JSONRPCErrorError> {    let Some((program, args)) = argv.split_first() else {        return Err(invalid_request("fs sandbox command was empty".to_string()));    };    let mut command = Command::new(program);    #[cfg(unix)]    if let Some(arg0) = arg0 {        command.arg0(arg0);    }    #[cfg(not(unix))]    let _ = arg0;    command.args(args);    command.current_dir(cwd.as_path());    command.env_clear();    command.envs(env);    command.stdin(std::process::Stdio::piped());    command.stdout(std::process::Stdio::piped());    command.stderr(std::process::Stdio::piped());    command.kill_on_drop(true);    command.spawn().map_err(io_error)}fn io_error(err: std::io::Error) -> JSONRPCErrorError {    internal_error(err.to_string())}fn json_error(err: serde_json::Error) -> JSONRPCErrorError {    internal_error(format!(        "failed to encode or decode fs sandbox helper message: {err}"    ))}#[cfg(test)]mod tests {    use std::collections::HashMap;    use std::ffi::OsString;    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::FileSystemSpecialPath;    use codex_protocol::permissions::NetworkSandboxPolicy;    use codex_utils_absolute_path::AbsolutePathBuf;    use codex_utils_path_uri::PathUri;    use pretty_assertions::assert_eq;    use crate::ExecServerRuntimePaths;    use super::FileSystemSandboxRunner;    use super::SandboxCwd;    use super::add_helper_runtime_permissions;    use super::helper_env;    use super::helper_env_from_vars;    use super::helper_env_key_is_allowed;    use super::helper_read_roots;    use super::sandbox_cwd;    #[test]    fn helper_permissions_enable_minimal_reads_for_restricted_profile() {        let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path())            .expect("absolute cwd");        let mut policy = restricted_policy(Vec::new());        add_helper_runtime_permissions(&mut policy, /*helper_read_roots*/ &[], cwd.as_path());        assert!(policy.include_platform_defaults());    }    #[test]    fn helper_permissions_enable_minimal_reads_for_restricted_profile_with_writes() {        let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path())            .expect("absolute cwd");        let mut policy = restricted_policy(vec![path_entry(            cwd.join("writable"),            FileSystemAccessMode::Write,        )]);        add_helper_runtime_permissions(&mut policy, /*helper_read_roots*/ &[], cwd.as_path());        assert!(policy.include_platform_defaults());    }    #[test]    fn helper_permissions_preserve_existing_writes() {        let codex_self_exe = std::env::current_exe().expect("current exe");        let runtime_paths =            ExecServerRuntimePaths::new(codex_self_exe, /*codex_linux_sandbox_exe*/ None)                .expect("runtime paths");        let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path())            .expect("absolute cwd");        let writable = cwd.join("writable");        let mut policy = restricted_policy(vec![path_entry(            writable.clone(),            FileSystemAccessMode::Write,        )]);        let readable = AbsolutePathBuf::from_absolute_path(            runtime_paths                .codex_self_exe                .parent()                .expect("current exe parent"),        )        .expect("absolute readable path");        add_helper_runtime_permissions(            &mut policy,            &helper_read_roots(&runtime_paths),            cwd.as_path(),        );        assert!(policy.can_read_path_with_cwd(readable.as_path(), cwd.as_path()));        assert!(policy.can_write_path_with_cwd(writable.as_path(), cwd.as_path()));    }    #[test]    fn helper_env_carries_only_allowlisted_runtime_vars() {        let env = helper_env();        let expected = std::env::vars_os()            .filter_map(|(key, value)| {                let key = key.to_string_lossy();                helper_env_key_is_allowed(&key)                    .then(|| (key.into_owned(), value.to_string_lossy().into_owned()))            })            .collect::<HashMap<_, _>>();        assert_eq!(env, expected);    }    #[test]    fn helper_env_preserves_path_for_system_bwrap_discovery_without_leaking_secrets() {        let env = helper_env_from_vars(            [                ("PATH", "/usr/bin:/bin"),                ("TMPDIR", "/tmp/codex"),                ("TMP", "/tmp"),                ("TEMP", "/tmp"),                ("HOME", "/home/user"),                ("OPENAI_API_KEY", "secret"),                ("HTTPS_PROXY", "http://proxy.example"),            ]            .map(|(key, value)| (OsString::from(key), OsString::from(value))),        );        assert_eq!(            env,            HashMap::from([                ("PATH".to_string(), "/usr/bin:/bin".to_string()),                ("TMPDIR".to_string(), "/tmp/codex".to_string()),                ("TMP".to_string(), "/tmp".to_string()),                ("TEMP".to_string(), "/tmp".to_string()),            ])        );    }    #[cfg(target_os = "macos")]    #[test]    fn helper_env_preserves_corefoundation_text_encoding() {        let env = helper_env_from_vars(            [                ("__CF_USER_TEXT_ENCODING", "0x1F6:0x0:0x0"),                ("HOME", "/Users/test"),            ]            .map(|(key, value)| (OsString::from(key), OsString::from(value))),        );        assert_eq!(            env,            HashMap::from([(                "__CF_USER_TEXT_ENCODING".to_string(),                "0x1F6:0x0:0x0".to_string(),            )])        );    }    #[cfg(windows)]    #[test]    fn helper_env_preserves_windows_path_key_for_system_bwrap_discovery() {        let env = helper_env_from_vars(            [                ("Path", r"C:\Windows\System32"),                ("PATH_INJECTION", "bad"),                ("OPENAI_API_KEY", "secret"),            ]            .map(|(key, value)| (OsString::from(key), OsString::from(value))),        );        assert_eq!(            env,            HashMap::from([("Path".to_string(), r"C:\Windows\System32".to_string())])        );    }    #[test]    fn sandbox_exec_request_carries_helper_env() {        let Some((path_key, path)) = std::env::vars_os().find(|(key, _)| {            let key = key.to_string_lossy();            key == "PATH" || (cfg!(windows) && key.eq_ignore_ascii_case("PATH"))        }) else {            return;        };        let path_key = path_key.to_string_lossy().into_owned();        let path = path.to_string_lossy().into_owned();        let codex_self_exe = std::env::current_exe().expect("current exe");        let runtime_paths =            ExecServerRuntimePaths::new(codex_self_exe.clone(), Some(codex_self_exe))                .expect("runtime paths");        let runner = FileSystemSandboxRunner::new(runtime_paths);        let native_cwd = AbsolutePathBuf::current_dir().expect("cwd");        let cwd = PathUri::from_abs_path(&native_cwd);        let file_system_policy =            restricted_policy(vec![path_entry(native_cwd, FileSystemAccessMode::Write)]);        let network_policy = NetworkSandboxPolicy::Restricted;        let permission_profile =            PermissionProfile::from_runtime_permissions(&file_system_policy, network_policy);        let sandbox_context = sandbox_context_with_cwd(&file_system_policy, cwd.clone());        let request = runner            .sandbox_exec_request(&permission_profile, &cwd, &sandbox_context)            .expect("sandbox exec request");        assert_eq!(request.env.get(&path_key), Some(&path));    }    #[test]    fn sandbox_cwd_uses_context_cwd() {        let native_cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path())            .expect("absolute cwd");        let cwd = PathUri::from_abs_path(&native_cwd);        let policy = restricted_policy(vec![special_entry(            FileSystemSpecialPath::project_roots(/*subpath*/ None),            FileSystemAccessMode::Write,        )]);        let sandbox_context = sandbox_context_with_cwd(&policy, cwd.clone());        assert_eq!(            sandbox_cwd(&sandbox_context).expect("sandbox cwd"),            SandboxCwd {                uri: cwd,                native: native_cwd            }        );    }    #[test]    fn sandbox_cwd_rejects_non_native_context_cwd_without_fallback() {        let cwd = non_native_cwd();        let policy = restricted_policy(vec![special_entry(            FileSystemSpecialPath::project_roots(/*subpath*/ None),            FileSystemAccessMode::Write,        )]);        let sandbox_context = sandbox_context_with_cwd(&policy, cwd.clone());        let err = sandbox_cwd(&sandbox_context).expect_err("non-native cwd should be rejected");        assert_eq!(            err,            crate::rpc::invalid_request(format!(                "'{cwd}' is invalid on '{}'",                std::env::consts::OS            ))        );    }    #[test]    fn sandbox_cwd_rejects_cwd_dependent_profile_without_context_cwd() {        let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {            path: FileSystemPath::Special {                value: FileSystemSpecialPath::project_roots(/*subpath*/ None),            },            access: FileSystemAccessMode::Write,        }]);        let sandbox_context = codex_file_system::FileSystemSandboxContext::from_permission_profile(            PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),        );        let err = sandbox_cwd(&sandbox_context).expect_err("missing cwd should be rejected");        assert_eq!(            err.message,            "file system sandbox context with dynamic permissions requires cwd"        );    }    #[test]    fn helper_permissions_include_helper_read_root_without_additional_permissions() {        let codex_self_exe = std::env::current_exe().expect("current exe");        let runtime_paths =            ExecServerRuntimePaths::new(codex_self_exe, /*codex_linux_sandbox_exe*/ None)                .expect("runtime paths");        let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path())            .expect("absolute cwd");        let mut policy = restricted_policy(Vec::new());        let readable = AbsolutePathBuf::from_absolute_path(            runtime_paths                .codex_self_exe                .parent()                .expect("current exe parent"),        )        .expect("absolute readable path");        add_helper_runtime_permissions(            &mut policy,            &helper_read_roots(&runtime_paths),            cwd.as_path(),        );        assert!(policy.can_read_path_with_cwd(readable.as_path(), cwd.as_path()));    }    #[test]    fn helper_permissions_include_linux_sandbox_alias_parent() {        let root = tempfile::tempdir().expect("temp dir");        let codex_self_exe = root.path().join("bin").join("codex");        let codex_linux_sandbox_exe = root.path().join("aliases").join("codex-linux-sandbox");        let runtime_paths =            ExecServerRuntimePaths::new(codex_self_exe, Some(codex_linux_sandbox_exe))                .expect("runtime paths");        let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path())            .expect("absolute cwd");        let mut policy = restricted_policy(Vec::new());        let codex_parent = AbsolutePathBuf::from_absolute_path(root.path().join("bin"))            .expect("absolute codex parent");        let alias_parent = AbsolutePathBuf::from_absolute_path(root.path().join("aliases"))            .expect("absolute alias parent");        add_helper_runtime_permissions(            &mut policy,            &helper_read_roots(&runtime_paths),            cwd.as_path(),        );        assert!(policy.can_read_path_with_cwd(codex_parent.as_path(), cwd.as_path()));        assert!(policy.can_read_path_with_cwd(alias_parent.as_path(), cwd.as_path()));    }    fn restricted_policy(entries: Vec<FileSystemSandboxEntry>) -> FileSystemSandboxPolicy {        FileSystemSandboxPolicy::restricted(entries)    }    fn sandbox_context_with_cwd(        policy: &FileSystemSandboxPolicy,        cwd: PathUri,    ) -> crate::FileSystemSandboxContext {        codex_file_system::FileSystemSandboxContext::from_permission_profile_with_cwd(            PermissionProfile::from_runtime_permissions(policy, NetworkSandboxPolicy::Restricted),            cwd,        )    }    fn non_native_cwd() -> PathUri {        #[cfg(unix)]        let uri = "file://server/share/checkout";        #[cfg(windows)]        let uri = "file:///usr/local/checkout";        PathUri::parse(uri).expect("non-native cwd URI")    }    fn path_entry(path: AbsolutePathBuf, access: FileSystemAccessMode) -> FileSystemSandboxEntry {        FileSystemSandboxEntry {            path: FileSystemPath::Path { path },            access,        }    }    fn special_entry(        value: FileSystemSpecialPath,        access: FileSystemAccessMode,    ) -> FileSystemSandboxEntry {        FileSystemSandboxEntry {            path: FileSystemPath::Special { value },            access,        }    }}