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 replace_config_state(&self, mut new_state: ConfigState) -> Result<()> {
357        self.reload_if_needed().await?;
358        let mut guard = self.state.write().await;
359        log_policy_changes(&guard.config, &new_state.config);
360        new_state.blocked = guard.blocked.clone();
361        new_state.blocked_total = guard.blocked_total;
362        *guard = new_state;
363        info!("updated network proxy config state");
364        Ok(())
365    }
366
367    pub async fn host_blocked(&self, host: &str, port: u16) -> Result<HostBlockDecision> {
368        self.reload_if_needed().await?;
369        let host = match Host::parse(host) {
370            Ok(host) => host,
371            Err(_) => return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)),
372        };
373        let (deny_set, allow_set, allow_local_binding, allowed_domains) = {
374            let guard = self.state.read().await;
375            let allowed_domains = guard.config.network.allowed_domains();
376            (
377                guard.deny_set.clone(),
378                guard.allow_set.clone(),
379                guard.config.network.allow_local_binding,
380                allowed_domains,
381            )
382        };
383        let allowed_domains_empty = allowed_domains.is_none();
384        let allowed_domains = allowed_domains.unwrap_or_default();
385
386        let host_str = host.as_str();
387
388        // Decision order matters:
389        //  1) explicit deny always wins
390        //  2) local/private networking is opt-in (defense-in-depth)
391        //  3) allowlist is enforced when configured
392        if deny_set.is_match(host_str) {
393            return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied));
394        }
395
396        let is_allowlisted = allow_set.is_match(host_str);
397        if !allow_local_binding {
398            // If the intent is "prevent access to local/internal networks", we must not rely solely
399            // on string checks like `localhost` / `127.0.0.1`. Attackers can use DNS rebinding or
400            // public suffix services that map hostnames onto private IPs.
401            //
402            // We therefore do a best-effort DNS + IP classification check before allowing the
403            // request. Explicit local/loopback literals are allowed only when explicitly
404            // allowlisted; hostnames that resolve to local/private IPs are blocked even if
405            // allowlisted.
406            let local_literal = {
407                let host_no_scope = host_str
408                    .split_once('%')
409                    .map(|(ip, _)| ip)
410                    .unwrap_or(host_str);
411                if is_loopback_host(&host) {
412                    true
413                } else if let Ok(ip) = host_no_scope.parse::<IpAddr>() {
414                    is_non_public_ip(ip)
415                } else {
416                    false
417                }
418            };
419
420            if local_literal {
421                if !is_explicit_local_allowlisted(&allowed_domains, &host) {
422                    return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal));
423                }
424            } else if host_resolves_to_non_public_ip(host_str, port).await {
425                return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal));
426            }
427        }
428
429        if allowed_domains_empty || !is_allowlisted {
430            Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed))
431        } else {
432            Ok(HostBlockDecision::Allowed)
433        }
434    }
435
436    pub async fn record_blocked(&self, entry: BlockedRequest) -> Result<()> {
437        self.reload_if_needed().await?;
438        let blocked_for_observer = entry.clone();
439        let blocked_request_observer = self.blocked_request_observer.read().await.clone();
440        let violation_line = blocked_request_violation_log_line(&entry);
441        let mut guard = self.state.write().await;
442        let host = entry.host.clone();
443        let reason = entry.reason.clone();
444        let decision = entry.decision.clone();
445        let source = entry.source.clone();
446        let protocol = entry.protocol.clone();
447        let port = entry.port;
448        guard.blocked.push_back(entry);
449        guard.blocked_total = guard.blocked_total.saturating_add(1);
450        let total = guard.blocked_total;
451        while guard.blocked.len() > MAX_BLOCKED_EVENTS {
452            guard.blocked.pop_front();
453        }
454        debug!(
455            "recorded blocked request telemetry (total={}, host={}, reason={}, decision={:?}, source={:?}, protocol={}, port={:?}, buffered={})",
456            total,
457            host,
458            reason,
459            decision,
460            source,
461            protocol,
462            port,
463            guard.blocked.len()
464        );
465        debug!("{violation_line}");
466        drop(guard);
467
468        if let Some(observer) = blocked_request_observer {
469            observer.on_blocked_request(blocked_for_observer).await;
470        }
471        Ok(())
472    }
473
474    /// Returns a snapshot of buffered blocked-request entries without consuming
475    /// them.
476    pub async fn blocked_snapshot(&self) -> Result<Vec<BlockedRequest>> {
477        self.reload_if_needed().await?;
478        let guard = self.state.read().await;
479        Ok(guard.blocked.iter().cloned().collect())
480    }
481
482    /// Drain and return the buffered blocked-request entries in FIFO order.
483    pub async fn drain_blocked(&self) -> Result<Vec<BlockedRequest>> {
484        self.reload_if_needed().await?;
485        let blocked = {
486            let mut guard = self.state.write().await;
487            std::mem::take(&mut guard.blocked)
488        };
489        Ok(blocked.into_iter().collect())
490    }
491
492    pub async fn is_unix_socket_allowed(&self, path: &str) -> Result<bool> {
493        self.reload_if_needed().await?;
494        if !unix_socket_permissions_supported() {
495            return Ok(false);
496        }
497
498        // We only support absolute unix socket paths (a relative path would be ambiguous with
499        // respect to the proxy process's CWD and can lead to confusing allowlist behavior).
500        let requested_path = Path::new(path);
501        if !requested_path.is_absolute() {
502            return Ok(false);
503        }
504
505        let guard = self.state.read().await;
506        if guard.config.network.dangerously_allow_all_unix_sockets {
507            return Ok(true);
508        }
509
510        // Normalize the path while keeping the absolute-path requirement explicit.
511        let requested_abs = match AbsolutePathBuf::from_absolute_path(requested_path) {
512            Ok(path) => path,
513            Err(_) => return Ok(false),
514        };
515        let requested_canonical = std::fs::canonicalize(requested_abs.as_path()).ok();
516        for allowed in &guard.config.network.allow_unix_sockets() {
517            let allowed_path = match ValidatedUnixSocketPath::parse(allowed) {
518                Ok(ValidatedUnixSocketPath::Native(path)) => path,
519                Ok(ValidatedUnixSocketPath::UnixStyleAbsolute(_)) => continue,
520                Err(err) => {
521                    warn!("ignoring invalid network.allow_unix_sockets entry at runtime: {err:#}");
522                    continue;
523                }
524            };
525
526            if allowed_path.as_path() == requested_abs.as_path() {
527                return Ok(true);
528            }
529
530            // Best-effort canonicalization to reduce surprises with symlinks.
531            // If canonicalization fails (e.g., socket not created yet), fall back to raw comparison.
532            let Some(requested_canonical) = &requested_canonical else {
533                continue;
534            };
535            if let Ok(allowed_canonical) = std::fs::canonicalize(allowed_path.as_path())
536                && &allowed_canonical == requested_canonical
537            {
538                return Ok(true);
539            }
540        }
541        Ok(false)
542    }
543
544    pub async fn method_allowed(&self, method: &str) -> Result<bool> {
545        self.reload_if_needed().await?;
546        let guard = self.state.read().await;
547        Ok(guard.config.network.mode.allows_method(method))
548    }
549
550    pub async fn allow_upstream_proxy(&self) -> Result<bool> {
551        self.reload_if_needed().await?;
552        let guard = self.state.read().await;
553        Ok(guard.config.network.allow_upstream_proxy)
554    }
555
556    pub async fn network_mode(&self) -> Result<NetworkMode> {
557        self.reload_if_needed().await?;
558        let guard = self.state.read().await;
559        Ok(guard.config.network.mode)
560    }
561
562    pub async fn set_network_mode(&self, mode: NetworkMode) -> Result<()> {
563        loop {
564            self.reload_if_needed().await?;
565            let (candidate, constraints) = {
566                let guard = self.state.read().await;
567                let mut candidate = guard.config.clone();
568                candidate.network.mode = mode;
569                (candidate, guard.constraints.clone())
570            };
571
572            validate_policy_against_constraints(&candidate, &constraints)
573                .map_err(NetworkProxyConstraintError::into_anyhow)
574                .context("network.mode constrained by managed config")?;
575
576            let mut guard = self.state.write().await;
577            if guard.constraints != constraints {
578                drop(guard);
579                continue;
580            }
581            guard.config.network.mode = mode;
582            info!("updated network mode to {mode:?}");
583            return Ok(());
584        }
585    }
586
587    pub async fn mitm_state(&self) -> Result<Option<Arc<MitmState>>> {
588        self.reload_if_needed().await?;
589        let guard = self.state.read().await;
590        Ok(guard.mitm.clone())
591    }
592
593    pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
594        self.update_domain_list(host, DomainListKind::Allow).await
595    }
596
597    pub async fn add_denied_domain(&self, host: &str) -> Result<()> {
598        self.update_domain_list(host, DomainListKind::Deny).await
599    }
600
601    async fn update_domain_list(&self, host: &str, target: DomainListKind) -> Result<()> {
602        let host = Host::parse(host).context("invalid network host")?;
603        let normalized_host = host.as_str().to_string();
604        let list_name = target.list_name();
605        let constraint_field = target.constraint_field();
606
607        loop {
608            self.reload_if_needed().await?;
609            let (previous_cfg, constraints, blocked, blocked_total) = {
610                let guard = self.state.read().await;
611                (
612                    guard.config.clone(),
613                    guard.constraints.clone(),
614                    guard.blocked.clone(),
615                    guard.blocked_total,
616                )
617            };
618
619            let mut candidate = previous_cfg.clone();
620            let target_entries = target.entries(&candidate.network);
621            let opposite_entries = target.opposite_entries(&candidate.network);
622            let target_contains = target_entries
623                .iter()
624                .any(|entry| normalize_host(entry) == normalized_host);
625            let opposite_contains = opposite_entries
626                .iter()
627                .any(|entry| normalize_host(entry) == normalized_host);
628            if target_contains && !opposite_contains {
629                return Ok(());
630            }
631
632            candidate.network.upsert_domain_permission(
633                normalized_host.clone(),
634                target.permission(),
635                normalize_host,
636            );
637
638            validate_policy_against_constraints(&candidate, &constraints)
639                .map_err(NetworkProxyConstraintError::into_anyhow)
640                .with_context(|| format!("{constraint_field} constrained by managed config"))?;
641
642            let mut new_state = build_config_state(candidate.clone(), constraints.clone())
643                .with_context(|| format!("failed to compile updated network {list_name}"))?;
644            new_state.blocked = blocked;
645            new_state.blocked_total = blocked_total;
646
647            let mut guard = self.state.write().await;
648            if guard.constraints != constraints || guard.config != previous_cfg {
649                drop(guard);
650                continue;
651            }
652
653            log_policy_changes(&guard.config, &candidate);
654            *guard = new_state;
655            info!("updated network {list_name} with {normalized_host}");
656            return Ok(());
657        }
658    }
659
660    async fn reload_if_needed(&self) -> Result<()> {
661        match self.reloader.maybe_reload().await? {
662            None => Ok(()),
663            Some(mut new_state) => {
664                let (previous_cfg, blocked, blocked_total) = {
665                    let guard = self.state.read().await;
666                    (
667                        guard.config.clone(),
668                        guard.blocked.clone(),
669                        guard.blocked_total,
670                    )
671                };
672                log_policy_changes(&previous_cfg, &new_state.config);
673                new_state.blocked = blocked;
674                new_state.blocked_total = blocked_total;
675                {
676                    let mut guard = self.state.write().await;
677                    *guard = new_state;
678                }
679                let source = self.reloader.source_label();
680                info!("reloaded config from {source}");
681                Ok(())
682            }
683        }
684    }
685}
686
687#[derive(Clone, Copy)]
688enum DomainListKind {
689    Allow,
690    Deny,
691}
692
693impl DomainListKind {
694    fn list_name(self) -> &'static str {
695        match self {
696            Self::Allow => "allowlist",
697            Self::Deny => "denylist",
698        }
699    }
700
701    fn constraint_field(self) -> &'static str {
702        match self {
703            Self::Allow => "network.allowed_domains",
704            Self::Deny => "network.denied_domains",
705        }
706    }
707
708    fn permission(self) -> NetworkDomainPermission {
709        match self {
710            Self::Allow => NetworkDomainPermission::Allow,
711            Self::Deny => NetworkDomainPermission::Deny,
712        }
713    }
714
715    fn entries(self, network: &crate::config::NetworkProxySettings) -> Vec<String> {
716        match self {
717            Self::Allow => network.allowed_domains().unwrap_or_default(),
718            Self::Deny => network.denied_domains().unwrap_or_default(),
719        }
720    }
721
722    fn opposite_entries(self, network: &crate::config::NetworkProxySettings) -> Vec<String> {
723        match self {
724            Self::Allow => network.denied_domains().unwrap_or_default(),
725            Self::Deny => network.allowed_domains().unwrap_or_default(),
726        }
727    }
728}
729
730pub(crate) fn unix_socket_permissions_supported() -> bool {
731    cfg!(target_os = "macos")
732}
733
734async fn host_resolves_to_non_public_ip(host: &str, port: u16) -> bool {
735    if let Ok(ip) = host.parse::<IpAddr>() {
736        return is_non_public_ip(ip);
737    }
738
739    // Block the request if this DNS lookup fails. We resolve the hostname again when we connect,
740    // so a failed check here does not prove the destination is public.
741    let addrs = match timeout(DNS_LOOKUP_TIMEOUT, lookup_host((host, port))).await {
742        Ok(Ok(addrs)) => addrs,
743        Ok(Err(err)) => {
744            debug!(
745                "blocking host because DNS lookup failed during local/private IP check (host={host}, port={port}): {err}"
746            );
747            return true;
748        }
749        Err(_) => {
750            debug!(
751                "blocking host because DNS lookup timed out during local/private IP check (host={host}, port={port})"
752            );
753            return true;
754        }
755    };
756
757    for addr in addrs {
758        if is_non_public_ip(addr.ip()) {
759            return true;
760        }
761    }
762
763    false
764}
765
766fn log_policy_changes(previous: &NetworkProxyConfig, next: &NetworkProxyConfig) {
767    let previous_allowed_domains = previous.network.allowed_domains().unwrap_or_default();
768    let next_allowed_domains = next.network.allowed_domains().unwrap_or_default();
769    log_domain_list_changes(
770        "allowlist",
771        &previous_allowed_domains,
772        &next_allowed_domains,
773    );
774    let previous_denied_domains = previous.network.denied_domains().unwrap_or_default();
775    let next_denied_domains = next.network.denied_domains().unwrap_or_default();
776    log_domain_list_changes("denylist", &previous_denied_domains, &next_denied_domains);
777}
778
779fn log_domain_list_changes(list_name: &str, previous: &[String], next: &[String]) {
780    let previous_set: HashSet<String> = previous
781        .iter()
782        .map(|entry| entry.to_ascii_lowercase())
783        .collect();
784    let next_set: HashSet<String> = next
785        .iter()
786        .map(|entry| entry.to_ascii_lowercase())
787        .collect();
788
789    let added = next_set
790        .difference(&previous_set)
791        .cloned()
792        .collect::<HashSet<_>>();
793    let removed = previous_set
794        .difference(&next_set)
795        .cloned()
796        .collect::<HashSet<_>>();
797
798    let mut seen_next = HashSet::new();
799    for entry in next {
800        let key = entry.to_ascii_lowercase();
801        if seen_next.insert(key.clone()) && added.contains(&key) {
802            info!("config entry added to {list_name}: {entry}");
803        }
804    }
805
806    let mut seen_previous = HashSet::new();
807    for entry in previous {
808        let key = entry.to_ascii_lowercase();
809        if seen_previous.insert(key.clone()) && removed.contains(&key) {
810            info!("config entry removed from {list_name}: {entry}");
811        }
812    }
813}
814
815fn is_explicit_local_allowlisted(allowed_domains: &[String], host: &Host) -> bool {
816    let normalized_host = host.as_str();
817    allowed_domains.iter().any(|pattern| {
818        let pattern = pattern.trim();
819        if pattern == "*" || pattern.starts_with("*.") || pattern.starts_with("**.") {
820            return false;
821        }
822        if pattern.contains('*') || pattern.contains('?') {
823            return false;
824        }
825        normalize_host(pattern) == normalized_host
826    })
827}
828
829fn unix_timestamp() -> i64 {
830    OffsetDateTime::now_utc().unix_timestamp()
831}
832
833#[cfg(test)]
834pub(crate) fn network_proxy_state_for_policy(
835    mut network: crate::config::NetworkProxySettings,
836) -> NetworkProxyState {
837    network.enabled = true;
838    network.mode = NetworkMode::Full;
839    let config = NetworkProxyConfig { network };
840    let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap();
841
842    NetworkProxyState::with_reloader(state, Arc::new(NoopReloader))
843}
844
845#[cfg(test)]
846struct NoopReloader;
847
848#[cfg(test)]
849#[async_trait]
850impl ConfigReloader for NoopReloader {
851    fn source_label(&self) -> String {
852        "test config state".to_string()
853    }
854
855    async fn maybe_reload(&self) -> Result<Option<ConfigState>> {
856        Ok(None)
857    }
858
859    async fn reload_now(&self) -> Result<ConfigState> {
860        Err(anyhow::anyhow!("force reload is not supported in tests"))
861    }
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867
868    use crate::config::NetworkProxyConfig;
869    use crate::config::NetworkProxySettings;
870    use crate::policy::compile_allowlist_globset;
871    use crate::policy::compile_denylist_globset;
872    use crate::state::NetworkProxyConstraints;
873    use crate::state::build_config_state;
874    use crate::state::validate_policy_against_constraints;
875    use pretty_assertions::assert_eq;
876
877    fn strings(entries: &[&str]) -> Vec<String> {
878        entries.iter().map(|entry| (*entry).to_string()).collect()
879    }
880
881    fn network_settings(allowed_domains: &[&str], denied_domains: &[&str]) -> NetworkProxySettings {
882        let mut network = NetworkProxySettings::default();
883        if !allowed_domains.is_empty() {
884            network.set_allowed_domains(strings(allowed_domains));
885        }
886        if !denied_domains.is_empty() {
887            network.set_denied_domains(strings(denied_domains));
888        }
889        network
890    }
891
892    fn network_settings_with_unix_sockets(
893        allowed_domains: &[&str],
894        denied_domains: &[&str],
895        unix_sockets: &[String],
896    ) -> NetworkProxySettings {
897        let mut network = network_settings(allowed_domains, denied_domains);
898        if !unix_sockets.is_empty() {
899            network.set_allow_unix_sockets(unix_sockets.to_vec());
900        }
901        network
902    }
903
904    #[tokio::test]
905    async fn host_blocked_denied_wins_over_allowed() {
906        let state =
907            network_proxy_state_for_policy(network_settings(&["example.com"], &["example.com"]));
908
909        assert_eq!(
910            state
911                .host_blocked("example.com", /*port*/ 80)
912                .await
913                .unwrap(),
914            HostBlockDecision::Blocked(HostBlockReason::Denied)
915        );
916    }
917
918    #[tokio::test]
919    async fn host_blocked_requires_allowlist_match() {
920        let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
921
922        assert_eq!(
923            state
924                .host_blocked("example.com", /*port*/ 80)
925                .await
926                .unwrap(),
927            HostBlockDecision::Allowed
928        );
929        assert_eq!(
930            // Use a public IP literal to avoid relying on ambient DNS behavior (some networks
931            // resolve unknown hostnames to private IPs, which would trigger `not_allowed_local`).
932            state.host_blocked("8.8.8.8", /*port*/ 80).await.unwrap(),
933            HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
934        );
935    }
936
937    #[tokio::test]
938    async fn add_allowed_domain_removes_matching_deny_entry() {
939        let state = network_proxy_state_for_policy(network_settings(&[], &["example.com"]));
940
941        state.add_allowed_domain("ExAmPlE.CoM").await.unwrap();
942
943        let (allowed, denied) = state.current_patterns().await.unwrap();
944        assert_eq!(allowed, vec!["example.com".to_string()]);
945        assert!(denied.is_empty());
946        assert_eq!(
947            state
948                .host_blocked("example.com", /*port*/ 80)
949                .await
950                .unwrap(),
951            HostBlockDecision::Allowed
952        );
953    }
954
955    #[tokio::test]
956    async fn add_denied_domain_removes_matching_allow_entry() {
957        let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
958
959        state.add_denied_domain("EXAMPLE.COM").await.unwrap();
960
961        let (allowed, denied) = state.current_patterns().await.unwrap();
962        assert!(allowed.is_empty());
963        assert_eq!(denied, vec!["example.com".to_string()]);
964        assert_eq!(
965            state
966                .host_blocked("example.com", /*port*/ 80)
967                .await
968                .unwrap(),
969            HostBlockDecision::Blocked(HostBlockReason::Denied)
970        );
971    }
972
973    #[tokio::test]
974    async fn add_denied_domain_forces_block_with_global_wildcard_allowlist() {
975        let state = network_proxy_state_for_policy(network_settings(&["*"], &[]));
976
977        assert_eq!(
978            // Use a public IP literal to avoid relying on ambient DNS behavior.
979            state.host_blocked("8.8.8.8", /*port*/ 80).await.unwrap(),
980            HostBlockDecision::Allowed
981        );
982
983        state.add_denied_domain("8.8.8.8").await.unwrap();
984
985        let (allowed, denied) = state.current_patterns().await.unwrap();
986        assert_eq!(allowed, vec!["*".to_string()]);
987        assert_eq!(denied, vec!["8.8.8.8".to_string()]);
988        assert_eq!(
989            state.host_blocked("8.8.8.8", /*port*/ 80).await.unwrap(),
990            HostBlockDecision::Blocked(HostBlockReason::Denied)
991        );
992    }
993
994    #[tokio::test]
995    async fn add_allowed_domain_succeeds_when_managed_baseline_allows_expansion() {
996        let config = NetworkProxyConfig {
997            network: {
998                let mut network = network_settings(&["managed.example.com"], &[]);
999                network.enabled = true;
1000                network
1001            },
1002        };
1003        let constraints = NetworkProxyConstraints {
1004            allowed_domains: Some(vec!["managed.example.com".to_string()]),
1005            allowlist_expansion_enabled: Some(true),
1006            ..NetworkProxyConstraints::default()
1007        };
1008        let state = NetworkProxyState::with_reloader(
1009            build_config_state(config, constraints).unwrap(),
1010            Arc::new(NoopReloader),
1011        );
1012
1013        state.add_allowed_domain("user.example.com").await.unwrap();
1014
1015        let (allowed, denied) = state.current_patterns().await.unwrap();
1016        assert_eq!(
1017            allowed,
1018            vec![
1019                "managed.example.com".to_string(),
1020                "user.example.com".to_string()
1021            ]
1022        );
1023        assert!(denied.is_empty());
1024    }
1025
1026    #[tokio::test]
1027    async fn add_allowed_domain_rejects_expansion_when_managed_baseline_is_fixed() {
1028        let config = NetworkProxyConfig {
1029            network: {
1030                let mut network = network_settings(&["managed.example.com"], &[]);
1031                network.enabled = true;
1032                network
1033            },
1034        };
1035        let constraints = NetworkProxyConstraints {
1036            allowed_domains: Some(vec!["managed.example.com".to_string()]),
1037            allowlist_expansion_enabled: Some(false),
1038            ..NetworkProxyConstraints::default()
1039        };
1040        let state = NetworkProxyState::with_reloader(
1041            build_config_state(config, constraints).unwrap(),
1042            Arc::new(NoopReloader),
1043        );
1044
1045        let err = state
1046            .add_allowed_domain("user.example.com")
1047            .await
1048            .expect_err("managed baseline should reject allowlist expansion");
1049
1050        assert!(
1051            format!("{err:#}").contains("network.allowed_domains constrained by managed config"),
1052            "unexpected error: {err:#}"
1053        );
1054    }
1055
1056    #[tokio::test]
1057    async fn add_denied_domain_rejects_expansion_when_managed_baseline_is_fixed() {
1058        let config = NetworkProxyConfig {
1059            network: {
1060                let mut network = network_settings(&[], &["managed.example.com"]);
1061                network.enabled = true;
1062                network
1063            },
1064        };
1065        let constraints = NetworkProxyConstraints {
1066            denied_domains: Some(vec!["managed.example.com".to_string()]),
1067            denylist_expansion_enabled: Some(false),
1068            ..NetworkProxyConstraints::default()
1069        };
1070        let state = NetworkProxyState::with_reloader(
1071            build_config_state(config, constraints).unwrap(),
1072            Arc::new(NoopReloader),
1073        );
1074
1075        let err = state
1076            .add_denied_domain("user.example.com")
1077            .await
1078            .expect_err("managed baseline should reject denylist expansion");
1079
1080        assert!(
1081            format!("{err:#}").contains("network.denied_domains constrained by managed config"),
1082            "unexpected error: {err:#}"
1083        );
1084    }
1085
1086    #[tokio::test]
1087    async fn blocked_snapshot_does_not_consume_entries() {
1088        let state = network_proxy_state_for_policy(NetworkProxySettings::default());
1089
1090        state
1091            .record_blocked(BlockedRequest::new(BlockedRequestArgs {
1092                host: "google.com".to_string(),
1093                reason: "not_allowed".to_string(),
1094                client: None,
1095                method: Some("GET".to_string()),
1096                mode: None,
1097                protocol: "http".to_string(),
1098                decision: Some("ask".to_string()),
1099                source: Some("decider".to_string()),
1100                port: Some(80),
1101            }))
1102            .await
1103            .expect("entry should be recorded");
1104
1105        let snapshot = state
1106            .blocked_snapshot()
1107            .await
1108            .expect("snapshot should succeed");
1109        assert_eq!(snapshot.len(), 1);
1110        assert_eq!(snapshot[0].host, "google.com");
1111        assert_eq!(snapshot[0].decision.as_deref(), Some("ask"));
1112
1113        let drained = state
1114            .drain_blocked()
1115            .await
1116            .expect("drain should include snapshot entry");
1117        assert_eq!(drained.len(), 1);
1118        assert_eq!(drained[0].host, snapshot[0].host);
1119        assert_eq!(drained[0].reason, snapshot[0].reason);
1120        assert_eq!(drained[0].decision, snapshot[0].decision);
1121        assert_eq!(drained[0].source, snapshot[0].source);
1122        assert_eq!(drained[0].port, snapshot[0].port);
1123    }
1124
1125    #[tokio::test]
1126    async fn drain_blocked_returns_buffered_window() {
1127        let state = network_proxy_state_for_policy(NetworkProxySettings::default());
1128
1129        for idx in 0..(MAX_BLOCKED_EVENTS + 5) {
1130            state
1131                .record_blocked(BlockedRequest::new(BlockedRequestArgs {
1132                    host: format!("example{idx}.com"),
1133                    reason: "not_allowed".to_string(),
1134                    client: None,
1135                    method: Some("GET".to_string()),
1136                    mode: None,
1137                    protocol: "http".to_string(),
1138                    decision: Some("ask".to_string()),
1139                    source: Some("decider".to_string()),
1140                    port: Some(80),
1141                }))
1142                .await
1143                .expect("entry should be recorded");
1144        }
1145
1146        let blocked = state.drain_blocked().await.expect("drain should succeed");
1147        assert_eq!(blocked.len(), MAX_BLOCKED_EVENTS);
1148        assert_eq!(blocked[0].host, "example5.com");
1149    }
1150
1151    #[test]
1152    fn blocked_request_violation_log_line_serializes_payload() {
1153        let entry = BlockedRequest {
1154            host: "google.com".to_string(),
1155            reason: "not_allowed".to_string(),
1156            client: Some("127.0.0.1".to_string()),
1157            method: Some("GET".to_string()),
1158            mode: Some(NetworkMode::Full),
1159            protocol: "http".to_string(),
1160            decision: Some("ask".to_string()),
1161            source: Some("decider".to_string()),
1162            port: Some(80),
1163            timestamp: 1_735_689_600,
1164        };
1165
1166        assert_eq!(
1167            blocked_request_violation_log_line(&entry),
1168            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}"#
1169        );
1170    }
1171
1172    #[tokio::test]
1173    async fn host_blocked_subdomain_wildcards_exclude_apex() {
1174        let state = network_proxy_state_for_policy(network_settings(&["*.openai.com"], &[]));
1175
1176        assert_eq!(
1177            state
1178                .host_blocked("api.openai.com", /*port*/ 80)
1179                .await
1180                .unwrap(),
1181            HostBlockDecision::Allowed
1182        );
1183        assert_eq!(
1184            state.host_blocked("openai.com", /*port*/ 80).await.unwrap(),
1185            HostBlockDecision::Blocked(HostBlockReason::NotAllowed)
1186        );
1187    }
1188
1189    #[tokio::test]
1190    async fn host_blocked_global_wildcard_allowlist_allows_public_hosts_except_denylist() {
1191        let state = network_proxy_state_for_policy(network_settings(&["*"], &["evil.example"]));
1192
1193        assert_eq!(
1194            state
1195                .host_blocked("example.com", /*port*/ 80)
1196                .await
1197                .unwrap(),
1198            HostBlockDecision::Allowed
1199        );
1200        assert_eq!(
1201            state
1202                .host_blocked("api.openai.com", /*port*/ 443)
1203                .await
1204                .unwrap(),
1205            HostBlockDecision::Allowed
1206        );
1207        assert_eq!(
1208            state
1209                .host_blocked("evil.example", /*port*/ 80)
1210                .await
1211                .unwrap(),
1212            HostBlockDecision::Blocked(HostBlockReason::Denied)
1213        );
1214    }
1215
1216    #[tokio::test]
1217    async fn host_blocked_rejects_loopback_when_local_binding_disabled() {
1218        let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
1219
1220        assert_eq!(
1221            state.host_blocked("127.0.0.1", /*port*/ 80).await.unwrap(),
1222            HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1223        );
1224        assert_eq!(
1225            state.host_blocked("localhost", /*port*/ 80).await.unwrap(),
1226            HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1227        );
1228    }
1229
1230    #[tokio::test]
1231    async fn host_blocked_allows_loopback_when_explicitly_allowlisted_and_local_binding_disabled() {
1232        let state = network_proxy_state_for_policy(network_settings(&["localhost"], &[]));
1233
1234        assert_eq!(
1235            state.host_blocked("localhost", /*port*/ 80).await.unwrap(),
1236            HostBlockDecision::Allowed
1237        );
1238    }
1239
1240    #[tokio::test]
1241    async fn host_blocked_allows_private_ip_literal_when_explicitly_allowlisted() {
1242        let state = network_proxy_state_for_policy(network_settings(&["10.0.0.1"], &[]));
1243
1244        assert_eq!(
1245            state.host_blocked("10.0.0.1", /*port*/ 80).await.unwrap(),
1246            HostBlockDecision::Allowed
1247        );
1248    }
1249
1250    #[tokio::test]
1251    async fn host_blocked_rejects_scoped_ipv6_literal_when_not_allowlisted() {
1252        let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
1253
1254        assert_eq!(
1255            state
1256                .host_blocked("fe80::1%lo0", /*port*/ 80)
1257                .await
1258                .unwrap(),
1259            HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1260        );
1261    }
1262
1263    #[tokio::test]
1264    async fn host_blocked_allows_scoped_ipv6_literal_when_explicitly_allowlisted() {
1265        let state = network_proxy_state_for_policy(network_settings(&["fe80::1%lo0"], &[]));
1266
1267        assert_eq!(
1268            state
1269                .host_blocked("fe80::1%lo0", /*port*/ 80)
1270                .await
1271                .unwrap(),
1272            HostBlockDecision::Allowed
1273        );
1274    }
1275
1276    #[tokio::test]
1277    async fn host_blocked_rejects_private_ip_literals_when_local_binding_disabled() {
1278        let state = network_proxy_state_for_policy(network_settings(&["example.com"], &[]));
1279
1280        assert_eq!(
1281            state.host_blocked("10.0.0.1", /*port*/ 80).await.unwrap(),
1282            HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1283        );
1284    }
1285
1286    #[tokio::test]
1287    async fn host_blocked_rejects_loopback_when_allowlist_empty() {
1288        let state = network_proxy_state_for_policy(NetworkProxySettings::default());
1289
1290        assert_eq!(
1291            state.host_blocked("127.0.0.1", /*port*/ 80).await.unwrap(),
1292            HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1293        );
1294    }
1295
1296    #[tokio::test]
1297    async fn host_blocked_rejects_allowlisted_hostname_when_dns_lookup_fails() {
1298        let mut network = NetworkProxySettings::default();
1299        network.set_allowed_domains(vec!["does-not-resolve.invalid".to_string()]);
1300        let state = network_proxy_state_for_policy(network);
1301
1302        assert_eq!(
1303            state
1304                .host_blocked("does-not-resolve.invalid", /*port*/ 80)
1305                .await
1306                .unwrap(),
1307            HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1308        );
1309    }
1310
1311    #[test]
1312    fn validate_policy_against_constraints_disallows_widening_allowed_domains() {
1313        let constraints = NetworkProxyConstraints {
1314            allowed_domains: Some(vec!["example.com".to_string()]),
1315            ..NetworkProxyConstraints::default()
1316        };
1317
1318        let config = NetworkProxyConfig {
1319            network: {
1320                let mut network = network_settings(&["example.com", "evil.com"], &[]);
1321                network.enabled = true;
1322                network
1323            },
1324        };
1325
1326        assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1327    }
1328
1329    #[test]
1330    fn validate_policy_against_constraints_allows_expanding_allowed_domains_when_enabled() {
1331        let constraints = NetworkProxyConstraints {
1332            allowed_domains: Some(vec!["example.com".to_string()]),
1333            allowlist_expansion_enabled: Some(true),
1334            ..NetworkProxyConstraints::default()
1335        };
1336
1337        let config = NetworkProxyConfig {
1338            network: {
1339                let mut network = network_settings(&["example.com", "api.openai.com"], &[]);
1340                network.enabled = true;
1341                network
1342            },
1343        };
1344
1345        assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
1346    }
1347
1348    #[test]
1349    fn validate_policy_against_constraints_disallows_widening_mode() {
1350        let constraints = NetworkProxyConstraints {
1351            mode: Some(NetworkMode::Limited),
1352            ..NetworkProxyConstraints::default()
1353        };
1354
1355        let config = NetworkProxyConfig {
1356            network: NetworkProxySettings {
1357                enabled: true,
1358                mode: NetworkMode::Full,
1359                ..NetworkProxySettings::default()
1360            },
1361        };
1362
1363        assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1364    }
1365
1366    #[test]
1367    fn validate_policy_against_constraints_allows_narrowing_wildcard_allowlist() {
1368        let constraints = NetworkProxyConstraints {
1369            allowed_domains: Some(vec!["*.example.com".to_string()]),
1370            ..NetworkProxyConstraints::default()
1371        };
1372
1373        let config = NetworkProxyConfig {
1374            network: {
1375                let mut network = network_settings(&["api.example.com"], &[]);
1376                network.enabled = true;
1377                network
1378            },
1379        };
1380
1381        assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
1382    }
1383
1384    #[test]
1385    fn validate_policy_against_constraints_rejects_widening_wildcard_allowlist() {
1386        let constraints = NetworkProxyConstraints {
1387            allowed_domains: Some(vec!["*.example.com".to_string()]),
1388            ..NetworkProxyConstraints::default()
1389        };
1390
1391        let config = NetworkProxyConfig {
1392            network: {
1393                let mut network = network_settings(&["**.example.com"], &[]);
1394                network.enabled = true;
1395                network
1396            },
1397        };
1398
1399        assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1400    }
1401
1402    #[test]
1403    fn validate_policy_against_constraints_rejects_global_wildcard_in_managed_allowlist() {
1404        let constraints = NetworkProxyConstraints {
1405            allowed_domains: Some(vec!["*".to_string()]),
1406            ..NetworkProxyConstraints::default()
1407        };
1408
1409        let config = NetworkProxyConfig {
1410            network: {
1411                let mut network = network_settings(&["api.example.com"], &[]);
1412                network.enabled = true;
1413                network
1414            },
1415        };
1416
1417        assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1418    }
1419
1420    #[test]
1421    fn validate_policy_against_constraints_rejects_bracketed_global_wildcard_in_managed_allowlist()
1422    {
1423        let constraints = NetworkProxyConstraints {
1424            allowed_domains: Some(vec!["[*]".to_string()]),
1425            ..NetworkProxyConstraints::default()
1426        };
1427
1428        let config = NetworkProxyConfig {
1429            network: {
1430                let mut network = network_settings(&["api.example.com"], &[]);
1431                network.enabled = true;
1432                network
1433            },
1434        };
1435
1436        assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1437    }
1438
1439    #[test]
1440    fn validate_policy_against_constraints_rejects_double_wildcard_bracketed_global_wildcard_in_managed_allowlist()
1441     {
1442        let constraints = NetworkProxyConstraints {
1443            allowed_domains: Some(vec!["**.[*]".to_string()]),
1444            ..NetworkProxyConstraints::default()
1445        };
1446
1447        let config = NetworkProxyConfig {
1448            network: {
1449                let mut network = network_settings(&["api.example.com"], &[]);
1450                network.enabled = true;
1451                network
1452            },
1453        };
1454
1455        assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1456    }
1457
1458    #[test]
1459    fn validate_policy_against_constraints_requires_managed_denied_domains_entries() {
1460        let constraints = NetworkProxyConstraints {
1461            denied_domains: Some(vec!["evil.com".to_string()]),
1462            ..NetworkProxyConstraints::default()
1463        };
1464
1465        let config = NetworkProxyConfig {
1466            network: NetworkProxySettings {
1467                enabled: true,
1468                ..NetworkProxySettings::default()
1469            },
1470        };
1471
1472        assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1473    }
1474
1475    #[test]
1476    fn validate_policy_against_constraints_disallows_expanding_denied_domains_when_fixed() {
1477        let constraints = NetworkProxyConstraints {
1478            denied_domains: Some(vec!["evil.com".to_string()]),
1479            denylist_expansion_enabled: Some(false),
1480            ..NetworkProxyConstraints::default()
1481        };
1482
1483        let config = NetworkProxyConfig {
1484            network: {
1485                let mut network = network_settings(&[], &["evil.com", "more-evil.com"]);
1486                network.enabled = true;
1487                network
1488            },
1489        };
1490
1491        assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1492    }
1493
1494    #[test]
1495    fn validate_policy_against_constraints_disallows_enabling_when_managed_disabled() {
1496        let constraints = NetworkProxyConstraints {
1497            enabled: Some(false),
1498            ..NetworkProxyConstraints::default()
1499        };
1500
1501        let config = NetworkProxyConfig {
1502            network: NetworkProxySettings {
1503                enabled: true,
1504                ..NetworkProxySettings::default()
1505            },
1506        };
1507
1508        assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1509    }
1510
1511    #[test]
1512    fn validate_policy_against_constraints_disallows_allow_local_binding_when_managed_disabled() {
1513        let constraints = NetworkProxyConstraints {
1514            allow_local_binding: Some(false),
1515            ..NetworkProxyConstraints::default()
1516        };
1517
1518        let config = NetworkProxyConfig {
1519            network: NetworkProxySettings {
1520                enabled: true,
1521                allow_local_binding: true,
1522                ..NetworkProxySettings::default()
1523            },
1524        };
1525
1526        assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1527    }
1528
1529    #[test]
1530    fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_without_managed_opt_in()
1531    {
1532        let constraints = NetworkProxyConstraints {
1533            dangerously_allow_all_unix_sockets: Some(false),
1534            ..NetworkProxyConstraints::default()
1535        };
1536
1537        let config = NetworkProxyConfig {
1538            network: NetworkProxySettings {
1539                enabled: true,
1540                dangerously_allow_all_unix_sockets: true,
1541                ..NetworkProxySettings::default()
1542            },
1543        };
1544
1545        assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1546    }
1547
1548    #[test]
1549    fn validate_policy_against_constraints_disallows_allow_all_unix_sockets_when_allowlist_is_managed()
1550     {
1551        let constraints = NetworkProxyConstraints {
1552            allow_unix_sockets: Some(vec!["/tmp/allowed.sock".to_string()]),
1553            ..NetworkProxyConstraints::default()
1554        };
1555
1556        let config = NetworkProxyConfig {
1557            network: NetworkProxySettings {
1558                enabled: true,
1559                dangerously_allow_all_unix_sockets: true,
1560                ..NetworkProxySettings::default()
1561            },
1562        };
1563
1564        assert!(validate_policy_against_constraints(&config, &constraints).is_err());
1565    }
1566
1567    #[test]
1568    fn validate_policy_against_constraints_allows_allow_all_unix_sockets_with_managed_opt_in() {
1569        let constraints = NetworkProxyConstraints {
1570            dangerously_allow_all_unix_sockets: Some(true),
1571            ..NetworkProxyConstraints::default()
1572        };
1573
1574        let config = NetworkProxyConfig {
1575            network: NetworkProxySettings {
1576                enabled: true,
1577                dangerously_allow_all_unix_sockets: true,
1578                ..NetworkProxySettings::default()
1579            },
1580        };
1581
1582        assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
1583    }
1584
1585    #[test]
1586    fn validate_policy_against_constraints_allows_allow_all_unix_sockets_when_unmanaged() {
1587        let constraints = NetworkProxyConstraints::default();
1588
1589        let config = NetworkProxyConfig {
1590            network: NetworkProxySettings {
1591                enabled: true,
1592                dangerously_allow_all_unix_sockets: true,
1593                ..NetworkProxySettings::default()
1594            },
1595        };
1596
1597        assert!(validate_policy_against_constraints(&config, &constraints).is_ok());
1598    }
1599
1600    #[test]
1601    fn compile_globset_is_case_insensitive() {
1602        let patterns = vec!["ExAmPle.CoM".to_string()];
1603        let set = compile_denylist_globset(&patterns).unwrap();
1604        assert!(set.is_match("example.com"));
1605        assert!(set.is_match("EXAMPLE.COM"));
1606    }
1607
1608    #[test]
1609    fn compile_globset_excludes_apex_for_subdomain_patterns() {
1610        let patterns = vec!["*.openai.com".to_string()];
1611        let set = compile_denylist_globset(&patterns).unwrap();
1612        assert!(set.is_match("api.openai.com"));
1613        assert!(!set.is_match("openai.com"));
1614        assert!(!set.is_match("evilopenai.com"));
1615    }
1616
1617    #[test]
1618    fn compile_globset_includes_apex_for_double_wildcard_patterns() {
1619        let patterns = vec!["**.openai.com".to_string()];
1620        let set = compile_denylist_globset(&patterns).unwrap();
1621        assert!(set.is_match("openai.com"));
1622        assert!(set.is_match("api.openai.com"));
1623        assert!(!set.is_match("evilopenai.com"));
1624    }
1625
1626    #[test]
1627    fn compile_globset_rejects_global_wildcard() {
1628        let patterns = vec!["*".to_string()];
1629        assert!(compile_denylist_globset(&patterns).is_err());
1630    }
1631
1632    #[test]
1633    fn compile_globset_allows_global_wildcard_when_enabled() {
1634        let patterns = vec!["*".to_string()];
1635        let set = compile_allowlist_globset(&patterns).unwrap();
1636        assert!(set.is_match("example.com"));
1637        assert!(set.is_match("api.openai.com"));
1638        assert!(set.is_match("localhost"));
1639    }
1640
1641    #[test]
1642    fn compile_globset_rejects_bracketed_global_wildcard() {
1643        let patterns = vec!["[*]".to_string()];
1644        assert!(compile_denylist_globset(&patterns).is_err());
1645    }
1646
1647    #[test]
1648    fn compile_globset_rejects_double_wildcard_bracketed_global_wildcard() {
1649        let patterns = vec!["**.[*]".to_string()];
1650        assert!(compile_denylist_globset(&patterns).is_err());
1651    }
1652
1653    #[test]
1654    fn compile_globset_dedupes_patterns_without_changing_behavior() {
1655        let patterns = vec!["example.com".to_string(), "example.com".to_string()];
1656        let set = compile_denylist_globset(&patterns).unwrap();
1657        assert!(set.is_match("example.com"));
1658        assert!(set.is_match("EXAMPLE.COM"));
1659        assert!(!set.is_match("not-example.com"));
1660    }
1661
1662    #[test]
1663    fn compile_globset_rejects_invalid_patterns() {
1664        let patterns = vec!["[".to_string()];
1665        assert!(compile_denylist_globset(&patterns).is_err());
1666    }
1667
1668    #[test]
1669    fn build_config_state_allows_global_wildcard_allowed_domains() {
1670        let config = NetworkProxyConfig {
1671            network: {
1672                let mut network = network_settings(&["*"], &[]);
1673                network.enabled = true;
1674                network
1675            },
1676        };
1677
1678        assert!(build_config_state(config, NetworkProxyConstraints::default()).is_ok());
1679    }
1680
1681    #[test]
1682    fn build_config_state_allows_bracketed_global_wildcard_allowed_domains() {
1683        let config = NetworkProxyConfig {
1684            network: {
1685                let mut network = network_settings(&["[*]"], &[]);
1686                network.enabled = true;
1687                network
1688            },
1689        };
1690
1691        assert!(build_config_state(config, NetworkProxyConstraints::default()).is_ok());
1692    }
1693
1694    #[test]
1695    fn build_config_state_rejects_global_wildcard_denied_domains() {
1696        let config = NetworkProxyConfig {
1697            network: {
1698                let mut network = network_settings(&["example.com"], &["*"]);
1699                network.enabled = true;
1700                network
1701            },
1702        };
1703
1704        assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err());
1705    }
1706
1707    #[test]
1708    fn build_config_state_rejects_bracketed_global_wildcard_denied_domains() {
1709        let config = NetworkProxyConfig {
1710            network: {
1711                let mut network = network_settings(&["example.com"], &["[*]"]);
1712                network.enabled = true;
1713                network
1714            },
1715        };
1716
1717        assert!(build_config_state(config, NetworkProxyConstraints::default()).is_err());
1718    }
1719
1720    #[cfg(target_os = "macos")]
1721    #[tokio::test]
1722    async fn unix_socket_allowlist_is_respected_on_macos() {
1723        let socket_path = "/tmp/example.sock".to_string();
1724        let state = network_proxy_state_for_policy(network_settings_with_unix_sockets(
1725            &["example.com"],
1726            &[],
1727            std::slice::from_ref(&socket_path),
1728        ));
1729
1730        assert!(state.is_unix_socket_allowed(&socket_path).await.unwrap());
1731        assert!(
1732            !state
1733                .is_unix_socket_allowed("/tmp/not-allowed.sock")
1734                .await
1735                .unwrap()
1736        );
1737    }
1738
1739    #[cfg(target_os = "macos")]
1740    #[tokio::test]
1741    async fn unix_socket_allowlist_resolves_symlinks() {
1742        use std::os::unix::fs::symlink;
1743        use tempfile::tempdir;
1744
1745        let temp_dir = tempdir().unwrap();
1746        let dir = temp_dir.path();
1747
1748        let real = dir.join("real.sock");
1749        let link = dir.join("link.sock");
1750
1751        // The allowlist mechanism is path-based; for test purposes we don't need an actual unix
1752        // domain socket. Any filesystem entry works for canonicalization.
1753        std::fs::write(&real, b"not a socket").unwrap();
1754        symlink(&real, &link).unwrap();
1755
1756        let real_s = real.to_str().unwrap().to_string();
1757        let link_s = link.to_str().unwrap().to_string();
1758
1759        let state = network_proxy_state_for_policy(network_settings_with_unix_sockets(
1760            &["example.com"],
1761            &[],
1762            std::slice::from_ref(&real_s),
1763        ));
1764
1765        assert!(state.is_unix_socket_allowed(&link_s).await.unwrap());
1766    }
1767
1768    #[cfg(target_os = "macos")]
1769    #[tokio::test]
1770    async fn unix_socket_allow_all_flag_bypasses_allowlist() {
1771        let state = network_proxy_state_for_policy({
1772            let mut network = network_settings(&["example.com"], &[]);
1773            network.dangerously_allow_all_unix_sockets = true;
1774            network
1775        });
1776
1777        assert!(state.is_unix_socket_allowed("/tmp/any.sock").await.unwrap());
1778        assert!(!state.is_unix_socket_allowed("relative.sock").await.unwrap());
1779    }
1780
1781    #[cfg(not(target_os = "macos"))]
1782    #[tokio::test]
1783    async fn unix_socket_allowlist_is_rejected_on_non_macos() {
1784        let socket_path = "/tmp/example.sock".to_string();
1785        let state = network_proxy_state_for_policy({
1786            let mut network = network_settings_with_unix_sockets(
1787                &["example.com"],
1788                &[],
1789                std::slice::from_ref(&socket_path),
1790            );
1791            network.dangerously_allow_all_unix_sockets = true;
1792            network
1793        });
1794
1795        assert!(!state.is_unix_socket_allowed(&socket_path).await.unwrap());
1796    }
1797}