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