Skip to main content

codex_config/
permissions_toml.rs

1use std::collections::BTreeMap;
2
3use crate::merge::merge_toml_values;
4use codex_network_proxy::InjectedHeaderConfig;
5use codex_network_proxy::MitmHookActionsConfig;
6use codex_network_proxy::MitmHookBodyConfig;
7use codex_network_proxy::MitmHookConfig;
8use codex_network_proxy::MitmHookMatchConfig;
9use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission;
10use codex_network_proxy::NetworkMode;
11use codex_network_proxy::NetworkProxyConfig;
12use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission;
13use codex_network_proxy::normalize_host;
14use codex_protocol::permissions::FileSystemAccessMode;
15use indexmap::IndexMap;
16use schemars::JsonSchema;
17use serde::Deserialize;
18use serde::Serialize;
19use thiserror::Error;
20use toml::Value as TomlValue;
21
22#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
23pub struct PermissionsToml {
24    #[serde(flatten)]
25    pub entries: BTreeMap<String, PermissionProfileToml>,
26}
27
28impl PermissionsToml {
29    pub fn is_empty(&self) -> bool {
30        self.entries.is_empty()
31    }
32
33    /// Resolve `profile_name` and all of its `extends` ancestors into one TOML
34    /// profile.
35    ///
36    /// Parent profiles are merged before their children, so child keys override
37    /// matching parent keys before callers compile the profile into runtime
38    /// permissions. The returned profile keeps the selected profile's
39    /// declaration metadata, such as `description` and `extends`.
40    pub fn resolve_profile<F>(
41        &self,
42        profile_name: &str,
43        mut parent_profile: F,
44    ) -> Result<PermissionProfileToml, PermissionProfileResolutionError>
45    where
46        F: FnMut(&str) -> Option<PermissionProfileToml>,
47    {
48        let mut profile_names = Vec::new();
49        let mut profiles = Vec::new();
50        let mut next_profile_name = profile_name.to_string();
51        let mut referenced_by: Option<String> = None;
52
53        loop {
54            if let Some(cycle_start) = profile_names
55                .iter()
56                .position(|name| name == &next_profile_name)
57            {
58                let cycle = profile_names[cycle_start..]
59                    .iter()
60                    .cloned()
61                    .chain(std::iter::once(next_profile_name))
62                    .collect::<Vec<_>>();
63                return Err(PermissionProfileResolutionError::Cycle { cycle });
64            }
65
66            let profile = self
67                .entries
68                .get(&next_profile_name)
69                .cloned()
70                .or_else(|| parent_profile(&next_profile_name))
71                .ok_or_else(|| {
72                    referenced_by.as_deref().map_or_else(
73                        || PermissionProfileResolutionError::UndefinedProfile {
74                            profile_name: next_profile_name.clone(),
75                        },
76                        |referenced_by| {
77                            if next_profile_name.starts_with(':') {
78                                PermissionProfileResolutionError::UnsupportedBuiltInParent {
79                                    profile_name: referenced_by.to_string(),
80                                    parent_profile_name: next_profile_name.clone(),
81                                }
82                            } else {
83                                PermissionProfileResolutionError::UndefinedParent {
84                                    profile_name: referenced_by.to_string(),
85                                    parent_profile_name: next_profile_name.clone(),
86                                }
87                            }
88                        },
89                    )
90                })?;
91            let parent_profile_name = profile.extends.clone();
92
93            profile_names.push(next_profile_name.clone());
94
95            if let Some(parent_profile_name) = parent_profile_name {
96                profiles.push(profile);
97                referenced_by = Some(next_profile_name);
98                next_profile_name = parent_profile_name;
99                continue;
100            }
101
102            let profile = profiles
103                .into_iter()
104                .rev()
105                .try_fold(profile, merge_permission_profiles)?;
106            return Ok(profile);
107        }
108    }
109}
110
111#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
112#[schemars(deny_unknown_fields)]
113pub struct PermissionProfileToml {
114    pub description: Option<String>,
115    pub extends: Option<String>,
116    pub workspace_roots: Option<WorkspaceRootsToml>,
117    pub filesystem: Option<FilesystemPermissionsToml>,
118    pub network: Option<NetworkToml>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Error)]
122pub enum PermissionProfileResolutionError {
123    #[error("default_permissions refers to undefined profile `{profile_name}`")]
124    UndefinedProfile { profile_name: String },
125    #[error(
126        "permissions profile `{profile_name}` extends undefined profile `{parent_profile_name}`"
127    )]
128    UndefinedParent {
129        profile_name: String,
130        parent_profile_name: String,
131    },
132    #[error(
133        "permissions profile `{profile_name}` cannot extend unsupported built-in profile `{parent_profile_name}`"
134    )]
135    UnsupportedBuiltInParent {
136        profile_name: String,
137        parent_profile_name: String,
138    },
139    #[error(
140        "permissions profile inheritance cycle detected: {}",
141        cycle.join(" -> ")
142    )]
143    Cycle { cycle: Vec<String> },
144    #[error("failed to serialize permissions profile while resolving inheritance: {source}")]
145    SerializeProfileToml {
146        #[source]
147        source: toml::ser::Error,
148    },
149    #[error(
150        "failed to deserialize merged permissions profile while resolving inheritance: {source}"
151    )]
152    DeserializeProfileToml {
153        #[source]
154        source: toml::de::Error,
155    },
156}
157
158fn merge_permission_profiles(
159    mut parent: PermissionProfileToml,
160    mut child: PermissionProfileToml,
161) -> Result<PermissionProfileToml, PermissionProfileResolutionError> {
162    let merges_network_domains = parent
163        .network
164        .as_ref()
165        .and_then(|network| network.domains.as_ref())
166        .is_some()
167        && child
168            .network
169            .as_ref()
170            .and_then(|network| network.domains.as_ref())
171            .is_some();
172
173    // Description and inheritance metadata belong to the selected profile
174    // declaration, so an inherited profile must not fill those gaps.
175    parent.description = None;
176    parent.extends = None;
177
178    if merges_network_domains {
179        normalize_profile_network_domains(&mut parent);
180        normalize_profile_network_domains(&mut child);
181    }
182
183    let mut merged = TomlValue::try_from(parent)
184        .map_err(|source| PermissionProfileResolutionError::SerializeProfileToml { source })?;
185    let child = TomlValue::try_from(child)
186        .map_err(|source| PermissionProfileResolutionError::SerializeProfileToml { source })?;
187    merge_toml_values(&mut merged, &child);
188    merged
189        .try_into()
190        .map_err(|source| PermissionProfileResolutionError::DeserializeProfileToml { source })
191}
192
193fn normalize_profile_network_domains(profile: &mut PermissionProfileToml) {
194    let Some(domains) = profile
195        .network
196        .as_mut()
197        .and_then(|network| network.domains.as_mut())
198    else {
199        return;
200    };
201
202    let entries = std::mem::take(&mut domains.entries);
203    domains.entries = entries
204        .into_iter()
205        .map(|(pattern, permission)| (normalize_host(&pattern), permission))
206        .collect();
207}
208
209#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
210pub struct WorkspaceRootsToml {
211    #[serde(flatten)]
212    pub entries: BTreeMap<String, bool>,
213}
214
215impl WorkspaceRootsToml {
216    pub fn enabled_roots(&self) -> impl Iterator<Item = &String> {
217        self.entries
218            .iter()
219            .filter_map(|(path, enabled)| (*enabled).then_some(path))
220    }
221}
222
223#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
224pub struct FilesystemPermissionsToml {
225    /// Optional maximum depth for expanding unreadable glob patterns on
226    /// platforms that snapshot glob matches before sandbox startup.
227    #[schemars(range(min = 1))]
228    pub glob_scan_max_depth: Option<usize>,
229    #[serde(flatten)]
230    pub entries: BTreeMap<String, FilesystemPermissionToml>,
231}
232
233impl FilesystemPermissionsToml {
234    pub fn is_empty(&self) -> bool {
235        self.entries.is_empty()
236    }
237}
238
239#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
240#[serde(untagged)]
241pub enum FilesystemPermissionToml {
242    Access(FileSystemAccessMode),
243    Scoped(BTreeMap<String, FileSystemAccessMode>),
244}
245
246#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
247pub struct NetworkDomainPermissionsToml {
248    #[serde(flatten)]
249    pub entries: BTreeMap<String, NetworkDomainPermissionToml>,
250}
251
252impl NetworkDomainPermissionsToml {
253    pub fn allowed_domains(&self) -> Option<Vec<String>> {
254        let allowed_domains: Vec<String> = self
255            .entries
256            .iter()
257            .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Allow))
258            .map(|(pattern, _)| pattern.clone())
259            .collect();
260        (!allowed_domains.is_empty()).then_some(allowed_domains)
261    }
262
263    pub fn denied_domains(&self) -> Option<Vec<String>> {
264        let denied_domains: Vec<String> = self
265            .entries
266            .iter()
267            .filter(|(_, permission)| matches!(permission, NetworkDomainPermissionToml::Deny))
268            .map(|(pattern, _)| pattern.clone())
269            .collect();
270        (!denied_domains.is_empty()).then_some(denied_domains)
271    }
272}
273
274#[derive(
275    Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema,
276)]
277#[serde(rename_all = "lowercase")]
278pub enum NetworkDomainPermissionToml {
279    Allow,
280    Deny,
281}
282
283impl std::fmt::Display for NetworkDomainPermissionToml {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        let permission = match self {
286            Self::Allow => "allow",
287            Self::Deny => "deny",
288        };
289        f.write_str(permission)
290    }
291}
292
293#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
294pub struct NetworkUnixSocketPermissionsToml {
295    #[serde(flatten)]
296    pub entries: BTreeMap<String, NetworkUnixSocketPermissionToml>,
297}
298
299impl NetworkUnixSocketPermissionsToml {
300    pub fn allow_unix_sockets(&self) -> Vec<String> {
301        self.entries
302            .iter()
303            .filter(|(_, permission)| matches!(permission, NetworkUnixSocketPermissionToml::Allow))
304            .map(|(path, _)| path.clone())
305            .collect()
306    }
307}
308
309#[derive(
310    Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, JsonSchema,
311)]
312#[serde(rename_all = "lowercase")]
313pub enum NetworkUnixSocketPermissionToml {
314    Allow,
315    Deny,
316}
317
318impl std::fmt::Display for NetworkUnixSocketPermissionToml {
319    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320        let permission = match self {
321            Self::Allow => "allow",
322            Self::Deny => "deny",
323        };
324        f.write_str(permission)
325    }
326}
327
328#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
329#[schemars(deny_unknown_fields)]
330pub struct NetworkToml {
331    pub enabled: Option<bool>,
332    pub proxy_url: Option<String>,
333    pub enable_socks5: Option<bool>,
334    pub socks_url: Option<String>,
335    pub enable_socks5_udp: Option<bool>,
336    pub allow_upstream_proxy: Option<bool>,
337    pub dangerously_allow_non_loopback_proxy: Option<bool>,
338    pub dangerously_allow_all_unix_sockets: Option<bool>,
339    #[schemars(with = "Option<NetworkModeSchema>")]
340    pub mode: Option<NetworkMode>,
341    pub domains: Option<NetworkDomainPermissionsToml>,
342    pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
343    pub allow_local_binding: Option<bool>,
344    pub mitm: Option<NetworkMitmToml>,
345}
346
347#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
348#[schemars(deny_unknown_fields)]
349pub struct NetworkMitmToml {
350    #[schemars(with = "Option<BTreeMap<String, NetworkMitmHookToml>>")]
351    pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
352    #[schemars(with = "Option<BTreeMap<String, NetworkMitmActionToml>>")]
353    pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
354}
355
356#[derive(Deserialize)]
357#[serde(deny_unknown_fields)]
358struct NetworkMitmTomlUnchecked {
359    pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
360    pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
361}
362
363#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
364#[schemars(deny_unknown_fields)]
365pub struct NetworkMitmHookToml {
366    pub host: String,
367    pub methods: Vec<String>,
368    pub path_prefixes: Vec<String>,
369    #[serde(default)]
370    pub query: BTreeMap<String, Vec<String>>,
371    #[serde(default)]
372    pub headers: BTreeMap<String, Vec<String>>,
373    #[schemars(with = "Option<MitmHookBodyConfigSchema>")]
374    pub body: Option<MitmHookBodyConfig>,
375    pub action: Vec<String>,
376}
377
378#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
379#[serde(rename_all = "lowercase")]
380enum NetworkModeSchema {
381    Limited,
382    Full,
383}
384
385#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
386#[serde(default)]
387pub struct NetworkMitmActionToml {
388    pub strip_request_headers: Vec<String>,
389    pub inject_request_headers: Vec<NetworkMitmInjectedHeaderToml>,
390}
391
392#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
393#[serde(default)]
394pub struct NetworkMitmInjectedHeaderToml {
395    pub name: String,
396    pub secret_env_var: Option<String>,
397    pub secret_file: Option<String>,
398    pub prefix: Option<String>,
399}
400
401#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
402#[serde(transparent)]
403struct MitmHookBodyConfigSchema(pub serde_json::Value);
404
405impl<'de> Deserialize<'de> for NetworkMitmToml {
406    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
407    where
408        D: serde::Deserializer<'de>,
409    {
410        let unchecked = NetworkMitmTomlUnchecked::deserialize(deserializer)?;
411        let mitm = Self {
412            hooks: unchecked.hooks,
413            actions: unchecked.actions,
414        };
415        mitm.validate_action_definitions()
416            .map_err(serde::de::Error::custom)?;
417        Ok(mitm)
418    }
419}
420
421impl NetworkMitmToml {
422    pub fn validate_action_definitions(&self) -> Result<(), String> {
423        if let Some(actions) = self.actions.as_ref() {
424            for (action_name, action) in actions {
425                if action.is_empty() {
426                    return Err(format!(
427                        "network.mitm.actions.{action_name} must define at least one operation"
428                    ));
429                }
430            }
431        }
432
433        let Some(hooks) = self.hooks.as_ref() else {
434            return Ok(());
435        };
436
437        for (hook_name, hook) in hooks {
438            if hook.action.is_empty() {
439                return Err(format!(
440                    "network.mitm.hooks.{hook_name}.action must not be empty"
441                ));
442            }
443        }
444
445        Ok(())
446    }
447
448    pub fn to_runtime_hooks(
449        &self,
450        actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
451    ) -> Vec<MitmHookConfig> {
452        self.hooks
453            .as_ref()
454            .map(|hooks| {
455                hooks
456                    .values()
457                    .map(|hook| hook.to_runtime(actions_by_name))
458                    .collect()
459            })
460            .unwrap_or_default()
461    }
462}
463
464impl NetworkMitmActionToml {
465    pub fn is_empty(&self) -> bool {
466        self.strip_request_headers.is_empty() && self.inject_request_headers.is_empty()
467    }
468}
469
470impl NetworkToml {
471    pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) {
472        if let Some(enabled) = self.enabled {
473            config.enabled = enabled;
474        }
475        if let Some(proxy_url) = self.proxy_url.as_ref() {
476            config.proxy_url = proxy_url.clone();
477        }
478        if let Some(enable_socks5) = self.enable_socks5 {
479            config.enable_socks5 = enable_socks5;
480        }
481        if let Some(socks_url) = self.socks_url.as_ref() {
482            config.socks_url = socks_url.clone();
483        }
484        if let Some(enable_socks5_udp) = self.enable_socks5_udp {
485            config.enable_socks5_udp = enable_socks5_udp;
486        }
487        if let Some(allow_upstream_proxy) = self.allow_upstream_proxy {
488            config.allow_upstream_proxy = allow_upstream_proxy;
489        }
490        if let Some(dangerously_allow_non_loopback_proxy) =
491            self.dangerously_allow_non_loopback_proxy
492        {
493            config.dangerously_allow_non_loopback_proxy = dangerously_allow_non_loopback_proxy;
494        }
495        if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets {
496            config.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets;
497        }
498        if let Some(mode) = self.mode {
499            config.mode = mode;
500        }
501        if let Some(domains) = self.domains.as_ref() {
502            overlay_network_domain_permissions(config, domains);
503        }
504        if let Some(unix_sockets) = self.unix_sockets.as_ref() {
505            let mut proxy_unix_sockets = config.unix_sockets.take().unwrap_or_default();
506            for (path, permission) in &unix_sockets.entries {
507                let permission = match permission {
508                    NetworkUnixSocketPermissionToml::Allow => {
509                        ProxyNetworkUnixSocketPermission::Allow
510                    }
511                    NetworkUnixSocketPermissionToml::Deny => ProxyNetworkUnixSocketPermission::Deny,
512                };
513                proxy_unix_sockets.entries.insert(path.clone(), permission);
514            }
515            config.unix_sockets =
516                (!proxy_unix_sockets.entries.is_empty()).then_some(proxy_unix_sockets);
517        }
518        if let Some(allow_local_binding) = self.allow_local_binding {
519            config.allow_local_binding = allow_local_binding;
520        }
521        if let Some(mitm) = self.mitm.as_ref() {
522            config.mitm_hooks = mitm.to_runtime_hooks(mitm.actions.as_ref());
523        }
524        config.mitm = config.mode == NetworkMode::Limited || !config.mitm_hooks.is_empty();
525    }
526
527    pub fn to_network_proxy_config(&self) -> NetworkProxyConfig {
528        let mut config = NetworkProxyConfig::default();
529        self.apply_to_network_proxy_config(&mut config);
530        config
531    }
532}
533
534impl NetworkMitmHookToml {
535    fn to_runtime(
536        &self,
537        actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
538    ) -> MitmHookConfig {
539        MitmHookConfig {
540            host: self.host.clone(),
541            matcher: MitmHookMatchConfig {
542                methods: self.methods.clone(),
543                path_prefixes: self.path_prefixes.clone(),
544                query: self.query.clone(),
545                headers: self.headers.clone(),
546                body: self.body.clone(),
547            },
548            actions: self.selected_actions(actions_by_name),
549        }
550    }
551
552    fn selected_actions(
553        &self,
554        actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
555    ) -> MitmHookActionsConfig {
556        let Some(actions_by_name) = actions_by_name else {
557            return MitmHookActionsConfig::default();
558        };
559
560        let mut selected = MitmHookActionsConfig::default();
561        for action_name in &self.action {
562            if let Some(action) = actions_by_name.get(action_name) {
563                selected
564                    .strip_request_headers
565                    .extend(action.strip_request_headers.clone());
566                selected.inject_request_headers.extend(
567                    action
568                        .inject_request_headers
569                        .iter()
570                        .map(NetworkMitmInjectedHeaderToml::to_runtime),
571                );
572            }
573        }
574        selected
575    }
576}
577
578impl NetworkMitmInjectedHeaderToml {
579    fn to_runtime(&self) -> InjectedHeaderConfig {
580        InjectedHeaderConfig {
581            name: self.name.clone(),
582            secret_env_var: self.secret_env_var.clone(),
583            secret_file: self.secret_file.clone(),
584            prefix: self.prefix.clone(),
585        }
586    }
587}
588
589pub fn overlay_network_domain_permissions(
590    config: &mut NetworkProxyConfig,
591    domains: &NetworkDomainPermissionsToml,
592) {
593    for (pattern, permission) in &domains.entries {
594        let permission = match permission {
595            NetworkDomainPermissionToml::Allow => ProxyNetworkDomainPermission::Allow,
596            NetworkDomainPermissionToml::Deny => ProxyNetworkDomainPermission::Deny,
597        };
598        config.upsert_domain_permission(pattern.clone(), permission, normalize_host);
599    }
600}