use std::borrow::Cow; use std::collections::BTreeMap; use std::io; use std::path::Component; use std::path::Path; use std::path::PathBuf; use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission; use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxyConfig; use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission; use codex_network_proxy::normalize_host; 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 schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct PermissionsToml { #[serde(flatten)] pub entries: BTreeMap, } impl PermissionsToml { pub fn is_empty(&self) -> bool { self.entries.is_empty() } } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct PermissionProfileToml { pub filesystem: Option, pub network: Option, } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct FilesystemPermissionsToml { #[serde(flatten)] pub entries: BTreeMap, } impl FilesystemPermissionsToml { pub fn is_empty(&self) -> bool { self.entries.is_empty() } } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[serde(untagged)] pub enum FilesystemPermissionToml { Access(FileSystemAccessMode), Scoped(BTreeMap), } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct NetworkDomainPermissionsToml { #[serde(flatten)] pub entries: BTreeMap, } impl NetworkDomainPermissionsToml { pub fn is_empty(&self) -> bool { self.entries.is_empty() } #[cfg(test)] pub(crate) fn allowed_domains(&self) -> Option> { let allowed_domains: Vec = self .entries .iter() .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow)) .map(|(pattern, _)| pattern.clone()) .collect(); (!allowed_domains.is_empty()).then_some(allowed_domains) } #[cfg(test)] pub(crate) fn denied_domains(&self) -> Option> { let denied_domains: Vec = self .entries .iter() .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny)) .map(|(pattern, _)| pattern.clone()) .collect(); (!denied_domains.is_empty()).then_some(denied_domains) } } #[derive( Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, )] #[serde(rename_all = "lowercase")] pub enum NetworkDomainPermissionToml { Allow, Deny, } impl std::fmt::Display for NetworkDomainPermissionToml { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let permission = match self { Self::Allow => "allow", Self::Deny => "deny", }; f.write_str(permission) } } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct NetworkUnixSocketPermissionsToml { #[serde(flatten)] pub entries: BTreeMap, } impl NetworkUnixSocketPermissionsToml { pub fn is_empty(&self) -> bool { self.entries.is_empty() } pub(crate) fn allow_unix_sockets(&self) -> Vec { self.entries .iter() .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow)) .map(|(path, _)| path.clone()) .collect() } } #[derive( Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema, )] #[serde(rename_all = "lowercase")] pub enum NetworkUnixSocketPermissionToml { Allow, None, } impl std::fmt::Display for NetworkUnixSocketPermissionToml { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let permission = match self { Self::Allow => "allow", Self::None => "none", }; f.write_str(permission) } } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct NetworkToml { pub enabled: Option, pub proxy_url: Option, pub enable_socks5: Option, pub socks_url: Option, pub enable_socks5_udp: Option, pub allow_upstream_proxy: Option, pub dangerously_allow_non_loopback_proxy: Option, pub dangerously_allow_all_unix_sockets: Option, #[schemars(with = "Option")] pub mode: Option, pub domains: Option, pub unix_sockets: Option, pub allow_local_binding: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "lowercase")] enum NetworkModeSchema { Limited, Full, } impl NetworkToml { pub(crate) fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) { if let Some(enabled) = self.enabled { config.network.enabled = enabled; } if let Some(proxy_url) = self.proxy_url.as_ref() { config.network.proxy_url = proxy_url.clone(); } if let Some(enable_socks5) = self.enable_socks5 { config.network.enable_socks5 = enable_socks5; } if let Some(socks_url) = self.socks_url.as_ref() { config.network.socks_url = socks_url.clone(); } if let Some(enable_socks5_udp) = self.enable_socks5_udp { config.network.enable_socks5_udp = enable_socks5_udp; } if let Some(allow_upstream_proxy) = self.allow_upstream_proxy { config.network.allow_upstream_proxy = allow_upstream_proxy; } if let Some(dangerously_allow_non_loopback_proxy) = self.dangerously_allow_non_loopback_proxy { config.network.dangerously_allow_non_loopback_proxy = dangerously_allow_non_loopback_proxy; } if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets { config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets; } if let Some(mode) = self.mode { config.network.mode = mode; } if let Some(domains) = self.domains.as_ref() { overlay_network_domain_permissions(config, domains); } if let Some(unix_sockets) = self.unix_sockets.as_ref() { let mut proxy_unix_sockets = config.network.unix_sockets.take().unwrap_or_default(); for (path, permission) in &unix_sockets.entries { let permission = match permission { NetworkUnixSocketPermissionToml::Allow => { ProxyNetworkUnixSocketPermission::Allow } NetworkUnixSocketPermissionToml::None => ProxyNetworkUnixSocketPermission::None, }; proxy_unix_sockets.entries.insert(path.clone(), permission); } config.network.unix_sockets = (!proxy_unix_sockets.entries.is_empty()).then_some(proxy_unix_sockets); } if let Some(allow_local_binding) = self.allow_local_binding { config.network.allow_local_binding = allow_local_binding; } } pub(crate) fn to_network_proxy_config(&self) -> NetworkProxyConfig { let mut config = NetworkProxyConfig::default(); self.apply_to_network_proxy_config(&mut config); config } } pub(crate) fn overlay_network_domain_permissions( config: &mut NetworkProxyConfig, domains: &NetworkDomainPermissionsToml, ) { for (pattern, permission) in &domains.entries { let permission = match permission { NetworkDomainPermissionToml::Allow => ProxyNetworkDomainPermission::Allow, NetworkDomainPermissionToml::Deny => ProxyNetworkDomainPermission::Deny, }; config .network .upsert_domain_permission(pattern.clone(), permission, normalize_host); } } pub(crate) fn network_proxy_config_from_profile_network( network: Option<&NetworkToml>, ) -> NetworkProxyConfig { network.map_or_else( NetworkProxyConfig::default, NetworkToml::to_network_proxy_config, ) } pub(crate) fn resolve_permission_profile<'a>( permissions: &'a PermissionsToml, profile_name: &str, ) -> io::Result<&'a PermissionProfileToml> { permissions.entries.get(profile_name).ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidInput, format!("default_permissions refers to undefined profile `{profile_name}`"), ) }) } pub(crate) fn compile_permission_profile( permissions: &PermissionsToml, profile_name: &str, startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { let profile = resolve_permission_profile(permissions, profile_name)?; let mut entries = Vec::new(); if let Some(filesystem) = profile.filesystem.as_ref() { if filesystem.is_empty() { push_warning( startup_warnings, missing_filesystem_entries_warning(profile_name), ); } else { for (path, permission) in &filesystem.entries { compile_filesystem_permission(path, permission, &mut entries, startup_warnings)?; } } } else { push_warning( startup_warnings, missing_filesystem_entries_warning(profile_name), ); } let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref()); Ok(( FileSystemSandboxPolicy::restricted(entries), network_sandbox_policy, )) } /// Returns a list of paths that must be readable by shell tools in order /// for Codex to function. These should always be added to the /// `FileSystemSandboxPolicy` for a thread. pub(crate) fn get_readable_roots_required_for_codex_runtime( codex_home: &Path, zsh_path: Option<&PathBuf>, main_execve_wrapper_exe: Option<&PathBuf>, ) -> Vec { let arg0_root = AbsolutePathBuf::from_absolute_path(codex_home.join("tmp").join("arg0")).ok(); let zsh_path = zsh_path.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()); let execve_wrapper_root = main_execve_wrapper_exe.and_then(|path| { let path = AbsolutePathBuf::from_absolute_path(path).ok()?; if let Some(arg0_root) = arg0_root.as_ref() && path.as_path().starts_with(arg0_root.as_path()) { path.parent() } else { Some(path) } }); let mut readable_roots = Vec::new(); if let Some(zsh_path) = zsh_path { readable_roots.push(zsh_path); } if let Some(execve_wrapper_root) = execve_wrapper_root { readable_roots.push(execve_wrapper_root); } readable_roots } fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy { let Some(network) = network else { return NetworkSandboxPolicy::Restricted; }; match network.enabled { Some(true) => NetworkSandboxPolicy::Enabled, _ => NetworkSandboxPolicy::Restricted, } } fn compile_filesystem_permission( path: &str, permission: &FilesystemPermissionToml, entries: &mut Vec, startup_warnings: &mut Vec, ) -> io::Result<()> { match permission { FilesystemPermissionToml::Access(access) => entries.push(FileSystemSandboxEntry { path: compile_filesystem_path(path, startup_warnings)?, access: *access, }), FilesystemPermissionToml::Scoped(scoped_entries) => { for (subpath, access) in scoped_entries { entries.push(FileSystemSandboxEntry { path: compile_scoped_filesystem_path(path, subpath, startup_warnings)?, access: *access, }); } } } Ok(()) } fn compile_filesystem_path( path: &str, startup_warnings: &mut Vec, ) -> io::Result { if let Some(special) = parse_special_path(path) { maybe_push_unknown_special_path_warning(&special, startup_warnings); return Ok(FileSystemPath::Special { value: special }); } let path = parse_absolute_path(path)?; Ok(FileSystemPath::Path { path }) } fn compile_scoped_filesystem_path( path: &str, subpath: &str, startup_warnings: &mut Vec, ) -> io::Result { if subpath == "." { return compile_filesystem_path(path, startup_warnings); } if let Some(special) = parse_special_path(path) { let subpath = parse_relative_subpath(subpath)?; let special = match special { FileSystemSpecialPath::ProjectRoots { .. } => Ok(FileSystemPath::Special { value: FileSystemSpecialPath::project_roots(Some(subpath)), }), FileSystemSpecialPath::Unknown { path, .. } => Ok(FileSystemPath::Special { value: FileSystemSpecialPath::unknown(path, Some(subpath)), }), _ => Err(io::Error::new( io::ErrorKind::InvalidInput, format!("filesystem path `{path}` does not support nested entries"), )), }?; if let FileSystemPath::Special { value } = &special { maybe_push_unknown_special_path_warning(value, startup_warnings); } return Ok(special); } let subpath = parse_relative_subpath(subpath)?; let base = parse_absolute_path(path)?; let path = AbsolutePathBuf::resolve_path_against_base(&subpath, base.as_path())?; Ok(FileSystemPath::Path { path }) } // WARNING: keep this parser forward-compatible. // Adding a new `:special_path` must not make older Codex versions reject the // config. Unknown values intentionally round-trip through // `FileSystemSpecialPath::Unknown` so they can be surfaced as warnings and // ignored, rather than aborting config load. fn parse_special_path(path: &str) -> Option { match path { ":root" => Some(FileSystemSpecialPath::Root), ":minimal" => Some(FileSystemSpecialPath::Minimal), ":project_roots" => Some(FileSystemSpecialPath::project_roots(/*subpath*/ None)), ":tmpdir" => Some(FileSystemSpecialPath::Tmpdir), _ if path.starts_with(':') => { Some(FileSystemSpecialPath::unknown(path, /*subpath*/ None)) } _ => None, } } fn parse_absolute_path(path: &str) -> io::Result { parse_absolute_path_for_platform(path, cfg!(windows)) } fn parse_absolute_path_for_platform(path: &str, is_windows: bool) -> io::Result { let path_ref = normalize_absolute_path_for_platform(path, is_windows); if !is_absolute_path_for_platform(path, path_ref.as_ref(), is_windows) && path != "~" && !path.starts_with("~/") { return Err(io::Error::new( io::ErrorKind::InvalidInput, format!("filesystem path `{path}` must be absolute, use `~/...`, or start with `:`"), )); } AbsolutePathBuf::from_absolute_path(path_ref.as_ref()) } fn is_absolute_path_for_platform(path: &str, normalized_path: &Path, is_windows: bool) -> bool { if is_windows { is_windows_absolute_path(path) || is_windows_absolute_path(&normalized_path.to_string_lossy()) } else { normalized_path.is_absolute() } } fn normalize_absolute_path_for_platform(path: &str, is_windows: bool) -> Cow<'_, Path> { if !is_windows { return Cow::Borrowed(Path::new(path)); } match normalize_windows_device_path(path) { Some(normalized) => Cow::Owned(PathBuf::from(normalized)), None => Cow::Borrowed(Path::new(path)), } } fn normalize_windows_device_path(path: &str) -> Option { if let Some(unc) = path.strip_prefix(r"\\?\UNC\") { return Some(format!(r"\\{unc}")); } if let Some(unc) = path.strip_prefix(r"\\.\UNC\") { return Some(format!(r"\\{unc}")); } if let Some(path) = path.strip_prefix(r"\\?\") && is_windows_drive_absolute_path(path) { return Some(path.to_string()); } if let Some(path) = path.strip_prefix(r"\\.\") && is_windows_drive_absolute_path(path) { return Some(path.to_string()); } None } fn is_windows_absolute_path(path: &str) -> bool { is_windows_drive_absolute_path(path) || path.starts_with(r"\\") } fn is_windows_drive_absolute_path(path: &str) -> bool { let bytes = path.as_bytes(); bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'\\' | b'/') } fn parse_relative_subpath(subpath: &str) -> io::Result { let path = Path::new(subpath); if !subpath.is_empty() && path .components() .all(|component| matches!(component, Component::Normal(_))) { return Ok(path.to_path_buf()); } Err(io::Error::new( io::ErrorKind::InvalidInput, format!( "filesystem subpath `{}` must be a descendant path without `.` or `..` components", path.display() ), )) } fn push_warning(startup_warnings: &mut Vec, message: String) { tracing::warn!("{message}"); startup_warnings.push(message); } fn missing_filesystem_entries_warning(profile_name: &str) -> String { format!( "Permissions profile `{profile_name}` does not define any recognized filesystem entries for this version of Codex. Filesystem access will remain restricted. Upgrade Codex if this profile expects filesystem permissions." ) } fn maybe_push_unknown_special_path_warning( special: &FileSystemSpecialPath, startup_warnings: &mut Vec, ) { let FileSystemSpecialPath::Unknown { path, subpath } = special else { return; }; push_warning( startup_warnings, match subpath.as_deref() { Some(subpath) => format!( "Configured filesystem path `{path}` with nested entry `{}` is not recognized by this version of Codex and will be ignored. Upgrade Codex if this path is required.", subpath.display() ), None => format!( "Configured filesystem path `{path}` is not recognized by this version of Codex and will be ignored. Upgrade Codex if this path is required." ), }, ); } #[cfg(test)] #[path = "permissions_tests.rs"] mod tests;