1use crate::config::NetworkDomainPermission;
2use crate::config::NetworkMode;
3use crate::config::NetworkProxyConfig;
4use crate::config::ValidatedUnixSocketPath;
5use crate::credential_broker::CredentialBroker;
6use crate::mitm::MitmState;
7use crate::mitm_hook::HookEvaluation;
8use crate::mitm_hook::MitmHooksByHost;
9use crate::mitm_hook::evaluate_mitm_hooks;
10use crate::policy::Host;
11use crate::policy::is_loopback_host;
12use crate::policy::is_non_public_ip;
13use crate::policy::normalize_host;
14use crate::policy::unscoped_ip_literal;
15use crate::reasons::REASON_DENIED;
16use crate::reasons::REASON_NOT_ALLOWED;
17use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
18use crate::state::NetworkProxyConstraintError;
19use crate::state::NetworkProxyConstraints;
20use crate::state::build_config_state;
21use crate::state::validate_policy_against_constraints;
22use anyhow::Context;
23use anyhow::Result;
24use codex_utils_absolute_path::AbsolutePathBuf;
25use globset::GlobSet;
26use serde::Deserialize;
27use serde::Serialize;
28use std::collections::HashMap;
29use std::collections::HashSet;
30use std::collections::VecDeque;
31use std::future::Future;
32use std::net::IpAddr;
33use std::net::SocketAddr;
34use std::path::Path;
35use std::pin::Pin;
36use std::sync::Arc;
37use std::sync::Mutex;
38use std::time::Duration;
39use time::OffsetDateTime;
40use tokio::net::lookup_host;
41use tokio::sync::RwLock;
42use tokio::time::timeout;
43use tracing::debug;
44use tracing::info;
45use tracing::warn;
46
47const MAX_BLOCKED_EVENTS: usize = 200;
48const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
49const NETWORK_POLICY_VIOLATION_PREFIX: &str = "CODEX_NETWORK_POLICY_VIOLATION";
50
51#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
52#[serde(rename_all = "camelCase")]
53pub struct NetworkProxyAuditMetadata {
54 pub conversation_id: Option<String>,
55 pub app_version: Option<String>,
56 pub user_account_id: Option<String>,
57 pub auth_mode: Option<String>,
58 pub originator: Option<String>,
59 pub user_email: Option<String>,
60 pub terminal_type: Option<String>,
61 pub model: Option<String>,
62 pub slug: Option<String>,
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum HostBlockReason {
67 Denied,
68 NotAllowed,
69 NotAllowedLocal,
70}
71
72impl HostBlockReason {
73 pub const fn as_str(self) -> &'static str {
74 match self {
75 Self::Denied => REASON_DENIED,
76 Self::NotAllowed => REASON_NOT_ALLOWED,
77 Self::NotAllowedLocal => REASON_NOT_ALLOWED_LOCAL,
78 }
79 }
80}
81
82impl std::fmt::Display for HostBlockReason {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.write_str(self.as_str())
85 }
86}
87
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub enum HostBlockDecision {
90 Allowed,
91 Blocked(HostBlockReason),
92}
93
94#[derive(Clone, Debug, Serialize)]
95pub struct BlockedRequest {
96 pub host: String,
97 pub reason: String,
98 pub client: Option<String>,
99 pub method: Option<String>,
100 pub mode: Option<NetworkMode>,
101 pub protocol: String,
102 #[serde(skip)]
103 pub execution_id: Option<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub decision: Option<String>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub source: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub port: Option<u16>,
110 pub timestamp: i64,
111}
112
113pub struct BlockedRequestArgs {
114 pub host: String,
115 pub reason: String,
116 pub client: Option<String>,
117 pub method: Option<String>,
118 pub mode: Option<NetworkMode>,
119 pub protocol: String,
120 pub decision: Option<String>,
121 pub source: Option<String>,
122 pub port: Option<u16>,
123}
124
125impl BlockedRequest {
126 pub fn new(args: BlockedRequestArgs) -> Self {
127 let BlockedRequestArgs {
128 host,
129 reason,
130 client,
131 method,
132 mode,
133 protocol,
134 decision,
135 source,
136 port,
137 } = args;
138 Self {
139 host,
140 reason,
141 client,
142 method,
143 mode,
144 protocol,
145 execution_id: None,
146 decision,
147 source,
148 port,
149 timestamp: unix_timestamp(),
150 }
151 }
152}
153
154fn blocked_request_violation_log_line(entry: &BlockedRequest) -> String {
155 match serde_json::to_string(entry) {
156 Ok(json) => format!("{NETWORK_POLICY_VIOLATION_PREFIX} {json}"),
157 Err(err) => {
158 debug!("failed to serialize blocked request for violation log: {err}");
159 format!(
160 "{NETWORK_POLICY_VIOLATION_PREFIX} host={} reason={}",
161 entry.host, entry.reason
162 )
163 }
164 }
165}
166
167#[derive(Clone)]
168pub struct ConfigState {
169 pub config: NetworkProxyConfig,
170 pub allow_set: GlobSet,
171 pub deny_set: GlobSet,
172 pub mitm: Option<Arc<MitmState>>,
173 pub mitm_hooks: MitmHooksByHost,
174 pub constraints: NetworkProxyConstraints,
175 pub blocked: VecDeque<BlockedRequest>,
176 pub blocked_total: u64,
177}
178
179pub trait ConfigReloader: Send + Sync {
180 fn source_label(&self) -> String;
182
183 fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option<ConfigState>>;
185
186 fn reload_now(&self) -> ConfigReloaderFuture<'_, ConfigState>;
188}
189
190pub type ConfigReloaderFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T>> + Send + 'a>>;
191
192struct StaticConfigReloader;
193
194impl ConfigReloader for StaticConfigReloader {
195 fn source_label(&self) -> String {
196 "static config state".to_string()
197 }
198
199 fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option<ConfigState>> {
200 Box::pin(async { Ok(None) })
201 }
202
203 fn reload_now(&self) -> ConfigReloaderFuture<'_, ConfigState> {
204 Box::pin(async { anyhow::bail!("static config state cannot be reloaded") })
205 }
206}
207
208pub trait BlockedRequestObserver: Send + Sync + 'static {
209 fn on_blocked_request(&self, request: BlockedRequest) -> BlockedRequestObserverFuture<'_>;
210}
211
212pub type BlockedRequestObserverFuture<'a> = Pin<Box<dyn Future<Output = ()> + Send + 'a>>;
213
214impl<O: BlockedRequestObserver + ?Sized> BlockedRequestObserver for Arc<O> {
215 fn on_blocked_request(&self, request: BlockedRequest) -> BlockedRequestObserverFuture<'_> {
216 Box::pin(async move { (**self).on_blocked_request(request).await })
217 }
218}
219
220impl<F, Fut> BlockedRequestObserver for F
221where
222 F: Fn(BlockedRequest) -> Fut + Send + Sync + 'static,
223 Fut: Future<Output = ()> + Send + 'static,
224{
225 fn on_blocked_request(&self, request: BlockedRequest) -> BlockedRequestObserverFuture<'_> {
226 Box::pin((self)(request))
227 }
228}
229
230pub struct NetworkProxyState {
231 state: Arc<RwLock<ConfigState>>,
232 reloader: Arc<dyn ConfigReloader>,
233 blocked_request_observer: Arc<RwLock<Option<Arc<dyn BlockedRequestObserver>>>>,
234 credential_broker: CredentialBroker,
235 audit_metadata: NetworkProxyAuditMetadata,
236 execution_attributions: Arc<Mutex<HashMap<String, ExecutionAttribution>>>,
237 environment_id: Option<Arc<str>>,
238 execution_id: Option<Arc<str>>,
239}
240
241#[derive(Clone, Copy, Debug, PartialEq, Eq)]
242pub(crate) enum HostMitmRequirement {
243 None,
244 Tls,
245 Always,
246}
247
248#[derive(Clone)]
249struct ExecutionAttribution {
250 environment_id: String,
251 execution_id: String,
252}
253
254impl std::fmt::Debug for NetworkProxyState {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 f.debug_struct("NetworkProxyState").finish_non_exhaustive()
259 }
260}
261
262impl Clone for NetworkProxyState {
263 fn clone(&self) -> Self {
264 Self {
265 state: self.state.clone(),
266 reloader: self.reloader.clone(),
267 blocked_request_observer: self.blocked_request_observer.clone(),
268 credential_broker: self.credential_broker.clone(),
269 audit_metadata: self.audit_metadata.clone(),
270 execution_attributions: self.execution_attributions.clone(),
271 environment_id: self.environment_id.clone(),
272 execution_id: self.execution_id.clone(),
273 }
274 }
275}
276
277impl NetworkProxyState {
278 pub fn from_remote_launch_config(
280 launch: crate::RemoteNetworkProxyLaunchConfig,
281 ) -> Result<Self> {
282 let crate::RemoteNetworkProxyLaunchConfig {
283 proxy,
284 audit_metadata,
285 environment_id,
286 execution_id,
287 } = launch;
288 anyhow::ensure!(
289 proxy.enabled,
290 "executor-local network proxy launch requires an enabled proxy"
291 );
292 let config = proxy.into_network_proxy_config();
293 let state = build_config_state(config, NetworkProxyConstraints::default())?;
294 Ok(Self {
295 environment_id: environment_id.map(Into::into),
296 execution_id: execution_id.map(Into::into),
297 ..Self::with_reloader_and_audit_metadata(
298 state,
299 Arc::new(StaticConfigReloader),
300 audit_metadata,
301 )
302 })
303 }
304
305 pub fn with_reloader(state: ConfigState, reloader: Arc<dyn ConfigReloader>) -> Self {
306 Self::with_reloader_and_audit_metadata(
307 state,
308 reloader,
309 NetworkProxyAuditMetadata::default(),
310 )
311 }
312
313 pub fn with_reloader_and_blocked_observer(
314 state: ConfigState,
315 reloader: Arc<dyn ConfigReloader>,
316 blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
317 ) -> Self {
318 Self::with_reloader_and_audit_metadata_and_blocked_observer(
319 state,
320 reloader,
321 NetworkProxyAuditMetadata::default(),
322 blocked_request_observer,
323 )
324 }
325
326 pub fn with_reloader_and_audit_metadata(
327 state: ConfigState,
328 reloader: Arc<dyn ConfigReloader>,
329 audit_metadata: NetworkProxyAuditMetadata,
330 ) -> Self {
331 Self::with_reloader_and_audit_metadata_and_blocked_observer(
332 state,
333 reloader,
334 audit_metadata,
335 None,
336 )
337 }
338
339 pub fn with_reloader_and_audit_metadata_and_blocked_observer(
340 state: ConfigState,
341 reloader: Arc<dyn ConfigReloader>,
342 audit_metadata: NetworkProxyAuditMetadata,
343 blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
344 ) -> Self {
345 Self {
346 credential_broker: CredentialBroker::new(state.config.credential_broker),
347 state: Arc::new(RwLock::new(state)),
348 reloader,
349 blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)),
350 audit_metadata,
351 execution_attributions: Arc::new(Mutex::new(HashMap::new())),
352 environment_id: None,
353 execution_id: None,
354 }
355 }
356
357 pub(crate) fn register_execution(
358 &self,
359 attribution_token: &str,
360 environment_id: &str,
361 execution_id: &str,
362 ) {
363 self.execution_attributions
364 .lock()
365 .unwrap_or_else(std::sync::PoisonError::into_inner)
366 .insert(
367 attribution_token.to_string(),
368 ExecutionAttribution {
369 environment_id: environment_id.to_string(),
370 execution_id: execution_id.to_string(),
371 },
372 );
373 }
374
375 pub(crate) fn unregister_execution(&self, attribution_token: &str) {
376 self.execution_attributions
377 .lock()
378 .unwrap_or_else(std::sync::PoisonError::into_inner)
379 .remove(attribution_token);
380 }
381
382 pub(crate) fn for_execution_token(&self, token: &str) -> Option<Self> {
383 let attribution = self
384 .execution_attributions
385 .lock()
386 .unwrap_or_else(std::sync::PoisonError::into_inner)
387 .get(token)?
388 .clone();
389 Some(Self {
390 environment_id: Some(attribution.environment_id.into()),
391 execution_id: Some(attribution.execution_id.into()),
392 ..self.clone()
393 })
394 }
395
396 pub(crate) fn environment_id(&self) -> Option<&str> {
397 self.environment_id.as_deref()
398 }
399
400 pub(crate) fn execution_id(&self) -> Option<String> {
401 self.execution_id.as_deref().map(str::to_string)
402 }
403
404 pub async fn set_blocked_request_observer(
405 &self,
406 blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
407 ) {
408 let mut observer = self.blocked_request_observer.write().await;
409 *observer = blocked_request_observer;
410 }
411
412 pub fn audit_metadata(&self) -> &NetworkProxyAuditMetadata {
413 &self.audit_metadata
414 }
415
416 pub fn virtualize_child_credentials(&self, env: &mut HashMap<String, String>) {
417 self.credential_broker.virtualize_child_env(env);
418 }
419
420 pub fn inject_request_credentials(&self, host: &str, headers: &mut rama_http::HeaderMap) {
421 self.credential_broker.inject_request_headers(host, headers);
422 }
423
424 pub async fn plaintext_credential_injection_enabled(&self) -> Result<bool> {
425 self.reload_if_needed().await?;
426 let guard = self.state.read().await;
427 Ok(guard
428 .config
429 .dangerously_allow_plaintext_credential_injection)
430 }
431
432 pub async fn current_cfg(&self) -> Result<NetworkProxyConfig> {
433 self.reload_if_needed().await?;
436 let guard = self.state.read().await;
437 Ok(guard.config.clone())
438 }
439
440 pub async fn current_patterns(&self) -> Result<(Vec<String>, Vec<String>)> {
441 self.reload_if_needed().await?;
442 let guard = self.state.read().await;
443 Ok((
444 guard.config.allowed_domains().unwrap_or_default(),
445 guard.config.denied_domains().unwrap_or_default(),
446 ))
447 }
448
449 pub async fn enabled(&self) -> Result<bool> {
450 self.reload_if_needed().await?;
451 let guard = self.state.read().await;
452 Ok(guard.config.enabled)
453 }
454
455 pub async fn force_reload(&self) -> Result<()> {
456 let previous_cfg = {
457 let guard = self.state.read().await;
458 guard.config.clone()
459 };
460
461 match self.reloader.reload_now().await {
462 Ok(mut new_state) => {
463 self.ensure_credential_broker_enablement_unchanged(&new_state)?;
464 log_policy_changes(&previous_cfg, &new_state.config);
467 {
468 let mut guard = self.state.write().await;
469 new_state.blocked = guard.blocked.clone();
470 *guard = new_state;
471 }
472 let source = self.reloader.source_label();
473 info!("reloaded config from {source}");
474 Ok(())
475 }
476 Err(err) => {
477 let source = self.reloader.source_label();
478 warn!("failed to reload config from {source}: {err}; keeping previous config");
479 Err(err)
480 }
481 }
482 }
483
484 pub async fn replace_config_state(&self, mut new_state: ConfigState) -> Result<()> {
485 self.reload_if_needed().await?;
486 self.ensure_credential_broker_enablement_unchanged(&new_state)?;
487 let mut guard = self.state.write().await;
488 log_policy_changes(&guard.config, &new_state.config);
489 new_state.blocked = guard.blocked.clone();
490 new_state.blocked_total = guard.blocked_total;
491 *guard = new_state;
492 info!("updated network proxy config state");
493 Ok(())
494 }
495
496 pub async fn host_blocked(&self, host: &str, port: u16) -> Result<HostBlockDecision> {
497 self.reload_if_needed().await?;
498 let host = match Host::parse(host) {
499 Ok(host) => host,
500 Err(_) => return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)),
501 };
502 let (deny_set, allow_set, allow_local_binding, allowed_domains) = {
503 let guard = self.state.read().await;
504 let allowed_domains = guard.config.allowed_domains();
505 (
506 guard.deny_set.clone(),
507 guard.allow_set.clone(),
508 guard.config.allow_local_binding,
509 allowed_domains,
510 )
511 };
512 let allowed_domains_empty = allowed_domains.is_none();
513 let allowed_domains = allowed_domains.unwrap_or_default();
514
515 let host_str = host.as_str();
516
517 if globset_matches_host_or_unscoped(&deny_set, host_str) {
522 return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied));
523 }
524
525 let is_allowlisted = globset_matches_host_or_unscoped(&allow_set, host_str);
526 if !allow_local_binding {
527 let local_literal = {
536 let host_no_scope = unscoped_ip_literal(host_str).unwrap_or(host_str);
537 if is_loopback_host(&host) {
538 true
539 } else if let Ok(ip) = host_no_scope.parse::<IpAddr>() {
540 is_non_public_ip(ip)
541 } else {
542 false
543 }
544 };
545
546 if local_literal {
547 if !is_explicit_local_allowlisted(&allowed_domains, &host) {
548 return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal));
549 }
550 } else if host_resolves_to_non_public_ip(
551 host_str,
552 port,
553 DNS_LOOKUP_TIMEOUT,
554 |host, port| async move {
555 lookup_host((host.as_str(), port))
556 .await
557 .map(Iterator::collect)
558 },
559 )
560 .await
561 {
562 return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal));
563 }
564 }
565
566 if allowed_domains_empty || !is_allowlisted {
567 Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed))
568 } else {
569 Ok(HostBlockDecision::Allowed)
570 }
571 }
572
573 pub async fn record_blocked(&self, mut entry: BlockedRequest) -> Result<()> {
574 self.reload_if_needed().await?;
575 entry.execution_id = self.execution_id();
576 let blocked_for_observer = entry.clone();
577 let blocked_request_observer = self.blocked_request_observer.read().await.clone();
578 let violation_line = blocked_request_violation_log_line(&entry);
579 let host = entry.host.clone();
580 let reason = entry.reason.clone();
581 let decision = entry.decision.clone();
582 let source = entry.source.clone();
583 let protocol = entry.protocol.clone();
584 let port = entry.port;
585 let (total, buffered) = {
586 let mut guard = self.state.write().await;
587 guard.blocked.push_back(entry);
588 guard.blocked_total = guard.blocked_total.saturating_add(1);
589 let total = guard.blocked_total;
590 while guard.blocked.len() > MAX_BLOCKED_EVENTS {
591 guard.blocked.pop_front();
592 }
593 (total, guard.blocked.len())
594 };
595 debug!(
596 "recorded blocked request telemetry (\
597 total={total}, host={host}, reason={reason}, \
598 decision={decision:?}, source={source:?}, \
599 protocol={protocol}, port={port:?}, buffered={buffered})"
600 );
601 debug!("{violation_line}");
602
603 if let Some(observer) = blocked_request_observer {
604 observer.on_blocked_request(blocked_for_observer).await;
605 }
606 Ok(())
607 }
608
609 pub async fn blocked_snapshot(&self) -> Result<Vec<BlockedRequest>> {
612 self.reload_if_needed().await?;
613 let guard = self.state.read().await;
614 Ok(guard.blocked.iter().cloned().collect())
615 }
616
617 pub async fn drain_blocked(&self) -> Result<Vec<BlockedRequest>> {
619 self.reload_if_needed().await?;
620 let blocked = {
621 let mut guard = self.state.write().await;
622 std::mem::take(&mut guard.blocked)
623 };
624 Ok(blocked.into_iter().collect())
625 }
626
627 pub async fn is_unix_socket_allowed(&self, path: &str) -> Result<bool> {
628 self.reload_if_needed().await?;
629 if !unix_socket_permissions_supported() {
630 return Ok(false);
631 }
632
633 let requested_path = Path::new(path);
636 if !requested_path.is_absolute() {
637 return Ok(false);
638 }
639
640 let guard = self.state.read().await;
641 if guard.config.dangerously_allow_all_unix_sockets {
642 return Ok(true);
643 }
644
645 let requested_abs = match AbsolutePathBuf::from_absolute_path(requested_path) {
647 Ok(path) => path,
648 Err(_) => return Ok(false),
649 };
650 let requested_canonical = std::fs::canonicalize(requested_abs.as_path()).ok();
651 for allowed in &guard.config.allow_unix_sockets() {
652 let allowed_path = match ValidatedUnixSocketPath::parse(allowed) {
653 Ok(ValidatedUnixSocketPath::Native(path)) => path,
654 Ok(ValidatedUnixSocketPath::UnixStyleAbsolute(_)) => continue,
655 Err(err) => {
656 warn!("ignoring invalid network.allow_unix_sockets entry at runtime: {err:#}");
657 continue;
658 }
659 };
660
661 if allowed_path.as_path() == requested_abs.as_path() {
662 return Ok(true);
663 }
664
665 let Some(requested_canonical) = &requested_canonical else {
668 continue;
669 };
670 if let Ok(allowed_canonical) = std::fs::canonicalize(allowed_path.as_path())
671 && &allowed_canonical == requested_canonical
672 {
673 return Ok(true);
674 }
675 }
676 Ok(false)
677 }
678
679 pub async fn method_allowed(&self, method: &str) -> Result<bool> {
680 self.reload_if_needed().await?;
681 let guard = self.state.read().await;
682 Ok(guard.config.mode.allows_method(method))
683 }
684
685 pub async fn allow_upstream_proxy(&self) -> Result<bool> {
686 self.reload_if_needed().await?;
687 let guard = self.state.read().await;
688 Ok(guard.config.allow_upstream_proxy)
689 }
690
691 pub async fn allow_local_binding(&self) -> Result<bool> {
692 self.reload_if_needed().await?;
693 let guard = self.state.read().await;
694 Ok(guard.config.allow_local_binding)
695 }
696
697 pub async fn network_mode(&self) -> Result<NetworkMode> {
698 self.reload_if_needed().await?;
699 let guard = self.state.read().await;
700 Ok(guard.config.mode)
701 }
702
703 pub async fn set_network_mode(&self, mode: NetworkMode) -> Result<()> {
704 loop {
705 self.reload_if_needed().await?;
706 let (candidate, constraints) = {
707 let guard = self.state.read().await;
708 let mut candidate = guard.config.clone();
709 candidate.mode = mode;
710 (candidate, guard.constraints.clone())
711 };
712
713 validate_policy_against_constraints(&candidate, &constraints)
714 .map_err(NetworkProxyConstraintError::into_anyhow)
715 .context("network.mode constrained by managed config")?;
716
717 let mut guard = self.state.write().await;
718 if guard.constraints != constraints {
719 drop(guard);
720 continue;
721 }
722 guard.config.mode = mode;
723 info!("updated network mode to {mode:?}");
724 return Ok(());
725 }
726 }
727
728 pub async fn mitm_state(&self) -> Result<Option<Arc<MitmState>>> {
729 self.reload_if_needed().await?;
730 let guard = self.state.read().await;
731 Ok(guard.mitm.clone())
732 }
733
734 pub(crate) async fn evaluate_mitm_hook_request(
735 &self,
736 host: &str,
737 req: &rama_http::Request,
738 ) -> Result<HookEvaluation> {
739 self.reload_if_needed().await?;
740 let guard = self.state.read().await;
741 Ok(evaluate_mitm_hooks(&guard.mitm_hooks, host, req))
742 }
743
744 pub(crate) async fn host_mitm_requirement(&self, host: &str) -> Result<HostMitmRequirement> {
745 self.reload_if_needed().await?;
746 let normalized_host = normalize_host(host);
747 let host_has_mitm_hooks = {
748 let guard = self.state.read().await;
749 guard.mitm_hooks.contains_key(&normalized_host)
750 };
751 Ok(if host_has_mitm_hooks {
752 HostMitmRequirement::Always
753 } else if self.credential_broker.host_requires_mitm(&normalized_host) {
754 HostMitmRequirement::Tls
755 } else {
756 HostMitmRequirement::None
757 })
758 }
759
760 pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
761 self.update_domain_list(host, DomainListKind::Allow).await
762 }
763
764 pub async fn add_denied_domain(&self, host: &str) -> Result<()> {
765 self.update_domain_list(host, DomainListKind::Deny).await
766 }
767
768 async fn update_domain_list(&self, host: &str, target: DomainListKind) -> Result<()> {
769 let host = Host::parse(host).context("invalid network host")?;
770 let normalized_host = host.as_str().to_string();
771 let list_name = target.list_name();
772 let constraint_field = target.constraint_field();
773
774 loop {
775 self.reload_if_needed().await?;
776 let (previous_cfg, constraints, blocked, blocked_total) = {
777 let guard = self.state.read().await;
778 (
779 guard.config.clone(),
780 guard.constraints.clone(),
781 guard.blocked.clone(),
782 guard.blocked_total,
783 )
784 };
785
786 let mut candidate = previous_cfg.clone();
787 let target_entries = target.entries(&candidate);
788 let opposite_entries = target.opposite_entries(&candidate);
789 let target_contains = target_entries
790 .iter()
791 .any(|entry| normalize_host(entry) == normalized_host);
792 let opposite_contains = opposite_entries
793 .iter()
794 .any(|entry| normalize_host(entry) == normalized_host);
795 if target_contains && !opposite_contains {
796 return Ok(());
797 }
798
799 candidate.upsert_domain_permission(
800 normalized_host.clone(),
801 target.permission(),
802 normalize_host,
803 );
804
805 validate_policy_against_constraints(&candidate, &constraints)
806 .map_err(NetworkProxyConstraintError::into_anyhow)
807 .with_context(|| format!("{constraint_field} constrained by managed config"))?;
808
809 let mut new_state = build_config_state(candidate.clone(), constraints.clone())
810 .with_context(|| format!("failed to compile updated network {list_name}"))?;
811 new_state.blocked = blocked;
812 new_state.blocked_total = blocked_total;
813
814 let mut guard = self.state.write().await;
815 if guard.constraints != constraints || guard.config != previous_cfg {
816 drop(guard);
817 continue;
818 }
819
820 log_policy_changes(&guard.config, &candidate);
821 *guard = new_state;
822 info!("updated network {list_name} with {normalized_host}");
823 return Ok(());
824 }
825 }
826
827 async fn reload_if_needed(&self) -> Result<()> {
828 match self.reloader.maybe_reload().await? {
829 None => Ok(()),
830 Some(mut new_state) => {
831 self.ensure_credential_broker_enablement_unchanged(&new_state)?;
832 let (previous_cfg, blocked, blocked_total) = {
833 let guard = self.state.read().await;
834 (
835 guard.config.clone(),
836 guard.blocked.clone(),
837 guard.blocked_total,
838 )
839 };
840 log_policy_changes(&previous_cfg, &new_state.config);
841 new_state.blocked = blocked;
842 new_state.blocked_total = blocked_total;
843 {
844 let mut guard = self.state.write().await;
845 *guard = new_state;
846 }
847 let source = self.reloader.source_label();
848 info!("reloaded config from {source}");
849 Ok(())
850 }
851 }
852 }
853
854 fn ensure_credential_broker_enablement_unchanged(&self, new_state: &ConfigState) -> Result<()> {
855 anyhow::ensure!(
856 self.credential_broker.enabled() == new_state.config.credential_broker,
857 "network.credential_broker cannot change while the proxy is running"
858 );
859 Ok(())
860 }
861}
862
863#[derive(Clone, Copy)]
864enum DomainListKind {
865 Allow,
866 Deny,
867}
868
869impl DomainListKind {
870 fn list_name(self) -> &'static str {
871 match self {
872 Self::Allow => "allowlist",
873 Self::Deny => "denylist",
874 }
875 }
876
877 fn constraint_field(self) -> &'static str {
878 match self {
879 Self::Allow => "network.allowed_domains",
880 Self::Deny => "network.denied_domains",
881 }
882 }
883
884 fn permission(self) -> NetworkDomainPermission {
885 match self {
886 Self::Allow => NetworkDomainPermission::Allow,
887 Self::Deny => NetworkDomainPermission::Deny,
888 }
889 }
890
891 fn entries(self, network: &crate::config::NetworkProxyConfig) -> Vec<String> {
892 match self {
893 Self::Allow => network.allowed_domains().unwrap_or_default(),
894 Self::Deny => network.denied_domains().unwrap_or_default(),
895 }
896 }
897
898 fn opposite_entries(self, network: &crate::config::NetworkProxyConfig) -> Vec<String> {
899 match self {
900 Self::Allow => network.denied_domains().unwrap_or_default(),
901 Self::Deny => network.allowed_domains().unwrap_or_default(),
902 }
903 }
904}
905
906pub(crate) fn unix_socket_permissions_supported() -> bool {
907 cfg!(target_os = "macos")
908}
909
910async fn host_resolves_to_non_public_ip<F, Fut>(
911 host: &str,
912 port: u16,
913 lookup_timeout: Duration,
914 lookup: F,
915) -> bool
916where
917 F: FnOnce(String, u16) -> Fut,
918 Fut: Future<Output = std::io::Result<Vec<SocketAddr>>>,
919{
920 if let Ok(ip) = host.parse::<IpAddr>() {
921 return is_non_public_ip(ip);
922 }
923
924 let addrs = match timeout(lookup_timeout, lookup(host.to_string(), port)).await {
927 Ok(Ok(addrs)) => addrs,
928 Ok(Err(err)) => {
929 debug!(
930 "blocking host because DNS lookup failed during local/private IP check (host={host}, port={port}): {err}"
931 );
932 return true;
933 }
934 Err(_) => {
935 debug!(
936 "blocking host because DNS lookup timed out during local/private IP check (host={host}, port={port})"
937 );
938 return true;
939 }
940 };
941
942 for addr in addrs {
943 if is_non_public_ip(addr.ip()) {
944 return true;
945 }
946 }
947
948 false
949}
950
951fn log_policy_changes(previous: &NetworkProxyConfig, next: &NetworkProxyConfig) {
952 let previous_allowed_domains = previous.allowed_domains().unwrap_or_default();
953 let next_allowed_domains = next.allowed_domains().unwrap_or_default();
954 log_domain_list_changes(
955 "allowlist",
956 &previous_allowed_domains,
957 &next_allowed_domains,
958 );
959 let previous_denied_domains = previous.denied_domains().unwrap_or_default();
960 let next_denied_domains = next.denied_domains().unwrap_or_default();
961 log_domain_list_changes("denylist", &previous_denied_domains, &next_denied_domains);
962}
963
964fn log_domain_list_changes(list_name: &str, previous: &[String], next: &[String]) {
965 let previous_set: HashSet<String> = previous
966 .iter()
967 .map(|entry| entry.to_ascii_lowercase())
968 .collect();
969 let next_set: HashSet<String> = next
970 .iter()
971 .map(|entry| entry.to_ascii_lowercase())
972 .collect();
973
974 let added = next_set
975 .difference(&previous_set)
976 .cloned()
977 .collect::<HashSet<_>>();
978 let removed = previous_set
979 .difference(&next_set)
980 .cloned()
981 .collect::<HashSet<_>>();
982
983 let mut seen_next = HashSet::new();
984 for entry in next {
985 let key = entry.to_ascii_lowercase();
986 if seen_next.insert(key.clone()) && added.contains(&key) {
987 info!("config entry added to {list_name}: {entry}");
988 }
989 }
990
991 let mut seen_previous = HashSet::new();
992 for entry in previous {
993 let key = entry.to_ascii_lowercase();
994 if seen_previous.insert(key.clone()) && removed.contains(&key) {
995 info!("config entry removed from {list_name}: {entry}");
996 }
997 }
998}
999
1000fn globset_matches_host_or_unscoped(set: &GlobSet, host: &str) -> bool {
1001 set.is_match(host) || unscoped_ip_literal(host).is_some_and(|ip| set.is_match(ip))
1002}
1003
1004fn is_explicit_local_allowlisted(allowed_domains: &[String], host: &Host) -> bool {
1005 let normalized_host = host.as_str();
1006 let unscoped_host = unscoped_ip_literal(normalized_host);
1007 allowed_domains.iter().any(|pattern| {
1008 let pattern = pattern.trim();
1009 if pattern == "*" || pattern.starts_with("*.") || pattern.starts_with("**.") {
1010 return false;
1011 }
1012 if pattern.contains('*') || pattern.contains('?') {
1013 return false;
1014 }
1015 let normalized_pattern = normalize_host(pattern);
1016 normalized_pattern == normalized_host
1017 || unscoped_host.is_some_and(|ip| normalized_pattern == ip)
1018 })
1019}
1020
1021fn unix_timestamp() -> i64 {
1022 OffsetDateTime::now_utc().unix_timestamp()
1023}
1024
1025#[cfg(test)]
1026pub(crate) fn network_proxy_state_for_policy(
1027 mut network: crate::config::NetworkProxyConfig,
1028) -> NetworkProxyState {
1029 network.enabled = true;
1030 let config = network;
1031 let state = ConfigState {
1032 allow_set: crate::policy::compile_allowlist_globset(
1033 &config.allowed_domains().unwrap_or_default(),
1034 )
1035 .unwrap(),
1036 blocked: VecDeque::new(),
1037 blocked_total: 0,
1038 config: config.clone(),
1039 constraints: NetworkProxyConstraints::default(),
1040 deny_set: crate::policy::compile_denylist_globset(
1041 &config.denied_domains().unwrap_or_default(),
1042 )
1043 .unwrap(),
1044 mitm: None,
1045 mitm_hooks: crate::mitm_hook::compile_mitm_hooks(&config).unwrap(),
1046 };
1047
1048 NetworkProxyState::with_reloader(state, Arc::new(NoopReloader))
1049}
1050
1051#[cfg(test)]
1052struct NoopReloader;
1053
1054#[cfg(test)]
1055impl ConfigReloader for NoopReloader {
1056 fn source_label(&self) -> String {
1057 "test config state".to_string()
1058 }
1059
1060 fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option<ConfigState>> {
1061 Box::pin(async { Ok(None) })
1062 }
1063
1064 fn reload_now(&self) -> ConfigReloaderFuture<'_, ConfigState> {
1065 Box::pin(async { Err(anyhow::anyhow!("force reload is not supported in tests")) })
1066 }
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 use super::*;
1072
1073 use crate::config::NetworkProxyConfig;
1074 use crate::policy::compile_allowlist_globset;
1075 use crate::policy::compile_denylist_globset;
1076 use crate::state::NetworkProxyConstraints;
1077 use crate::state::build_config_state;
1078 use crate::state::validate_policy_against_constraints;
1079 use pretty_assertions::assert_eq;
1080
1081 #[derive(Clone)]
1082 struct StaticReloader {
1083 state: ConfigState,
1084 }
1085
1086 impl ConfigReloader for StaticReloader {
1087 fn source_label(&self) -> String {
1088 "static test reloader".to_string()
1089 }
1090
1091 fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option<ConfigState>> {
1092 let state = self.state.clone();
1093 Box::pin(async move { Ok(Some(state)) })
1094 }
1095
1096 fn reload_now(&self) -> ConfigReloaderFuture<'_, ConfigState> {
1097 let state = self.state.clone();
1098 Box::pin(async move { Ok(state) })
1099 }
1100 }
1101
1102 fn strings(entries: &[&str]) -> Vec<String> {
1103 entries.iter().map(|entry| (*entry).to_string()).collect()
1104 }
1105
1106 fn network_settings(allowed_domains: &[&str], denied_domains: &[&str]) -> NetworkProxyConfig {
1107 let mut network = NetworkProxyConfig::default();
1108 if !allowed_domains.is_empty() {
1109 network.set_allowed_domains(strings(allowed_domains));
1110 }
1111 if !denied_domains.is_empty() {
1112 network.set_denied_domains(strings(denied_domains));
1113 }
1114 network
1115 }
1116
1117 fn network_settings_with_unix_sockets(
1118 allowed_domains: &[&str],
1119 denied_domains: &[&str],
1120 unix_sockets: &[String],
1121 ) -> NetworkProxyConfig {
1122 let mut network = network_settings(allowed_domains, denied_domains);
1123 if !unix_sockets.is_empty() {
1124 network.set_allow_unix_sockets(unix_sockets.to_vec());
1125 }
1126 network
1127 }
1128
1129 #[tokio::test]
1130 async fn reload_rejects_credential_broker_enablement_changes() {
1131 let initial_state = build_config_state(
1132 NetworkProxyConfig::default(),
1133 NetworkProxyConstraints::default(),
1134 )
1135 .unwrap();
1136 let mut reloaded_state = initial_state.clone();
1137 reloaded_state
1138 .config
1139 .set_credential_broker_enabled(true);
1140 let state = NetworkProxyState::with_reloader(
1141 initial_state,
1142 Arc::new(StaticReloader {
1143 state: reloaded_state,
1144 }),
1145 );
1146
1147 let err = state
1148 .force_reload()
1149 .await
1150 .expect_err("credential broker enablement should require a proxy restart");
1151 let mut env = HashMap::from([("OPENAI_API_KEY".to_string(), "sk-real".to_string())]);
1152 state.virtualize_child_credentials(&mut env);
1153
1154 assert!(
1155 format!("{err:#}")
1156 .contains("network.credential_broker cannot change while the proxy is running"),
1157 "unexpected error: {err:#}"
1158 );
1159 assert_eq!(env["OPENAI_API_KEY"], "sk-real");
1160 assert!(!state.credential_broker.enabled());
1161 }
1162
1163 #[tokio::test]
1164 async fn host_blocked_denied_wins_over_allowed() {
1165 let state =
1166 network_proxy_state_for_policy(network_settings(&["example.com"], &["example.com"]));
1167
1168 assert_eq!(
1169 state
1170 .host_blocked("example.com", 80)
1171 .await
1172 .unwrap(),
1173 HostBlockDecision::Blocked(HostBlockReason::Denied)
1174 );
1175 }
1176
1177 #[tokio::test]
1178 async fn host_blocked_requires_allowlist_match() {
1179 let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
1180
1181 assert_eq!(
1182 state
1183 .host_blocked("example.com", 80)
1184 .await
1185 .unwrap(),
1186 HostBlockDecision::Allowed
1187 );
1188 assert_eq!(
1189 state.host_blocked("8.8.8.8", 80).await.unwrap(),
1192 HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
1193 );
1194 }
1195
1196 #[tokio::test]
1197 async fn add_allowed_domain_removes_matching_deny_entry() {
1198 let state = network_proxy_state_for_policy(network_settings(&[], &["example.com"]));
1199
1200 state.add_allowed_domain("ExAmPlE.CoM").await.unwrap();
1201
1202 let (allowed, denied) = state.current_patterns().await.unwrap();
1203 assert_eq!(allowed, vec!["example.com".to_string()]);
1204 assert!(denied.is_empty());
1205 assert_eq!(
1206 state
1207 .host_blocked("example.com", 80)
1208 .await
1209 .unwrap(),
1210 HostBlockDecision::Allowed
1211 );
1212 }
1213
1214 #[tokio::test]
1215 async fn add_denied_domain_removes_matching_allow_entry() {
1216 let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
1217
1218 state.add_denied_domain("EXAMPLE.COM").await.unwrap();
1219
1220 let (allowed, denied) = state.current_patterns().await.unwrap();
1221 assert!(allowed.is_empty());
1222 assert_eq!(denied, vec!["example.com".to_string()]);
1223 assert_eq!(
1224 state
1225 .host_blocked("example.com", 80)
1226 .await
1227 .unwrap(),
1228 HostBlockDecision::Blocked(HostBlockReason::Denied)
1229 );
1230 }
1231
1232 #[tokio::test]
1233 async fn add_denied_domain_forces_block_with_global_wildcard_allowlist() {
1234 let state = network_proxy_state_for_policy(network_settings(&["*"], &[]));
1235
1236 assert_eq!(
1237 state.host_blocked("8.8.8.8", 80).await.unwrap(),
1239 HostBlockDecision::Allowed
1240 );
1241
1242 state.add_denied_domain("8.8.8.8").await.unwrap();
1243
1244 let (allowed, denied) = state.current_patterns().await.unwrap();
1245 assert_eq!(allowed, vec!["*".to_string()]);
1246 assert_eq!(denied, vec!["8.8.8.8".to_string()]);
1247 assert_eq!(
1248 state.host_blocked("8.8.8.8", 80).await.unwrap(),
1249 HostBlockDecision::Blocked(HostBlockReason::Denied)
1250 );
1251 }
1252
1253 #[tokio::test]
1254 async fn add_allowed_domain_succeeds_when_managed_baseline_allows_expansion() {
1255 let mut config = network_settings(&["managed.example.com"], &[]);
1256 config.enabled = true;
1257 let constraints = NetworkProxyConstraints {
1258 allowed_domains: Some(vec!["managed.example.com".to_string()]),
1259 allowlist_expansion_enabled: Some(true),
1260 ..NetworkProxyConstraints::default()
1261 };
1262 let state = NetworkProxyState::with_reloader(
1263 build_config_state(config, constraints).unwrap(),
1264 Arc::new(NoopReloader),
1265 );
1266
1267 state.add_allowed_domain("user.example.com").await.unwrap();
1268
1269 let (allowed, denied) = state.current_patterns().await.unwrap();
1270 assert_eq!(
1271 allowed,
1272 vec![
1273 "managed.example.com".to_string(),
1274 "user.example.com".to_string()
1275 ]
1276 );
1277 assert!(denied.is_empty());
1278 }
1279
1280 #[tokio::test]
1281 async fn add_allowed_domain_rejects_expansion_when_managed_baseline_is_fixed() {
1282 let mut config = network_settings(&["managed.example.com"], &[]);
1283 config.enabled = true;
1284 let constraints = NetworkProxyConstraints {
1285 allowed_domains: Some(vec!["managed.example.com".to_string()]),
1286 allowlist_expansion_enabled: Some(false),
1287 ..NetworkProxyConstraints::default()
1288 };
1289 let state = NetworkProxyState::with_reloader(
1290 build_config_state(config, constraints).unwrap(),
1291 Arc::new(NoopReloader),
1292 );
1293
1294 let err = state
1295 .add_allowed_domain("user.example.com")
1296 .await
1297 .expect_err("managed baseline should reject allowlist expansion");
1298
1299 assert!(
1300 format!("{err:#}").contains("network.allowed_domains constrained by managed config"),
1301 "unexpected error: {err:#}"
1302 );
1303 }
1304
1305 #[tokio::test]
1306 async fn add_denied_domain_rejects_expansion_when_managed_baseline_is_fixed() {
1307 let mut config = network_settings(&[], &["managed.example.com"]);
1308 config.enabled = true;
1309 let constraints = NetworkProxyConstraints {
1310 denied_domains: Some(vec!["managed.example.com".to_string()]),
1311 denylist_expansion_enabled: Some(false),
1312 ..NetworkProxyConstraints::default()
1313 };
1314 let state = NetworkProxyState::with_reloader(
1315 build_config_state(config, constraints).unwrap(),
1316 Arc::new(NoopReloader),
1317 );
1318
1319 let err = state
1320 .add_denied_domain("user.example.com")
1321 .await
1322 .expect_err("managed baseline should reject denylist expansion");
1323
1324 assert!(
1325 format!("{err:#}").contains("network.denied_domains constrained by managed config"),
1326 "unexpected error: {err:#}"
1327 );
1328 }
1329
1330 #[tokio::test]
1331 async fn blocked_snapshot_does_not_consume_entries() {
1332 let state = network_proxy_state_for_policy(NetworkProxyConfig::default());
1333
1334 state
1335 .record_blocked(BlockedRequest::new(BlockedRequestArgs {
1336 host: "google.com".to_string(),
1337 reason: "not_allowed".to_string(),
1338 client: None,
1339 method: Some("GET".to_string()),
1340 mode: None,
1341 protocol: "http".to_string(),
1342 decision: Some("ask".to_string()),
1343 source: Some("decider".to_string()),
1344 port: Some(80),
1345 }))
1346 .await
1347 .expect("entry should be recorded");
1348
1349 let snapshot = state
1350 .blocked_snapshot()
1351 .await
1352 .expect("snapshot should succeed");
1353 assert_eq!(snapshot.len(), 1);
1354 assert_eq!(snapshot[0].host, "google.com");
1355 assert_eq!(snapshot[0].decision.as_deref(), Some("ask"));
1356
1357 let drained = state
1358 .drain_blocked()
1359 .await
1360 .expect("drain should include snapshot entry");
1361 assert_eq!(drained.len(), 1);
1362 assert_eq!(drained[0].host, snapshot[0].host);
1363 assert_eq!(drained[0].reason, snapshot[0].reason);
1364 assert_eq!(drained[0].decision, snapshot[0].decision);
1365 assert_eq!(drained[0].source, snapshot[0].source);
1366 assert_eq!(drained[0].port, snapshot[0].port);
1367 }
1368
1369 #[tokio::test]
1370 async fn drain_blocked_returns_buffered_window() {
1371 let state = network_proxy_state_for_policy(NetworkProxyConfig::default());
1372
1373 for idx in 0..(MAX_BLOCKED_EVENTS + 5) {
1374 state
1375 .record_blocked(BlockedRequest::new(BlockedRequestArgs {
1376 host: format!("example{idx}.com"),
1377 reason: "not_allowed".to_string(),
1378 client: None,
1379 method: Some("GET".to_string()),
1380 mode: None,
1381 protocol: "http".to_string(),
1382 decision: Some("ask".to_string()),
1383 source: Some("decider".to_string()),
1384 port: Some(80),
1385 }))
1386 .await
1387 .expect("entry should be recorded");
1388 }
1389
1390 let blocked = state.drain_blocked().await.expect("drain should succeed");
1391 assert_eq!(blocked.len(), MAX_BLOCKED_EVENTS);
1392 assert_eq!(blocked[0].host, "example5.com");
1393 }
1394
1395 #[test]
1396 fn blocked_request_violation_log_line_serializes_payload() {
1397 let entry = BlockedRequest {
1398 host: "google.com".to_string(),
1399 reason: "not_allowed".to_string(),
1400 client: Some("127.0.0.1".to_string()),
1401 method: Some("GET".to_string()),
1402 mode: Some(NetworkMode::Full),
1403 protocol: "http".to_string(),
1404 execution_id: None,
1405 decision: Some("ask".to_string()),
1406 source: Some("decider".to_string()),
1407 port: Some(80),
1408 timestamp: 1_735_689_600,
1409 };
1410
1411 assert_eq!(
1412 blocked_request_violation_log_line(&entry),
1413 r#"CODEX_NETWORK_POLICY_VIOLATION {"host":"google.com","reason":"not_allowed","client":"127.0.0.1","method":"GET","mode":"full","protocol":"http","decision":"ask","source":"decider","port":80,"timestamp":1735689600}"#
1414 );
1415 }
1416
1417 #[tokio::test]
1418 async fn host_blocked_subdomain_wildcards_exclude_apex() {
1419 let state = network_proxy_state_for_policy(network_settings(&["*.openai.com"], &[]));
1420
1421 assert_eq!(
1422 state
1423 .host_blocked("api.openai.com", 80)
1424 .await
1425 .unwrap(),
1426 HostBlockDecision::Allowed
1427 );
1428 assert_eq!(
1429 state.host_blocked("openai.com", 80).await.unwrap(),
1430 HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
1431 );
1432 }
1433
1434 #[tokio::test]
1435 async fn host_blocked_global_wildcard_allowlist_allows_public_hosts_except_denylist() {
1436 let state = network_proxy_state_for_policy(network_settings(&["*"], &["evil.example"]));
1437
1438 assert_eq!(
1439 state
1440 .host_blocked("example.com", 80)
1441 .await
1442 .unwrap(),
1443 HostBlockDecision::Allowed
1444 );
1445 assert_eq!(
1446 state
1447 .host_blocked("api.openai.com", 443)
1448 .await
1449 .unwrap(),
1450 HostBlockDecision::Allowed
1451 );
1452 assert_eq!(
1453 state
1454 .host_blocked("evil.example", 80)
1455 .await
1456 .unwrap(),
1457 HostBlockDecision::Blocked(HostBlockReason::Denied)
1458 );
1459 }
1460
1461 #[tokio::test]
1462 async fn host_blocked_rejects_loopback_when_local_binding_disabled() {
1463 let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
1464
1465 assert_eq!(
1466 state.host_blocked("127.0.0.1", 80).await.unwrap(),
1467 HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1468 );
1469 assert_eq!(
1470 state.host_blocked("localhost", 80).await.unwrap(),
1471 HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1472 );
1473 }
1474
1475 #[tokio::test]
1476 async fn host_blocked_allows_loopback_when_explicitly_allowlisted_and_local_binding_disabled() {
1477 let state = network_proxy_state_for_policy(network_settings(&["localhost"], &[]));
1478
1479 assert_eq!(
1480 state.host_blocked("localhost", 80).await.unwrap(),
1481 HostBlockDecision::Allowed
1482 );
1483 }
1484
1485 #[tokio::test]
1486 async fn host_blocked_allows_private_ip_literal_when_explicitly_allowlisted() {
1487 let state = network_proxy_state_for_policy(network_settings(&["10.0.0.1"], &[]));
1488
1489 assert_eq!(
1490 state.host_blocked("10.0.0.1", 80).await.unwrap(),
1491 HostBlockDecision::Allowed
1492 );
1493 }
1494
1495 #[tokio::test]
1496 async fn host_blocked_rejects_scoped_ipv6_literal_when_not_allowlisted() {
1497 let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
1498
1499 assert_eq!(
1500 state
1501 .host_blocked("fe80::1%lo0", 80)
1502 .await
1503 .unwrap(),
1504 HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1505 );
1506 }
1507
1508 #[tokio::test]
1509 async fn host_blocked_allows_scoped_ipv6_literal_when_explicitly_allowlisted() {
1510 let state = network_proxy_state_for_policy(network_settings(&["fe80::1"], &[]));
1511
1512 assert_eq!(
1513 state
1514 .host_blocked("fe80::1%lo0", 80)
1515 .await
1516 .unwrap(),
1517 HostBlockDecision::Allowed
1518 );
1519 }
1520
1521 #[tokio::test]
1522 async fn host_blocked_requires_exact_scoped_ipv6_allowlist_match() {
1523 let state = network_proxy_state_for_policy(NetworkProxyConfig {
1524 allow_local_binding: true,
1525 ..network_settings(&["fe80::1%eth0"], &[])
1526 });
1527
1528 assert_eq!(
1529 state
1530 .host_blocked("fe80::1%eth0", 80)
1531 .await
1532 .unwrap(),
1533 HostBlockDecision::Allowed
1534 );
1535 assert_eq!(
1536 state
1537 .host_blocked("fe80::1%eth1", 80)
1538 .await
1539 .unwrap(),
1540 HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
1541 );
1542 }
1543
1544 #[tokio::test]
1545 async fn host_blocked_denies_scoped_ipv6_literal_before_local_binding() {
1546 let state = network_proxy_state_for_policy(NetworkProxyConfig {
1547 allow_local_binding: true,
1548 ..network_settings(&["*"], &["fd00::1"])
1549 });
1550
1551 for host in ["fd00::1%eth0", "[fd00::1%eth0]", "[fd00::1%25eth0]"] {
1552 assert_eq!(
1553 state.host_blocked(host, 80).await.unwrap(),
1554 HostBlockDecision::Blocked(HostBlockReason::Denied),
1555 "host should be denied after normalization: {host}"
1556 );
1557 }
1558 }
1559
1560 #[tokio::test]
1561 async fn host_blocked_requires_exact_scoped_ipv6_denylist_match() {
1562 let state = network_proxy_state_for_policy(NetworkProxyConfig {
1563 allow_local_binding: true,
1564 ..network_settings(&["*"], &["fd00::1%eth0"])
1565 });
1566
1567 assert_eq!(
1568 state
1569 .host_blocked("fd00::1%eth0", 80)
1570 .await
1571 .unwrap(),
1572 HostBlockDecision::Blocked(HostBlockReason::Denied)
1573 );
1574 assert_eq!(
1575 state
1576 .host_blocked("fd00::1%eth1", 80)
1577 .await
1578 .unwrap(),
1579 HostBlockDecision::Allowed
1580 );
1581 }
1582
1583 #[tokio::test]
1584 async fn host_blocked_rejects_private_ip_literals_when_local_binding_disabled() {
1585 let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
1586
1587 assert_eq!(
1588 state.host_blocked("10.0.0.1", 80).await.unwrap(),
1589 HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1590 );
1591 }
1592
1593 #[tokio::test]
1594 async fn host_blocked_rejects_loopback_when_allowlist_empty() {
1595 let state = network_proxy_state_for_policy(NetworkProxyConfig::default());
1596
1597 assert_eq!(
1598 state.host_blocked("127.0.0.1", 80).await.unwrap(),
1599 HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1600 );
1601 }
1602
1603 #[tokio::test]
1604 async fn host_blocked_rejects_allowlisted_hostname_when_dns_lookup_fails() {
1605 let mut network = NetworkProxyConfig::default();
1606 network.set_allowed_domains(vec!["does-not-resolve.invalid".to_string()]);
1607 let state = network_proxy_state_for_policy(network);
1608
1609 assert_eq!(
1610 state
1611 .host_blocked("does-not-resolve.invalid", 80)
1612 .await
1613 .unwrap(),
1614 HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1615 );
1616 }
1617
1618 #[tokio::test]
1619 async fn host_resolves_to_non_public_ip_blocks_on_dns_lookup_timeout() {
1620 let blocked = host_resolves_to_non_public_ip(
1621 "slow.example",
1622 80,
1623 Duration::from_millis(1),
1624 |_host, _port| async {
1625 std::future::pending::<std::io::Result<Vec<SocketAddr>>>().await
1626 },
1627 )
1628 .await;
1629
1630 assert!(blocked);
1631 }
1632
1633 #[tokio::test]
1634 async fn host_resolves_to_non_public_ip_blocks_on_dns_lookup_error() {
1635 let blocked = host_resolves_to_non_public_ip(
1636 "error.example",
1637 80,
1638 Duration::from_millis(10),
1639 |_host, _port| async {
1640 Err::<Vec<SocketAddr>, std::io::Error>(std::io::Error::new(
1641 std::io::ErrorKind::TimedOut,
1642 "forced failure",
1643 ))
1644 },
1645 )
1646 .await;
1647
1648 assert!(blocked);
1649 }
1650
1651 #[tokio::test]
1652 async fn host_resolves_to_non_public_ip_blocks_private_resolution() {
1653 let blocked = host_resolves_to_non_public_ip(
1654 "local.example",
1655 80,
1656 Duration::from_millis(10),
1657 |_host, _port| async { Ok(vec!["127.0.0.1:80".parse().unwrap()]) },
1658 )
1659 .await;
1660
1661 assert!(blocked);
1662 }
1663
1664 #[tokio::test]
1665 async fn host_resolves_to_non_public_ip_allows_public_resolution() {
1666 let blocked = host_resolves_to_non_public_ip(
1667 "public.example",
1668 80,
1669 Duration::from_millis(10),
1670 |_host, _port| async { Ok(vec!["8.8.8.8:80".parse().unwrap()]) },
1671 )
1672 .await;
1673
1674 assert!(!blocked);
1675 }
1676
1677 #[test]
1678 fn validate_policy_against_constraints_disallows_widening_allowed_domains() {
1679 let constraints = NetworkProxyConstraints {
1680 allowed_domains: Some(vec!["example.com".to_string()]),
1681 ..NetworkProxyConstraints::default()
1682 };
1683
1684 let mut config = network_settings(&["example.com", "evil.com"], &[]);
1685 config.enabled = true;
1686
1687 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1688 }
1689
1690 #[test]
1691 fn validate_policy_against_constraints_allows_expanding_allowed_domains_when_enabled() {
1692 let constraints = NetworkProxyConstraints {
1693 allowed_domains: Some(vec!["example.com".to_string()]),
1694 allowlist_expansion_enabled: Some(true),
1695 ..NetworkProxyConstraints::default()
1696 };
1697
1698 let mut config = network_settings(&["example.com", "api.openai.com"], &[]);
1699 config.enabled = true;
1700
1701 assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
1702 }
1703
1704 #[test]
1705 fn validate_policy_against_constraints_disallows_widening_mode() {
1706 let constraints = NetworkProxyConstraints {
1707 mode: Some(NetworkMode::Limited),
1708 ..NetworkProxyConstraints::default()
1709 };
1710
1711 let config = NetworkProxyConfig {
1712 enabled: true,
1713 mode: NetworkMode::Full,
1714 ..NetworkProxyConfig::default()
1715 };
1716
1717 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1718 }
1719
1720 #[test]
1721 fn validate_policy_against_constraints_allows_narrowing_wildcard_allowlist() {
1722 let constraints = NetworkProxyConstraints {
1723 allowed_domains: Some(vec!["*.example.com".to_string()]),
1724 ..NetworkProxyConstraints::default()
1725 };
1726
1727 let mut config = network_settings(&["api.example.com"], &[]);
1728 config.enabled = true;
1729
1730 assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
1731 }
1732
1733 #[test]
1734 fn validate_policy_against_constraints_rejects_widening_wildcard_allowlist() {
1735 let constraints = NetworkProxyConstraints {
1736 allowed_domains: Some(vec!["*.example.com".to_string()]),
1737 ..NetworkProxyConstraints::default()
1738 };
1739
1740 let mut config = network_settings(&["**.example.com"], &[]);
1741 config.enabled = true;
1742
1743 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1744 }
1745
1746 #[test]
1747 fn validate_policy_against_constraints_rejects_global_wildcard_in_managed_allowlist() {
1748 let constraints = NetworkProxyConstraints {
1749 allowed_domains: Some(vec!["*".to_string()]),
1750 ..NetworkProxyConstraints::default()
1751 };
1752
1753 let mut config = network_settings(&["api.example.com"], &[]);
1754 config.enabled = true;
1755
1756 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1757 }
1758
1759 #[test]
1760 fn validate_policy_against_constraints_rejects_bracketed_global_wildcard_in_managed_allowlist()
1761 {
1762 let constraints = NetworkProxyConstraints {
1763 allowed_domains: Some(vec!["[*]".to_string()]),
1764 ..NetworkProxyConstraints::default()
1765 };
1766
1767 let mut config = network_settings(&["api.example.com"], &[]);
1768 config.enabled = true;
1769
1770 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1771 }
1772
1773 #[test]
1774 fn validate_policy_against_constraints_rejects_double_wildcard_bracketed_global_wildcard_in_managed_allowlist()
1775 {
1776 let constraints = NetworkProxyConstraints {
1777 allowed_domains: Some(vec!["**.[*]".to_string()]),
1778 ..NetworkProxyConstraints::default()
1779 };
1780
1781 let mut config = network_settings(&["api.example.com"], &[]);
1782 config.enabled = true;
1783
1784 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1785 }
1786
1787 #[test]
1788 fn validate_policy_against_constraints_requires_managed_denied_domains_entries() {
1789 let constraints = NetworkProxyConstraints {
1790 denied_domains: Some(vec!["evil.com".to_string()]),
1791 ..NetworkProxyConstraints::default()
1792 };
1793
1794 let config = NetworkProxyConfig {
1795 enabled: true,
1796 ..NetworkProxyConfig::default()
1797 };
1798
1799 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1800 }
1801
1802 #[test]
1803 fn validate_policy_against_constraints_disallows_expanding_denied_domains_when_fixed() {
1804 let constraints = NetworkProxyConstraints {
1805 denied_domains: Some(vec!["evil.com".to_string()]),
1806 denylist_expansion_enabled: Some(false),
1807 ..NetworkProxyConstraints::default()
1808 };
1809
1810 let mut config = network_settings(&[], &["evil.com", "more-evil.com"]);
1811 config.enabled = true;
1812
1813 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1814 }
1815
1816 #[test]
1817 fn validate_policy_against_constraints_disallows_enabling_when_managed_disabled() {
1818 let constraints = NetworkProxyConstraints {
1819 enabled: Some(false),
1820 ..NetworkProxyConstraints::default()
1821 };
1822
1823 let config = NetworkProxyConfig {
1824 enabled: true,
1825 ..NetworkProxyConfig::default()
1826 };
1827
1828 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1829 }
1830
1831 #[test]
1832 fn validate_policy_against_constraints_disallows_allow_local_binding_when_managed_disabled() {
1833 let constraints = NetworkProxyConstraints {
1834 allow_local_binding: Some(false),
1835 ..NetworkProxyConstraints::default()
1836 };
1837
1838 let config = NetworkProxyConfig {
1839 enabled: true,
1840 allow_local_binding: true,
1841 ..NetworkProxyConfig::default()
1842 };
1843
1844 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1845 }
1846
1847 #[test]
1848 fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_without_managed_opt_in()
1849 {
1850 let constraints = NetworkProxyConstraints {
1851 dangerously_allow_all_unix_sockets: Some(false),
1852 ..NetworkProxyConstraints::default()
1853 };
1854
1855 let config = NetworkProxyConfig {
1856 enabled: true,
1857 dangerously_allow_all_unix_sockets: true,
1858 ..NetworkProxyConfig::default()
1859 };
1860
1861 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1862 }
1863
1864 #[test]
1865 fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_when_allowlist_is_managed()
1866 {
1867 let constraints = NetworkProxyConstraints {
1868 allow_unix_sockets: Some(vec!["/tmp/allowed.sock".to_string()]),
1869 ..NetworkProxyConstraints::default()
1870 };
1871
1872 let config = NetworkProxyConfig {
1873 enabled: true,
1874 dangerously_allow_all_unix_sockets: true,
1875 ..NetworkProxyConfig::default()
1876 };
1877
1878 assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1879 }
1880
1881 #[test]
1882 fn validate_policy_against_constraints_allows_allow_all_unix_sockets_with_managed_opt_in() {
1883 let constraints = NetworkProxyConstraints {
1884 dangerously_allow_all_unix_sockets: Some(true),
1885 ..NetworkProxyConstraints::default()
1886 };
1887
1888 let config = NetworkProxyConfig {
1889 enabled: true,
1890 dangerously_allow_all_unix_sockets: true,
1891 ..NetworkProxyConfig::default()
1892 };
1893
1894 assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
1895 }
1896
1897 #[test]
1898 fn validate_policy_against_constraints_allows_allow_all_unix_sockets_when_unmanaged() {
1899 let constraints = NetworkProxyConstraints::default();
1900
1901 let config = NetworkProxyConfig {
1902 enabled: true,
1903 dangerously_allow_all_unix_sockets: true,
1904 ..NetworkProxyConfig::default()
1905 };
1906
1907 assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
1908 }
1909
1910 #[test]
1911 fn compile_globset_is_case_insensitive() {
1912 let patterns = vec!["ExAmPle.CoM".to_string()];
1913 let set = compile_denylist_globset(&patterns).unwrap();
1914 assert!(set.is_match("example.com"));
1915 assert!(set.is_match("EXAMPLE.COM"));
1916 }
1917
1918 #[test]
1919 fn compile_globset_excludes_apex_for_subdomain_patterns() {
1920 let patterns = vec!["*.openai.com".to_string()];
1921 let set = compile_denylist_globset(&patterns).unwrap();
1922 assert!(set.is_match("api.openai.com"));
1923 assert!(!set.is_match("openai.com"));
1924 assert!(!set.is_match("evilopenai.com"));
1925 }
1926
1927 #[test]
1928 fn compile_globset_includes_apex_for_double_wildcard_patterns() {
1929 let patterns = vec!["**.openai.com".to_string()];
1930 let set = compile_denylist_globset(&patterns).unwrap();
1931 assert!(set.is_match("openai.com"));
1932 assert!(set.is_match("api.openai.com"));
1933 assert!(!set.is_match("evilopenai.com"));
1934 }
1935
1936 #[test]
1937 fn compile_globset_rejects_global_wildcard() {
1938 let patterns = vec!["*".to_string()];
1939 assert!(compile_denylist_globset(&patterns).is_err());
1940 }
1941
1942 #[test]
1943 fn compile_globset_allows_global_wildcard_when_enabled() {
1944 let patterns = vec!["*".to_string()];
1945 let set = compile_allowlist_globset(&patterns).unwrap();
1946 assert!(set.is_match("example.com"));
1947 assert!(set.is_match("api.openai.com"));
1948 assert!(set.is_match("localhost"));
1949 }
1950
1951 #[test]
1952 fn compile_globset_rejects_bracketed_global_wildcard() {
1953 let patterns = vec!["[*]".to_string()];
1954 assert!(compile_denylist_globset(&patterns).is_err());
1955 }
1956
1957 #[test]
1958 fn compile_globset_rejects_double_wildcard_bracketed_global_wildcard() {
1959 let patterns = vec!["**.[*]".to_string()];
1960 assert!(compile_denylist_globset(&patterns).is_err());
1961 }
1962
1963 #[test]
1964 fn compile_globset_dedupes_patterns_without_changing_behavior() {
1965 let patterns = vec!["example.com".to_string(), "example.com".to_string()];
1966 let set = compile_denylist_globset(&patterns).unwrap();
1967 assert!(set.is_match("example.com"));
1968 assert!(set.is_match("EXAMPLE.COM"));
1969 assert!(!set.is_match("not-example.com"));
1970 }
1971
1972 #[test]
1973 fn compile_globset_rejects_invalid_patterns() {
1974 let patterns = vec!["[".to_string()];
1975 assert!(compile_denylist_globset(&patterns).is_err());
1976 }
1977
1978 #[test]
1979 fn build_config_state_allows_global_wildcard_allowed_domains() {
1980 let mut config = network_settings(&["*"], &[]);
1981 config.enabled = true;
1982
1983 assert!(build_config_state(config, NetworkProxyConstraints::default()).is_ok());
1984 }
1985
1986 #[test]
1987 fn build_config_state_allows_bracketed_global_wildcard_allowed_domains() {
1988 let mut config = network_settings(&["[*]"], &[]);
1989 config.enabled = true;
1990
1991 assert!(build_config_state(config, NetworkProxyConstraints::default()).is_ok());
1992 }
1993
1994 #[test]
1995 fn build_config_state_rejects_global_wildcard_denied_domains() {
1996 let mut config = network_settings(&["example.com"], &["*"]);
1997 config.enabled = true;
1998
1999 assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err());
2000 }
2001
2002 #[test]
2003 fn build_config_state_rejects_bracketed_global_wildcard_denied_domains() {
2004 let mut config = network_settings(&["example.com"], &["[*]"]);
2005 config.enabled = true;
2006
2007 assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err());
2008 }
2009
2010 #[cfg(target_os = "macos")]
2011 #[tokio::test]
2012 async fn unix_socket_allowlist_is_respected_on_macos() {
2013 let socket_path = "/tmp/example.sock".to_string();
2014 let state = network_proxy_state_for_policy(network_settings_with_unix_sockets(
2015 &["example.com"],
2016 &[],
2017 std::slice::from_ref(&socket_path),
2018 ));
2019
2020 assert!(state.is_unix_socket_allowed(&socket_path).await.unwrap());
2021 assert!(
2022 !state
2023 .is_unix_socket_allowed("/tmp/not-allowed.sock")
2024 .await
2025 .unwrap()
2026 );
2027 }
2028
2029 #[cfg(target_os = "macos")]
2030 #[tokio::test]
2031 async fn unix_socket_allowlist_resolves_symlinks() {
2032 use std::os::unix::fs::symlink;
2033 use tempfile::tempdir;
2034
2035 let temp_dir = tempdir().unwrap();
2036 let dir = temp_dir.path();
2037
2038 let real = dir.join("real.sock");
2039 let link = dir.join("link.sock");
2040
2041 std::fs::write(&real, b"not a socket").unwrap();
2044 symlink(&real, &link).unwrap();
2045
2046 let real_s = real.to_str().unwrap().to_string();
2047 let link_s = link.to_str().unwrap().to_string();
2048
2049 let state = network_proxy_state_for_policy(network_settings_with_unix_sockets(
2050 &["example.com"],
2051 &[],
2052 std::slice::from_ref(&real_s),
2053 ));
2054
2055 assert!(state.is_unix_socket_allowed(&link_s).await.unwrap());
2056 }
2057
2058 #[cfg(target_os = "macos")]
2059 #[tokio::test]
2060 async fn unix_socket_allow_all_flag_bypasses_allowlist() {
2061 let state = network_proxy_state_for_policy({
2062 let mut network = network_settings(&["example.com"], &[]);
2063 network.dangerously_allow_all_unix_sockets = true;
2064 network
2065 });
2066
2067 assert!(state.is_unix_socket_allowed("/tmp/any.sock").await.unwrap());
2068 assert!(!state.is_unix_socket_allowed("relative.sock").await.unwrap());
2069 }
2070
2071 #[cfg(not(target_os = "macos"))]
2072 #[tokio::test]
2073 async fn unix_socket_allowlist_is_rejected_on_non_macos() {
2074 let socket_path = "/tmp/example.sock".to_string();
2075 let state = network_proxy_state_for_policy({
2076 let mut network = network_settings_with_unix_sockets(
2077 &["example.com"],
2078 &[],
2079 std::slice::from_ref(&socket_path),
2080 );
2081 network.dangerously_allow_all_unix_sockets = true;
2082 network
2083 });
2084
2085 assert!(!state.is_unix_socket_allowed(&socket_path).await.unwrap());
2086 }
2087}