Skip to main content

zerobox_network_proxy/
network_policy.rs

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/// Decide whether a network request should be allowed.
262///
263/// If `command` or `exec_policy_hint` is provided, callers can map exec-policy
264/// approvals to network access (e.g., allow all requests for commands matching
265/// approved prefixes like `curl *`).
266#[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                // The default policy denies all; the decider is consulted for not_allowed
619                // requests and can override that decision.
620                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, /*decider*/ 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, /*decider*/ 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, /*decider*/ 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}