Skip to main content

zerobox_network_proxy/
runtime.rs

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