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 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 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 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 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 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 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 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 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 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", 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", 80)
925 .await
926 .unwrap(),
927 HostBlockDecision::Allowed
928 );
929 assert_eq!(
930 state.host_blocked("8.8.8.8", 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", 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", 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 state.host_blocked("8.8.8.8", 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", 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", 80)
1179 .await
1180 .unwrap(),
1181 HostBlockDecision::Allowed
1182 );
1183 assert_eq!(
1184 state.host_blocked("openai.com", 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", 80)
1196 .await
1197 .unwrap(),
1198 HostBlockDecision::Allowed
1199 );
1200 assert_eq!(
1201 state
1202 .host_blocked("api.openai.com", 443)
1203 .await
1204 .unwrap(),
1205 HostBlockDecision::Allowed
1206 );
1207 assert_eq!(
1208 state
1209 .host_blocked("evil.example", 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", 80).await.unwrap(),
1222 HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)
1223 );
1224 assert_eq!(
1225 state.host_blocked("localhost", 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", 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", 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", 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", 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", 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", 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", 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 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}