1use crate::reasons::REASON_POLICY_DENIED;
2use crate::runtime::HostBlockDecision;
3use crate::runtime::HostBlockReason;
4use crate::state::NetworkProxyState;
5use anyhow::Result;
6use async_trait::async_trait;
7use chrono::SecondsFormat;
8use chrono::Utc;
9use std::future::Future;
10use std::sync::Arc;
11
12const AUDIT_TARGET: &str = "codex_otel.network_proxy";
13const POLICY_DECISION_EVENT_NAME: &str = "codex.network_proxy.policy_decision";
14const POLICY_SCOPE_DOMAIN: &str = "domain";
15const POLICY_SCOPE_NON_DOMAIN: &str = "non_domain";
16const POLICY_DECISION_ALLOW: &str = "allow";
17const POLICY_DECISION_DENY: &str = "deny";
18const POLICY_REASON_ALLOW: &str = "allow";
19const DEFAULT_METHOD: &str = "none";
20const DEFAULT_CLIENT_ADDRESS: &str = "unknown";
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum NetworkProtocol {
24 Http,
25 HttpsConnect,
26 Socks5Tcp,
27 Socks5Udp,
28}
29
30impl NetworkProtocol {
31 pub const fn as_policy_protocol(self) -> &'static str {
32 match self {
33 Self::Http => "http",
34 Self::HttpsConnect => "https_connect",
35 Self::Socks5Tcp => "socks5_tcp",
36 Self::Socks5Udp => "socks5_udp",
37 }
38 }
39}
40
41#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
42#[serde(rename_all = "lowercase")]
43pub enum NetworkPolicyDecision {
44 Deny,
45 Ask,
46}
47
48impl NetworkPolicyDecision {
49 pub const fn as_str(self) -> &'static str {
50 match self {
51 Self::Deny => "deny",
52 Self::Ask => "ask",
53 }
54 }
55}
56
57#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
58#[serde(rename_all = "snake_case")]
59pub enum NetworkDecisionSource {
60 BaselinePolicy,
61 ModeGuard,
62 ProxyState,
63 Decider,
64}
65
66impl NetworkDecisionSource {
67 pub const fn as_str(self) -> &'static str {
68 match self {
69 Self::BaselinePolicy => "baseline_policy",
70 Self::ModeGuard => "mode_guard",
71 Self::ProxyState => "proxy_state",
72 Self::Decider => "decider",
73 }
74 }
75}
76
77#[derive(Clone, Debug)]
78pub struct NetworkPolicyRequest {
79 pub protocol: NetworkProtocol,
80 pub host: String,
81 pub port: u16,
82 pub client_addr: Option<String>,
83 pub method: Option<String>,
84 pub command: Option<String>,
85 pub exec_policy_hint: Option<String>,
86}
87
88pub struct NetworkPolicyRequestArgs {
89 pub protocol: NetworkProtocol,
90 pub host: String,
91 pub port: u16,
92 pub client_addr: Option<String>,
93 pub method: Option<String>,
94 pub command: Option<String>,
95 pub exec_policy_hint: Option<String>,
96}
97
98impl NetworkPolicyRequest {
99 pub fn new(args: NetworkPolicyRequestArgs) -> Self {
100 let NetworkPolicyRequestArgs {
101 protocol,
102 host,
103 port,
104 client_addr,
105 method,
106 command,
107 exec_policy_hint,
108 } = args;
109 Self {
110 protocol,
111 host,
112 port,
113 client_addr,
114 method,
115 command,
116 exec_policy_hint,
117 }
118 }
119}
120
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub enum NetworkDecision {
123 Allow,
124 Deny {
125 reason: String,
126 source: NetworkDecisionSource,
127 decision: NetworkPolicyDecision,
128 },
129}
130
131impl NetworkDecision {
132 pub fn deny(reason: impl Into<String>) -> Self {
133 Self::deny_with_source(reason, NetworkDecisionSource::Decider)
134 }
135
136 pub fn ask(reason: impl Into<String>) -> Self {
137 Self::ask_with_source(reason, NetworkDecisionSource::Decider)
138 }
139
140 pub fn deny_with_source(reason: impl Into<String>, source: NetworkDecisionSource) -> Self {
141 let reason = reason.into();
142 let reason = if reason.is_empty() {
143 REASON_POLICY_DENIED.to_string()
144 } else {
145 reason
146 };
147 Self::Deny {
148 reason,
149 source,
150 decision: NetworkPolicyDecision::Deny,
151 }
152 }
153
154 pub fn ask_with_source(reason: impl Into<String>, source: NetworkDecisionSource) -> Self {
155 let reason = reason.into();
156 let reason = if reason.is_empty() {
157 REASON_POLICY_DENIED.to_string()
158 } else {
159 reason
160 };
161 Self::Deny {
162 reason,
163 source,
164 decision: NetworkPolicyDecision::Ask,
165 }
166 }
167}
168
169pub(crate) struct BlockDecisionAuditEventArgs<'a> {
170 pub source: NetworkDecisionSource,
171 pub reason: &'a str,
172 pub protocol: NetworkProtocol,
173 pub server_address: &'a str,
174 pub server_port: u16,
175 pub method: Option<&'a str>,
176 pub client_addr: Option<&'a str>,
177}
178
179pub(crate) fn emit_block_decision_audit_event(
180 state: &NetworkProxyState,
181 args: BlockDecisionAuditEventArgs<'_>,
182) {
183 emit_non_domain_policy_decision_audit_event(state, args, POLICY_DECISION_DENY);
184}
185
186pub(crate) fn emit_allow_decision_audit_event(
187 state: &NetworkProxyState,
188 args: BlockDecisionAuditEventArgs<'_>,
189) {
190 emit_non_domain_policy_decision_audit_event(state, args, POLICY_DECISION_ALLOW);
191}
192
193fn emit_non_domain_policy_decision_audit_event(
194 state: &NetworkProxyState,
195 args: BlockDecisionAuditEventArgs<'_>,
196 decision: &'static str,
197) {
198 emit_policy_audit_event(
199 state,
200 PolicyAuditEventArgs {
201 scope: POLICY_SCOPE_NON_DOMAIN,
202 decision,
203 source: args.source.as_str(),
204 reason: args.reason,
205 protocol: args.protocol,
206 server_address: args.server_address,
207 server_port: args.server_port,
208 method: args.method,
209 client_addr: args.client_addr,
210 policy_override: false,
211 },
212 );
213}
214
215struct PolicyAuditEventArgs<'a> {
216 scope: &'static str,
217 decision: &'a str,
218 source: &'a str,
219 reason: &'a str,
220 protocol: NetworkProtocol,
221 server_address: &'a str,
222 server_port: u16,
223 method: Option<&'a str>,
224 client_addr: Option<&'a str>,
225 policy_override: bool,
226}
227
228fn emit_policy_audit_event(state: &NetworkProxyState, args: PolicyAuditEventArgs<'_>) {
229 let metadata = state.audit_metadata();
230 tracing::event!(
231 target: AUDIT_TARGET,
232 tracing::Level::INFO,
233 event.name = POLICY_DECISION_EVENT_NAME,
234 event.timestamp = %audit_timestamp(),
235 conversation.id = metadata.conversation_id.as_deref(),
236 app.version = metadata.app_version.as_deref(),
237 auth_mode = metadata.auth_mode.as_deref(),
238 originator = metadata.originator.as_deref(),
239 user.account_id = metadata.user_account_id.as_deref(),
240 user.email = metadata.user_email.as_deref(),
241 terminal.type = metadata.terminal_type.as_deref(),
242 model = metadata.model.as_deref(),
243 slug = metadata.slug.as_deref(),
244 network.policy.scope = args.scope,
245 network.policy.decision = args.decision,
246 network.policy.source = args.source,
247 network.policy.reason = args.reason,
248 network.transport.protocol = args.protocol.as_policy_protocol(),
249 server.address = args.server_address,
250 server.port = args.server_port,
251 http.request.method = args.method.unwrap_or(DEFAULT_METHOD),
252 client.address = args.client_addr.unwrap_or(DEFAULT_CLIENT_ADDRESS),
253 network.policy.override = args.policy_override,
254 );
255}
256
257fn audit_timestamp() -> String {
258 Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
259}
260
261#[async_trait]
267pub trait NetworkPolicyDecider: Send + Sync + 'static {
268 async fn decide(&self, req: NetworkPolicyRequest) -> NetworkDecision;
269}
270
271#[async_trait]
272impl<D: NetworkPolicyDecider + ?Sized> NetworkPolicyDecider for Arc<D> {
273 async fn decide(&self, req: NetworkPolicyRequest) -> NetworkDecision {
274 (**self).decide(req).await
275 }
276}
277
278#[async_trait]
279impl<F, Fut> NetworkPolicyDecider for F
280where
281 F: Fn(NetworkPolicyRequest) -> Fut + Send + Sync + 'static,
282 Fut: Future<Output = NetworkDecision> + Send,
283{
284 async fn decide(&self, req: NetworkPolicyRequest) -> NetworkDecision {
285 (self)(req).await
286 }
287}
288
289pub(crate) async fn evaluate_host_policy(
290 state: &NetworkProxyState,
291 decider: Option<&Arc<dyn NetworkPolicyDecider>>,
292 request: &NetworkPolicyRequest,
293) -> Result<NetworkDecision> {
294 let host_decision = state.host_blocked(&request.host, request.port).await?;
295 let (decision, policy_override) = match host_decision {
296 HostBlockDecision::Allowed => (NetworkDecision::Allow, false),
297 HostBlockDecision::Blocked(HostBlockReason::NotAllowed) => {
298 if let Some(decider) = decider {
299 let decider_decision = map_decider_decision(decider.decide(request.clone()).await);
300 let policy_override = matches!(decider_decision, NetworkDecision::Allow);
301 (decider_decision, policy_override)
302 } else {
303 (
304 NetworkDecision::deny_with_source(
305 HostBlockReason::NotAllowed.as_str(),
306 NetworkDecisionSource::BaselinePolicy,
307 ),
308 false,
309 )
310 }
311 }
312 HostBlockDecision::Blocked(reason) => (
313 NetworkDecision::deny_with_source(
314 reason.as_str(),
315 NetworkDecisionSource::BaselinePolicy,
316 ),
317 false,
318 ),
319 };
320
321 let (policy_decision, source, reason) = match &decision {
322 NetworkDecision::Allow => (
323 POLICY_DECISION_ALLOW,
324 if policy_override {
325 NetworkDecisionSource::Decider
326 } else {
327 NetworkDecisionSource::BaselinePolicy
328 },
329 if policy_override {
330 HostBlockReason::NotAllowed.as_str()
331 } else {
332 POLICY_REASON_ALLOW
333 },
334 ),
335 NetworkDecision::Deny {
336 reason,
337 source,
338 decision,
339 } => (decision.as_str(), *source, reason.as_str()),
340 };
341
342 emit_policy_audit_event(
343 state,
344 PolicyAuditEventArgs {
345 scope: POLICY_SCOPE_DOMAIN,
346 decision: policy_decision,
347 source: source.as_str(),
348 reason,
349 protocol: request.protocol,
350 server_address: request.host.as_str(),
351 server_port: request.port,
352 method: request.method.as_deref(),
353 client_addr: request.client_addr.as_deref(),
354 policy_override,
355 },
356 );
357
358 Ok(decision)
359}
360
361fn map_decider_decision(decision: NetworkDecision) -> NetworkDecision {
362 match decision {
363 NetworkDecision::Allow => NetworkDecision::Allow,
364 NetworkDecision::Deny {
365 reason, decision, ..
366 } => NetworkDecision::Deny {
367 reason,
368 source: NetworkDecisionSource::Decider,
369 decision,
370 },
371 }
372}
373
374#[cfg(test)]
375pub(crate) mod test_support {
376 pub(crate) const POLICY_DECISION_EVENT_NAME: &str = super::POLICY_DECISION_EVENT_NAME;
377
378 use std::collections::BTreeMap;
379 use std::fmt;
380 use std::future::Future;
381 use std::sync::Arc;
382 use std::sync::Mutex;
383 use std::sync::atomic::AtomicU64;
384 use std::sync::atomic::Ordering;
385 use tracing::Event;
386 use tracing::Id;
387 use tracing::Metadata;
388 use tracing::Subscriber;
389 use tracing::field::Field;
390 use tracing::field::Visit;
391 use tracing::span::Attributes;
392 use tracing::span::Record;
393 use tracing::subscriber::Interest;
394
395 #[derive(Clone, Debug, PartialEq, Eq)]
396 pub(crate) struct CapturedEvent {
397 pub target: String,
398 pub fields: BTreeMap<String, String>,
399 }
400
401 impl CapturedEvent {
402 pub fn field(&self, name: &str) -> Option<&str> {
403 self.fields.get(name).map(String::as_str)
404 }
405 }
406
407 #[derive(Clone, Default)]
408 struct EventCollector {
409 events: Arc<Mutex<Vec<CapturedEvent>>>,
410 next_span_id: Arc<AtomicU64>,
411 }
412
413 impl EventCollector {
414 fn events(&self) -> Vec<CapturedEvent> {
415 self.events
416 .lock()
417 .unwrap_or_else(std::sync::PoisonError::into_inner)
418 .clone()
419 }
420 }
421
422 impl Subscriber for EventCollector {
423 fn enabled(&self, _metadata: &Metadata<'_>) -> bool {
424 true
425 }
426
427 fn register_callsite(&self, _metadata: &'static Metadata<'static>) -> Interest {
428 Interest::always()
429 }
430
431 fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
432 Some(tracing::level_filters::LevelFilter::TRACE)
433 }
434
435 fn new_span(&self, _span: &Attributes<'_>) -> Id {
436 Id::from_u64(self.next_span_id.fetch_add(1, Ordering::Relaxed) + 1)
437 }
438
439 fn record(&self, _span: &Id, _values: &Record<'_>) {}
440
441 fn record_follows_from(&self, _span: &Id, _follows: &Id) {}
442
443 fn event(&self, event: &Event<'_>) {
444 let mut visitor = FieldVisitor::default();
445 event.record(&mut visitor);
446 self.events
447 .lock()
448 .unwrap_or_else(std::sync::PoisonError::into_inner)
449 .push(CapturedEvent {
450 target: event.metadata().target().to_string(),
451 fields: visitor.fields,
452 });
453 }
454
455 fn enter(&self, _span: &Id) {}
456
457 fn exit(&self, _span: &Id) {}
458 }
459
460 #[derive(Default)]
461 struct FieldVisitor {
462 fields: BTreeMap<String, String>,
463 }
464
465 impl FieldVisitor {
466 fn insert(&mut self, field: &Field, value: impl Into<String>) {
467 self.fields.insert(field.name().to_string(), value.into());
468 }
469 }
470
471 impl Visit for FieldVisitor {
472 fn record_str(&mut self, field: &Field, value: &str) {
473 self.insert(field, value);
474 }
475
476 fn record_bool(&mut self, field: &Field, value: bool) {
477 self.insert(field, value.to_string());
478 }
479
480 fn record_i64(&mut self, field: &Field, value: i64) {
481 self.insert(field, value.to_string());
482 }
483
484 fn record_u64(&mut self, field: &Field, value: u64) {
485 self.insert(field, value.to_string());
486 }
487
488 fn record_i128(&mut self, field: &Field, value: i128) {
489 self.insert(field, value.to_string());
490 }
491
492 fn record_u128(&mut self, field: &Field, value: u128) {
493 self.insert(field, value.to_string());
494 }
495
496 fn record_f64(&mut self, field: &Field, value: f64) {
497 self.insert(field, value.to_string());
498 }
499
500 fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
501 self.insert(field, value.to_string());
502 }
503
504 fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
505 self.insert(field, format!("{value:?}"));
506 }
507 }
508
509 pub(crate) async fn capture_events<F, Fut, T>(f: F) -> (T, Vec<CapturedEvent>)
510 where
511 F: FnOnce() -> Fut,
512 Fut: Future<Output = T>,
513 {
514 let collector = EventCollector::default();
515 let _guard = tracing::subscriber::set_default(collector.clone());
516 let output = f().await;
517 let events = collector.events();
518 (output, events)
519 }
520
521 pub(crate) fn find_event_by_name<'a>(
522 events: &'a [CapturedEvent],
523 event_name: &str,
524 ) -> Option<&'a CapturedEvent> {
525 events
526 .iter()
527 .find(|event| event.field("event.name") == Some(event_name))
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::test_support::capture_events;
534 use super::test_support::find_event_by_name;
535 use super::*;
536 use crate::config::NetworkMode;
537 use crate::config::NetworkProxyConfig;
538 use crate::config::NetworkProxySettings;
539 use crate::reasons::REASON_DENIED;
540 use crate::reasons::REASON_METHOD_NOT_ALLOWED;
541 use crate::reasons::REASON_NOT_ALLOWED;
542 use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
543 use crate::runtime::ConfigReloader;
544 use crate::runtime::ConfigState;
545 use crate::runtime::NetworkProxyAuditMetadata;
546 use crate::state::NetworkProxyConstraints;
547 use crate::state::build_config_state;
548 use crate::state::network_proxy_state_for_policy;
549 use pretty_assertions::assert_eq;
550 use std::sync::Arc;
551 use std::sync::atomic::AtomicUsize;
552 use std::sync::atomic::Ordering;
553
554 const LEGACY_DOMAIN_POLICY_DECISION_EVENT_NAME: &str =
555 "codex.network_proxy.domain_policy_decision";
556 const LEGACY_BLOCK_DECISION_EVENT_NAME: &str = "codex.network_proxy.block_decision";
557
558 #[derive(Clone)]
559 struct StaticReloader {
560 state: ConfigState,
561 }
562
563 #[async_trait]
564 impl ConfigReloader for StaticReloader {
565 async fn maybe_reload(&self) -> anyhow::Result<Option<ConfigState>> {
566 Ok(None)
567 }
568
569 async fn reload_now(&self) -> anyhow::Result<ConfigState> {
570 Ok(self.state.clone())
571 }
572
573 fn source_label(&self) -> String {
574 "static test reloader".to_string()
575 }
576 }
577
578 fn state_with_metadata(metadata: NetworkProxyAuditMetadata) -> NetworkProxyState {
579 let network = NetworkProxySettings {
580 enabled: true,
581 mode: NetworkMode::Full,
582 ..NetworkProxySettings::default()
583 };
584 let config = NetworkProxyConfig { network };
585 let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap();
586 let reloader = Arc::new(StaticReloader {
587 state: state.clone(),
588 });
589 NetworkProxyState::with_reloader_and_audit_metadata(state, reloader, metadata)
590 }
591
592 fn is_rfc3339_utc_millis(timestamp: &str) -> bool {
593 let bytes = timestamp.as_bytes();
594 if bytes.len() != 24 {
595 return false;
596 }
597 bytes[4] == b'-'
598 && bytes[7] == b'-'
599 && bytes[10] == b'T'
600 && bytes[13] == b':'
601 && bytes[16] == b':'
602 && bytes[19] == b'.'
603 && bytes[23] == b'Z'
604 && bytes.iter().enumerate().all(|(idx, value)| match idx {
605 4 | 7 | 10 | 13 | 16 | 19 | 23 => true,
606 _ => value.is_ascii_digit(),
607 })
608 }
609
610 #[tokio::test(flavor = "current_thread")]
611 async fn evaluate_host_policy_emits_domain_event_for_decider_allow_override() {
612 let state = network_proxy_state_for_policy(NetworkProxySettings::default());
613 let calls = Arc::new(AtomicUsize::new(0));
614 let decider: Arc<dyn NetworkPolicyDecider> = Arc::new({
615 let calls = calls.clone();
616 move |_req| {
617 calls.fetch_add(1, Ordering::SeqCst);
618 async { NetworkDecision::Allow }
621 }
622 });
623
624 let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
625 protocol: NetworkProtocol::Http,
626 host: "example.com".to_string(),
627 port: 80,
628 client_addr: None,
629 method: None,
630 command: None,
631 exec_policy_hint: None,
632 });
633
634 let (decision, events) = capture_events(|| async {
635 evaluate_host_policy(&state, Some(&decider), &request)
636 .await
637 .unwrap()
638 })
639 .await;
640 assert_eq!(decision, NetworkDecision::Allow);
641 assert_eq!(calls.load(Ordering::SeqCst), 1);
642
643 let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
644 .expect("expected policy decision audit event");
645 assert_eq!(event.target, AUDIT_TARGET);
646 assert!(event.target.starts_with("codex_otel."));
647 assert_eq!(
648 event.field("network.policy.scope"),
649 Some(POLICY_SCOPE_DOMAIN)
650 );
651 assert_eq!(event.field("network.policy.decision"), Some("allow"));
652 assert_eq!(event.field("network.policy.source"), Some("decider"));
653 assert_eq!(
654 event.field("network.policy.reason"),
655 Some(REASON_NOT_ALLOWED)
656 );
657 assert_eq!(event.field("network.transport.protocol"), Some("http"));
658 assert_eq!(event.field("server.address"), Some("example.com"));
659 assert_eq!(event.field("server.port"), Some("80"));
660 assert_eq!(event.field("http.request.method"), Some(DEFAULT_METHOD));
661 assert_eq!(event.field("client.address"), Some(DEFAULT_CLIENT_ADDRESS));
662 assert_eq!(event.field("network.policy.override"), Some("true"));
663 let timestamp = event
664 .field("event.timestamp")
665 .expect("event timestamp should be present");
666 assert!(is_rfc3339_utc_millis(timestamp));
667 assert_eq!(
668 find_event_by_name(&events, LEGACY_DOMAIN_POLICY_DECISION_EVENT_NAME),
669 None
670 );
671 assert_eq!(
672 find_event_by_name(&events, LEGACY_BLOCK_DECISION_EVENT_NAME),
673 None
674 );
675 }
676
677 #[tokio::test(flavor = "current_thread")]
678 async fn evaluate_host_policy_emits_domain_event_for_baseline_deny() {
679 let state = network_proxy_state_for_policy({
680 let mut network = NetworkProxySettings::default();
681 network.set_allowed_domains(vec!["example.com".to_string()]);
682 network.set_denied_domains(vec!["blocked.com".to_string()]);
683 network
684 });
685 let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
686 protocol: NetworkProtocol::Http,
687 host: "blocked.com".to_string(),
688 port: 80,
689 client_addr: Some("127.0.0.1:1234".to_string()),
690 method: Some("GET".to_string()),
691 command: None,
692 exec_policy_hint: None,
693 });
694
695 let (decision, events) = capture_events(|| async {
696 evaluate_host_policy(&state, None, &request)
697 .await
698 .unwrap()
699 })
700 .await;
701 assert_eq!(
702 decision,
703 NetworkDecision::Deny {
704 reason: REASON_DENIED.to_string(),
705 source: NetworkDecisionSource::BaselinePolicy,
706 decision: NetworkPolicyDecision::Deny,
707 }
708 );
709
710 let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
711 .expect("expected policy decision audit event");
712 assert_eq!(event.field("network.policy.decision"), Some("deny"));
713 assert_eq!(
714 event.field("network.policy.source"),
715 Some("baseline_policy")
716 );
717 assert_eq!(event.field("network.policy.reason"), Some(REASON_DENIED));
718 assert_eq!(event.field("network.policy.override"), Some("false"));
719 assert_eq!(event.field("http.request.method"), Some("GET"));
720 assert_eq!(event.field("client.address"), Some("127.0.0.1:1234"));
721 }
722
723 #[tokio::test(flavor = "current_thread")]
724 async fn evaluate_host_policy_emits_domain_event_for_decider_ask() {
725 let state = network_proxy_state_for_policy(NetworkProxySettings::default());
726 let decider: Arc<dyn NetworkPolicyDecider> =
727 Arc::new(|_req| async { NetworkDecision::ask(REASON_NOT_ALLOWED) });
728 let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
729 protocol: NetworkProtocol::Http,
730 host: "example.com".to_string(),
731 port: 80,
732 client_addr: None,
733 method: Some("GET".to_string()),
734 command: None,
735 exec_policy_hint: None,
736 });
737
738 let (decision, events) = capture_events(|| async {
739 evaluate_host_policy(&state, Some(&decider), &request)
740 .await
741 .unwrap()
742 })
743 .await;
744 assert_eq!(
745 decision,
746 NetworkDecision::Deny {
747 reason: REASON_NOT_ALLOWED.to_string(),
748 source: NetworkDecisionSource::Decider,
749 decision: NetworkPolicyDecision::Ask,
750 }
751 );
752
753 let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
754 .expect("expected policy decision audit event");
755 assert_eq!(event.field("network.policy.decision"), Some("ask"));
756 assert_eq!(event.field("network.policy.source"), Some("decider"));
757 assert_eq!(
758 event.field("network.policy.reason"),
759 Some(REASON_NOT_ALLOWED)
760 );
761 assert_eq!(event.field("network.policy.override"), Some("false"));
762 }
763
764 #[tokio::test(flavor = "current_thread")]
765 async fn evaluate_host_policy_emits_metadata_fields() {
766 let metadata = NetworkProxyAuditMetadata {
767 conversation_id: Some("conversation-1".to_string()),
768 app_version: Some("1.2.3".to_string()),
769 user_account_id: Some("acct-1".to_string()),
770 auth_mode: Some("Chatgpt".to_string()),
771 originator: Some("codex_cli_rs".to_string()),
772 user_email: Some("test@example.com".to_string()),
773 terminal_type: Some("iTerm.app/3.6.5".to_string()),
774 model: Some("gpt-5.3-codex".to_string()),
775 slug: Some("gpt-5.3-codex".to_string()),
776 };
777 let state = state_with_metadata(metadata);
778 let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
779 protocol: NetworkProtocol::Http,
780 host: "example.com".to_string(),
781 port: 80,
782 client_addr: None,
783 method: Some("GET".to_string()),
784 command: None,
785 exec_policy_hint: None,
786 });
787
788 let (_decision, events) = capture_events(|| async {
789 evaluate_host_policy(&state, None, &request)
790 .await
791 .unwrap()
792 })
793 .await;
794
795 let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
796 .expect("expected policy decision audit event");
797 assert_eq!(event.field("conversation.id"), Some("conversation-1"));
798 assert_eq!(event.field("app.version"), Some("1.2.3"));
799 assert_eq!(event.field("auth_mode"), Some("Chatgpt"));
800 assert_eq!(event.field("originator"), Some("codex_cli_rs"));
801 assert_eq!(event.field("user.account_id"), Some("acct-1"));
802 assert_eq!(event.field("user.email"), Some("test@example.com"));
803 assert_eq!(event.field("terminal.type"), Some("iTerm.app/3.6.5"));
804 assert_eq!(event.field("model"), Some("gpt-5.3-codex"));
805 assert_eq!(event.field("slug"), Some("gpt-5.3-codex"));
806 }
807
808 #[tokio::test(flavor = "current_thread")]
809 async fn emit_block_decision_audit_event_emits_non_domain_event() {
810 let state = network_proxy_state_for_policy(NetworkProxySettings::default());
811
812 let (_, events) = capture_events(|| async {
813 emit_block_decision_audit_event(
814 &state,
815 BlockDecisionAuditEventArgs {
816 source: NetworkDecisionSource::ModeGuard,
817 reason: REASON_METHOD_NOT_ALLOWED,
818 protocol: NetworkProtocol::Http,
819 server_address: "unix-socket",
820 server_port: 0,
821 method: Some("POST"),
822 client_addr: None,
823 },
824 );
825 })
826 .await;
827
828 let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
829 .expect("expected policy decision audit event");
830 assert_eq!(event.target, AUDIT_TARGET);
831 assert_eq!(
832 event.field("network.policy.scope"),
833 Some(POLICY_SCOPE_NON_DOMAIN)
834 );
835 assert_eq!(
836 event.field("network.policy.decision"),
837 Some(POLICY_DECISION_DENY)
838 );
839 assert_eq!(event.field("network.policy.source"), Some("mode_guard"));
840 assert_eq!(
841 event.field("network.policy.reason"),
842 Some(REASON_METHOD_NOT_ALLOWED)
843 );
844 assert_eq!(event.field("network.transport.protocol"), Some("http"));
845 assert_eq!(event.field("server.address"), Some("unix-socket"));
846 assert_eq!(event.field("server.port"), Some("0"));
847 assert_eq!(event.field("http.request.method"), Some("POST"));
848 assert_eq!(event.field("client.address"), Some(DEFAULT_CLIENT_ADDRESS));
849 assert_eq!(event.field("network.policy.override"), Some("false"));
850 assert_eq!(
851 find_event_by_name(&events, LEGACY_BLOCK_DECISION_EVENT_NAME),
852 None
853 );
854 }
855
856 #[tokio::test(flavor = "current_thread")]
857 async fn evaluate_host_policy_still_denies_not_allowed_local_without_decider_override() {
858 let state = network_proxy_state_for_policy({
859 let mut network = NetworkProxySettings::default();
860 network.set_allowed_domains(vec!["example.com".to_string()]);
861 network.allow_local_binding = false;
862 network
863 });
864 let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
865 protocol: NetworkProtocol::Http,
866 host: "127.0.0.1".to_string(),
867 port: 80,
868 client_addr: None,
869 method: Some("GET".to_string()),
870 command: None,
871 exec_policy_hint: None,
872 });
873
874 let decision = evaluate_host_policy(&state, None, &request)
875 .await
876 .unwrap();
877 assert_eq!(
878 decision,
879 NetworkDecision::Deny {
880 reason: REASON_NOT_ALLOWED_LOCAL.to_string(),
881 source: NetworkDecisionSource::BaselinePolicy,
882 decision: NetworkPolicyDecision::Deny,
883 }
884 );
885 }
886
887 #[test]
888 fn ask_uses_decider_source_and_ask_decision() {
889 assert_eq!(
890 NetworkDecision::ask(REASON_NOT_ALLOWED),
891 NetworkDecision::Deny {
892 reason: REASON_NOT_ALLOWED.to_string(),
893 source: NetworkDecisionSource::Decider,
894 decision: NetworkPolicyDecision::Ask,
895 }
896 );
897 }
898}