Codex Handbook
config/src/permissions_toml.rs 635 lines
use std::collections::BTreeMap;use crate::merge::merge_toml_values;use codex_network_proxy::InjectedHeaderConfig;use codex_network_proxy::MitmHookActionsConfig;use codex_network_proxy::MitmHookBodyConfig;use codex_network_proxy::MitmHookConfig;use codex_network_proxy::MitmHookMatchConfig;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 indexmap::IndexMap;use schemars::JsonSchema;use serde::Deserialize;use serde::Serialize;use thiserror::Error;use toml::Value as TomlValue;#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]pub struct PermissionsToml {    #[serde(flatten)]    pub entries: BTreeMap<String, PermissionProfileToml>,}impl PermissionsToml {    pub fn is_empty(&self) -> bool {        self.entries.is_empty()    }    /// Resolve `profile_name` and all of its `extends` ancestors into one TOML    /// profile.    ///    /// Parent profiles are merged before their children, so child keys override    /// matching parent keys before callers compile the profile into runtime    /// permissions. The returned profile keeps the selected profile's    /// declaration metadata, such as `description` and `extends`.    pub fn resolve_profile<F>(        &self,        profile_name: &str,        mut parent_profile: F,    ) -> Result<PermissionProfileToml, PermissionProfileResolutionError>    where        F: FnMut(&str) -> Option<PermissionProfileToml>,    {        let mut profile_names = Vec::new();        let mut profiles = Vec::new();        let mut next_profile_name = profile_name.to_string();        let mut referenced_by: Option<String> = None;        loop {            if let Some(cycle_start) = profile_names                .iter()                .position(|name| name == &next_profile_name)            {                let cycle = profile_names[cycle_start..]                    .iter()                    .cloned()                    .chain(std::iter::once(next_profile_name))                    .collect::<Vec<_>>();                return Err(PermissionProfileResolutionError::Cycle { cycle });            }            let profile = self                .entries                .get(&next_profile_name)                .cloned()                .or_else(|| parent_profile(&next_profile_name))                .ok_or_else(|| {                    referenced_by.as_deref().map_or_else(                        || PermissionProfileResolutionError::UndefinedProfile {                            profile_name: next_profile_name.clone(),                        },                        |referenced_by| {                            if next_profile_name.starts_with(':') {                                PermissionProfileResolutionError::UnsupportedBuiltInParent {                                    profile_name: referenced_by.to_string(),                                    parent_profile_name: next_profile_name.clone(),                                }                            } else {                                PermissionProfileResolutionError::UndefinedParent {                                    profile_name: referenced_by.to_string(),                                    parent_profile_name: next_profile_name.clone(),                                }                            }                        },                    )                })?;            let parent_profile_name = profile.extends.clone();            profile_names.push(next_profile_name.clone());            if let Some(parent_profile_name) = parent_profile_name {                profiles.push(profile);                referenced_by = Some(next_profile_name);                next_profile_name = parent_profile_name;                continue;            }            let profile = profiles                .into_iter()                .rev()                .try_fold(profile, merge_permission_profiles)?;            return Ok(profile);        }    }}#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]#[schemars(deny_unknown_fields)]pub struct PermissionProfileToml {    pub description: Option<String>,    pub extends: Option<String>,    pub workspace_roots: Option<WorkspaceRootsToml>,    pub filesystem: Option<FilesystemPermissionsToml>,    pub network: Option<NetworkToml>,}#[derive(Debug, Clone, PartialEq, Eq, Error)]pub enum PermissionProfileResolutionError {    #[error("default_permissions refers to undefined profile `{profile_name}`")]    UndefinedProfile { profile_name: String },    #[error(        "permissions profile `{profile_name}` extends undefined profile `{parent_profile_name}`"    )]    UndefinedParent {        profile_name: String,        parent_profile_name: String,    },    #[error(        "permissions profile `{profile_name}` cannot extend unsupported built-in profile `{parent_profile_name}`"    )]    UnsupportedBuiltInParent {        profile_name: String,        parent_profile_name: String,    },    #[error(        "permissions profile inheritance cycle detected: {}",        cycle.join(" -> ")    )]    Cycle { cycle: Vec<String> },    #[error("failed to serialize permissions profile while resolving inheritance: {source}")]    SerializeProfileToml {        #[source]        source: toml::ser::Error,    },    #[error(        "failed to deserialize merged permissions profile while resolving inheritance: {source}"    )]    DeserializeProfileToml {        #[source]        source: toml::de::Error,    },}fn merge_permission_profiles(    mut parent: PermissionProfileToml,    mut child: PermissionProfileToml,) -> Result<PermissionProfileToml, PermissionProfileResolutionError> {    let merges_network_domains = parent        .network        .as_ref()        .and_then(|network| network.domains.as_ref())        .is_some()        && child            .network            .as_ref()            .and_then(|network| network.domains.as_ref())            .is_some();    // Description and inheritance metadata belong to the selected profile    // declaration, so an inherited profile must not fill those gaps.    parent.description = None;    parent.extends = None;    if merges_network_domains {        normalize_profile_network_domains(&mut parent);        normalize_profile_network_domains(&mut child);    }    let mut merged = TomlValue::try_from(parent)        .map_err(|source| PermissionProfileResolutionError::SerializeProfileToml { source })?;    let child = TomlValue::try_from(child)        .map_err(|source| PermissionProfileResolutionError::SerializeProfileToml { source })?;    merge_toml_values(&mut merged, &child);    merged        .try_into()        .map_err(|source| PermissionProfileResolutionError::DeserializeProfileToml { source })}fn normalize_profile_network_domains(profile: &mut PermissionProfileToml) {    let Some(domains) = profile        .network        .as_mut()        .and_then(|network| network.domains.as_mut())    else {        return;    };    let entries = std::mem::take(&mut domains.entries);    domains.entries = entries        .into_iter()        .map(|(pattern, permission)| (normalize_host(&pattern), permission))        .collect();}#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]pub struct WorkspaceRootsToml {    #[serde(flatten)]    pub entries: BTreeMap<String, bool>,}impl WorkspaceRootsToml {    pub fn enabled_roots(&self) -> impl Iterator<Item = &String> {        self.entries            .iter()            .filter_map(|(path, enabled)| (*enabled).then_some(path))    }}#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]pub struct FilesystemPermissionsToml {    /// Optional maximum depth for expanding unreadable glob patterns on    /// platforms that snapshot glob matches before sandbox startup.    #[schemars(range(min = 1))]    pub glob_scan_max_depth: Option<usize>,    #[serde(flatten)]    pub entries: BTreeMap<String, FilesystemPermissionToml>,}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<String, FileSystemAccessMode>),}#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]pub struct NetworkDomainPermissionsToml {    #[serde(flatten)]    pub entries: BTreeMap<String, NetworkDomainPermissionToml>,}impl NetworkDomainPermissionsToml {    pub fn is_empty(&self) -> bool {        self.entries.is_empty()    }    pub fn allowed_domains(&self) -> Option<Vec<String>> {        let allowed_domains: Vec<String> = self            .entries            .iter()            .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow))            .map(|(pattern, _)| pattern.clone())            .collect();        (!allowed_domains.is_empty()).then_some(allowed_domains)    }    pub fn denied_domains(&self) -> Option<Vec<String>> {        let denied_domains: Vec<String> = 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<String, NetworkUnixSocketPermissionToml>,}impl NetworkUnixSocketPermissionsToml {    pub fn is_empty(&self) -> bool {        self.entries.is_empty()    }    pub fn allow_unix_sockets(&self) -> Vec<String> {        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,    Deny,}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::Deny => "deny",        };        f.write_str(permission)    }}#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]#[schemars(deny_unknown_fields)]pub struct NetworkToml {    pub enabled: Option<bool>,    pub proxy_url: Option<String>,    pub enable_socks5: Option<bool>,    pub socks_url: Option<String>,    pub enable_socks5_udp: Option<bool>,    pub allow_upstream_proxy: Option<bool>,    pub dangerously_allow_non_loopback_proxy: Option<bool>,    pub dangerously_allow_all_unix_sockets: Option<bool>,    #[schemars(with = "Option<NetworkModeSchema>")]    pub mode: Option<NetworkMode>,    pub domains: Option<NetworkDomainPermissionsToml>,    pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,    pub allow_local_binding: Option<bool>,    pub mitm: Option<NetworkMitmToml>,}#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]#[schemars(deny_unknown_fields)]pub struct NetworkMitmToml {    #[schemars(with = "Option<BTreeMap<String, NetworkMitmHookToml>>")]    pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,    #[schemars(with = "Option<BTreeMap<String, NetworkMitmActionToml>>")]    pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,}#[derive(Deserialize)]#[serde(deny_unknown_fields)]struct NetworkMitmTomlUnchecked {    pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,    pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,}#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]#[schemars(deny_unknown_fields)]pub struct NetworkMitmHookToml {    pub host: String,    pub methods: Vec<String>,    pub path_prefixes: Vec<String>,    #[serde(default)]    pub query: BTreeMap<String, Vec<String>>,    #[serde(default)]    pub headers: BTreeMap<String, Vec<String>>,    #[schemars(with = "Option<MitmHookBodyConfigSchema>")]    pub body: Option<MitmHookBodyConfig>,    pub action: Vec<String>,}#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]#[serde(rename_all = "lowercase")]enum NetworkModeSchema {    Limited,    Full,}#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]#[serde(default)]pub struct NetworkMitmActionToml {    pub strip_request_headers: Vec<String>,    pub inject_request_headers: Vec<NetworkMitmInjectedHeaderToml>,}#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]#[serde(default)]pub struct NetworkMitmInjectedHeaderToml {    pub name: String,    pub secret_env_var: Option<String>,    pub secret_file: Option<String>,    pub prefix: Option<String>,}#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]#[serde(transparent)]struct MitmHookBodyConfigSchema(pub serde_json::Value);impl<'de> Deserialize<'de> for NetworkMitmToml {    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>    where        D: serde::Deserializer<'de>,    {        let unchecked = NetworkMitmTomlUnchecked::deserialize(deserializer)?;        let mitm = Self {            hooks: unchecked.hooks,            actions: unchecked.actions,        };        mitm.validate_action_definitions()            .map_err(serde::de::Error::custom)?;        Ok(mitm)    }}impl NetworkMitmToml {    pub fn validate_action_definitions(&self) -> Result<(), String> {        if let Some(actions) = self.actions.as_ref() {            for (action_name, action) in actions {                if action.is_empty() {                    return Err(format!(                        "network.mitm.actions.{action_name} must define at least one operation"                    ));                }            }        }        let Some(hooks) = self.hooks.as_ref() else {            return Ok(());        };        for (hook_name, hook) in hooks {            if hook.action.is_empty() {                return Err(format!(                    "network.mitm.hooks.{hook_name}.action must not be empty"                ));            }        }        Ok(())    }    pub fn validate_action_references(        &self,        actions_by_name: &IndexMap<String, NetworkMitmActionToml>,    ) -> Result<(), String> {        self.validate_action_definitions()?;        let Some(hooks) = self.hooks.as_ref() else {            return Ok(());        };        for (hook_name, hook) in hooks {            for action_name in &hook.action {                if !actions_by_name.contains_key(action_name) {                    return Err(format!(                        "network.mitm.hooks.{hook_name}.action references undefined action `{action_name}`"                    ));                }            }        }        Ok(())    }    pub fn to_runtime_hooks(        &self,        actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,    ) -> Vec<MitmHookConfig> {        self.hooks            .as_ref()            .map(|hooks| {                hooks                    .values()                    .map(|hook| hook.to_runtime(actions_by_name))                    .collect()            })            .unwrap_or_default()    }}impl NetworkMitmActionToml {    pub fn is_empty(&self) -> bool {        self.strip_request_headers.is_empty() && self.inject_request_headers.is_empty()    }}impl NetworkToml {    pub 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::Deny => ProxyNetworkUnixSocketPermission::Deny,                };                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;        }        if let Some(mitm) = self.mitm.as_ref() {            config.network.mitm_hooks = mitm.to_runtime_hooks(mitm.actions.as_ref());        }        config.network.mitm =            config.network.mode == NetworkMode::Limited || !config.network.mitm_hooks.is_empty();    }    pub fn to_network_proxy_config(&self) -> NetworkProxyConfig {        let mut config = NetworkProxyConfig::default();        self.apply_to_network_proxy_config(&mut config);        config    }}impl NetworkMitmHookToml {    fn to_runtime(        &self,        actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,    ) -> MitmHookConfig {        MitmHookConfig {            host: self.host.clone(),            matcher: MitmHookMatchConfig {                methods: self.methods.clone(),                path_prefixes: self.path_prefixes.clone(),                query: self.query.clone(),                headers: self.headers.clone(),                body: self.body.clone(),            },            actions: self.selected_actions(actions_by_name),        }    }    fn selected_actions(        &self,        actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,    ) -> MitmHookActionsConfig {        let Some(actions_by_name) = actions_by_name else {            return MitmHookActionsConfig::default();        };        let mut selected = MitmHookActionsConfig::default();        for action_name in &self.action {            if let Some(action) = actions_by_name.get(action_name) {                selected                    .strip_request_headers                    .extend(action.strip_request_headers.clone());                selected.inject_request_headers.extend(                    action                        .inject_request_headers                        .iter()                        .map(NetworkMitmInjectedHeaderToml::to_runtime),                );            }        }        selected    }}impl NetworkMitmInjectedHeaderToml {    fn to_runtime(&self) -> InjectedHeaderConfig {        InjectedHeaderConfig {            name: self.name.clone(),            secret_env_var: self.secret_env_var.clone(),            secret_file: self.secret_file.clone(),            prefix: self.prefix.clone(),        }    }}pub 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);    }}