Skip to main content

codex_network_proxy/
runtime.rs

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    /// Human-readable description of where config is loaded from, for logs.
181    fn source_label(&self) -> String;
182
183    /// Return a freshly loaded state if a reload is needed; otherwise, return `None`.
184    fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option<ConfigState>>;
185
186    /// Force a reload, regardless of whether a change was detected.
187    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        // Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy
257        // and may contain sensitive paths.
258        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    /// Builds runtime state for one executor-local proxy launch.
279    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            /*blocked_request_observer*/ 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        // Callers treat `NetworkProxyState` as a live view of policy. We reload-on-demand so edits to
434        // `config.toml` (including Codex-managed writes) take effect without a restart.
435        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                // Policy changes are operationally sensitive; logging diffs makes changes traceable
465                // without needing to dump full config blobs (which can include unrelated settings).
466                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        // Decision order matters:
518        //  1) explicit deny always wins
519        //  2) local/private networking is opt-in (defense-in-depth)
520        //  3) allowlist is enforced when configured
521        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            // If the intent is "prevent access to local/internal networks", we must not rely solely
528            // on string checks like `localhost` / `127.0.0.1`. Attackers can use DNS rebinding or
529            // public suffix services that map hostnames onto private IPs.
530            //
531            // We therefore do a best-effort DNS + IP classification check before allowing the
532            // request. Explicit local/loopback literals are allowed only when explicitly
533            // allowlisted; hostnames that resolve to local/private IPs are blocked even if
534            // allowlisted.
535            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    /// Returns a snapshot of buffered blocked-request entries without consuming
610    /// them.
611    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    /// Drain and return the buffered blocked-request entries in FIFO order.
618    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        // We only support absolute unix socket paths (a relative path would be ambiguous with
634        // respect to the proxy process's CWD and can lead to confusing allowlist behavior).
635        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        // Normalize the path while keeping the absolute-path requirement explicit.
646        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            // Best-effort canonicalization to reduce surprises with symlinks.
666            // If canonicalization fails (e.g., socket not created yet), fall back to raw comparison.
667            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    // Block the request if this DNS lookup fails. We resolve the hostname again when we connect,
925    // so a failed check here does not prove the destination is public.
926    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(/*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", /*port*/ 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", /*port*/ 80)
1184                .await
1185                .unwrap(),
1186            HostBlockDecision::Allowed
1187        );
1188        assert_eq!(
1189            // Use a public IP literal to avoid relying on ambient DNS behavior (some networks
1190            // resolve unknown hostnames to private IPs, which would trigger `not_allowed_local`).
1191            state.host_blocked("8.8.8.8", /*port*/ 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", /*port*/ 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", /*port*/ 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            // Use a public IP literal to avoid relying on ambient DNS behavior.
1238            state.host_blocked("8.8.8.8", /*port*/ 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", /*port*/ 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", /*port*/ 80)
1424                .await
1425                .unwrap(),
1426            HostBlockDecision::Allowed
1427        );
1428        assert_eq!(
1429            state.host_blocked("openai.com", /*port*/ 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", /*port*/ 80)
1441                .await
1442                .unwrap(),
1443            HostBlockDecision::Allowed
1444        );
1445        assert_eq!(
1446            state
1447                .host_blocked("api.openai.com", /*port*/ 443)
1448                .await
1449                .unwrap(),
1450            HostBlockDecision::Allowed
1451        );
1452        assert_eq!(
1453            state
1454                .host_blocked("evil.example", /*port*/ 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", /*port*/ 80).await.unwrap(),
1467            HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1468        );
1469        assert_eq!(
1470            state.host_blocked("localhost", /*port*/ 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", /*port*/ 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", /*port*/ 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", /*port*/ 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", /*port*/ 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", /*port*/ 80)
1531                .await
1532                .unwrap(),
1533            HostBlockDecision::Allowed
1534        );
1535        assert_eq!(
1536            state
1537                .host_blocked("fe80::1%eth1", /*port*/ 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, /*port*/ 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", /*port*/ 80)
1570                .await
1571                .unwrap(),
1572            HostBlockDecision::Blocked(HostBlockReason::Denied)
1573        );
1574        assert_eq!(
1575            state
1576                .host_blocked("fd00::1%eth1", /*port*/ 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", /*port*/ 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", /*port*/ 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", /*port*/ 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            /*port*/ 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            /*port*/ 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            /*port*/ 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            /*port*/ 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        // The allowlist mechanism is path-based; for test purposes we don't need an actual unix
2042        // domain socket. Any filesystem entry works for canonicalization.
2043        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}