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 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 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 #[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}