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 fn source_label(&self) -> String;
169
170 async fn maybe_reload(&self) -> Result<Option<ConfigState>>;
172
173 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
200pub 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 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 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 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 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 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 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 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 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 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 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 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 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", 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", 80)
914 .await
915 .unwrap(),
916 HostBlockDecision::Allowed
917 );
918 assert_eq!(
919 state.host_blocked("8.8.8.8", 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", 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", 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 state.host_blocked("8.8.8.8", 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", 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", 80)
1168 .await
1169 .unwrap(),
1170 HostBlockDecision::Allowed
1171 );
1172 assert_eq!(
1173 state.host_blocked("openai.com", 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", 80)
1185 .await
1186 .unwrap(),
1187 HostBlockDecision::Allowed
1188 );
1189 assert_eq!(
1190 state
1191 .host_blocked("api.openai.com", 443)
1192 .await
1193 .unwrap(),
1194 HostBlockDecision::Allowed
1195 );
1196 assert_eq!(
1197 state
1198 .host_blocked("evil.example", 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", 80).await.unwrap(),
1211 HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1212 );
1213 assert_eq!(
1214 state.host_blocked("localhost", 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", 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", 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", 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", 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", 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", 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", 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 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}