mirror of
https://github.com/openai/codex.git
synced 2026-04-26 15:45:02 +00:00
feat(network-proxy): add embedded OTEL policy audit logging (#12046)
**PR Summary** This PR adds embedded-only OTEL policy audit logging for `codex-network-proxy` and threads audit metadata from `codex-core` into managed proxy startup. ### What changed - Added structured audit event emission in `network_policy.rs` with target `codex_otel.network_proxy`. - Emitted: - `codex.network_proxy.domain_policy_decision` once per domain-policy evaluation. - `codex.network_proxy.block_decision` for non-domain denies. - Added required policy/network fields, RFC3339 UTC millisecond `event.timestamp`, and fallback defaults (`http.request.method="none"`, `client.address="unknown"`). - Added non-domain deny audit emission in HTTP/SOCKS handlers for mode-guard and proxy-state denies, including unix-socket deny paths. - Added `REASON_UNIX_SOCKET_UNSUPPORTED` and used it for unsupported unix-socket auditing. - Added `NetworkProxyAuditMetadata` to runtime/state, re-exported from `lib.rs` and `state.rs`. - Added `start_proxy_with_audit_metadata(...)` in core config, with `start_proxy()` delegating to default metadata. - Wired metadata construction in `codex.rs` from session/auth context, including originator sanitization for OTEL-safe tagging. - Updated `network-proxy/README.md` with embedded-mode audit schema and behavior notes. - Refactored HTTP block-audit emission to a small local helper to reduce duplication. - Preserved existing unix-socket proxy-disabled host/path behavior for responses and blocked history while using an audit-only endpoint override (`server.address="unix-socket"`, `server.port=0`). ### Explicit exclusions - No standalone proxy OTEL startup work. - No `main.rs` binary wiring. - No `standalone_otel.rs`. - No standalone docs/tests. ### Tests - Extended `network_policy.rs` tests for event mapping, metadata propagation, fallbacks, timestamp format, and target prefix. - Extended HTTP tests to assert unix-socket deny block audit events. - Extended SOCKS tests to cover deny emission from handler deny branches. - Added/updated core tests to verify audit metadata threading into managed proxy state. ### Validation run - `just fmt` - `cargo test -p codex-network-proxy` ✅ - `cargo test -p codex-core` ran with one unrelated flaky timeout (`shell_snapshot::tests::snapshot_shell_does_not_inherit_stdin`), and the test passed when rerun directly ✅ --------- Co-authored-by: viyatb-oai <viyatb@openai.com>
This commit is contained in:
@@ -4,9 +4,21 @@ use crate::runtime::HostBlockReason;
|
||||
use crate::state::NetworkProxyState;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::SecondsFormat;
|
||||
use chrono::Utc;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
const AUDIT_TARGET: &str = "codex_otel.network_proxy";
|
||||
const POLICY_DECISION_EVENT_NAME: &str = "codex.network_proxy.policy_decision";
|
||||
const POLICY_SCOPE_DOMAIN: &str = "domain";
|
||||
const POLICY_SCOPE_NON_DOMAIN: &str = "non_domain";
|
||||
const POLICY_DECISION_ALLOW: &str = "allow";
|
||||
const POLICY_DECISION_DENY: &str = "deny";
|
||||
const POLICY_REASON_ALLOW: &str = "allow";
|
||||
const DEFAULT_METHOD: &str = "none";
|
||||
const DEFAULT_CLIENT_ADDRESS: &str = "unknown";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NetworkProtocol {
|
||||
Http,
|
||||
@@ -154,6 +166,98 @@ impl NetworkDecision {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct BlockDecisionAuditEventArgs<'a> {
|
||||
pub source: NetworkDecisionSource,
|
||||
pub reason: &'a str,
|
||||
pub protocol: NetworkProtocol,
|
||||
pub server_address: &'a str,
|
||||
pub server_port: u16,
|
||||
pub method: Option<&'a str>,
|
||||
pub client_addr: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub(crate) fn emit_block_decision_audit_event(
|
||||
state: &NetworkProxyState,
|
||||
args: BlockDecisionAuditEventArgs<'_>,
|
||||
) {
|
||||
emit_non_domain_policy_decision_audit_event(state, args, POLICY_DECISION_DENY);
|
||||
}
|
||||
|
||||
pub(crate) fn emit_allow_decision_audit_event(
|
||||
state: &NetworkProxyState,
|
||||
args: BlockDecisionAuditEventArgs<'_>,
|
||||
) {
|
||||
emit_non_domain_policy_decision_audit_event(state, args, POLICY_DECISION_ALLOW);
|
||||
}
|
||||
|
||||
fn emit_non_domain_policy_decision_audit_event(
|
||||
state: &NetworkProxyState,
|
||||
args: BlockDecisionAuditEventArgs<'_>,
|
||||
decision: &'static str,
|
||||
) {
|
||||
emit_policy_audit_event(
|
||||
state,
|
||||
PolicyAuditEventArgs {
|
||||
scope: POLICY_SCOPE_NON_DOMAIN,
|
||||
decision,
|
||||
source: args.source.as_str(),
|
||||
reason: args.reason,
|
||||
protocol: args.protocol,
|
||||
server_address: args.server_address,
|
||||
server_port: args.server_port,
|
||||
method: args.method,
|
||||
client_addr: args.client_addr,
|
||||
policy_override: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
struct PolicyAuditEventArgs<'a> {
|
||||
scope: &'static str,
|
||||
decision: &'a str,
|
||||
source: &'a str,
|
||||
reason: &'a str,
|
||||
protocol: NetworkProtocol,
|
||||
server_address: &'a str,
|
||||
server_port: u16,
|
||||
method: Option<&'a str>,
|
||||
client_addr: Option<&'a str>,
|
||||
policy_override: bool,
|
||||
}
|
||||
|
||||
fn emit_policy_audit_event(state: &NetworkProxyState, args: PolicyAuditEventArgs<'_>) {
|
||||
let metadata = state.audit_metadata();
|
||||
tracing::event!(
|
||||
target: AUDIT_TARGET,
|
||||
tracing::Level::INFO,
|
||||
event.name = POLICY_DECISION_EVENT_NAME,
|
||||
event.timestamp = %audit_timestamp(),
|
||||
conversation.id = metadata.conversation_id.as_deref(),
|
||||
app.version = metadata.app_version.as_deref(),
|
||||
auth_mode = metadata.auth_mode.as_deref(),
|
||||
originator = metadata.originator.as_deref(),
|
||||
user.account_id = metadata.user_account_id.as_deref(),
|
||||
user.email = metadata.user_email.as_deref(),
|
||||
terminal.type = metadata.terminal_type.as_deref(),
|
||||
model = metadata.model.as_deref(),
|
||||
slug = metadata.slug.as_deref(),
|
||||
network.policy.scope = args.scope,
|
||||
network.policy.decision = args.decision,
|
||||
network.policy.source = args.source,
|
||||
network.policy.reason = args.reason,
|
||||
network.transport.protocol = args.protocol.as_policy_protocol(),
|
||||
server.address = args.server_address,
|
||||
server.port = args.server_port,
|
||||
http.request.method = args.method.unwrap_or(DEFAULT_METHOD),
|
||||
client.address = args.client_addr.unwrap_or(DEFAULT_CLIENT_ADDRESS),
|
||||
network.policy.override = args.policy_override,
|
||||
);
|
||||
}
|
||||
|
||||
fn audit_timestamp() -> String {
|
||||
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
|
||||
}
|
||||
|
||||
/// Decide whether a network request should be allowed.
|
||||
///
|
||||
/// If `command` or `exec_policy_hint` is provided, callers can map exec-policy
|
||||
@@ -187,23 +291,71 @@ pub(crate) async fn evaluate_host_policy(
|
||||
decider: Option<&Arc<dyn NetworkPolicyDecider>>,
|
||||
request: &NetworkPolicyRequest,
|
||||
) -> Result<NetworkDecision> {
|
||||
match state.host_blocked(&request.host, request.port).await? {
|
||||
HostBlockDecision::Allowed => Ok(NetworkDecision::Allow),
|
||||
let host_decision = state.host_blocked(&request.host, request.port).await?;
|
||||
let (decision, policy_override) = match host_decision {
|
||||
HostBlockDecision::Allowed => (NetworkDecision::Allow, false),
|
||||
HostBlockDecision::Blocked(HostBlockReason::NotAllowed) => {
|
||||
if let Some(decider) = decider {
|
||||
Ok(map_decider_decision(decider.decide(request.clone()).await))
|
||||
let decider_decision = map_decider_decision(decider.decide(request.clone()).await);
|
||||
let policy_override = matches!(decider_decision, NetworkDecision::Allow);
|
||||
(decider_decision, policy_override)
|
||||
} else {
|
||||
Ok(NetworkDecision::deny_with_source(
|
||||
HostBlockReason::NotAllowed.as_str(),
|
||||
NetworkDecisionSource::BaselinePolicy,
|
||||
))
|
||||
(
|
||||
NetworkDecision::deny_with_source(
|
||||
HostBlockReason::NotAllowed.as_str(),
|
||||
NetworkDecisionSource::BaselinePolicy,
|
||||
),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
HostBlockDecision::Blocked(reason) => Ok(NetworkDecision::deny_with_source(
|
||||
reason.as_str(),
|
||||
NetworkDecisionSource::BaselinePolicy,
|
||||
)),
|
||||
}
|
||||
HostBlockDecision::Blocked(reason) => (
|
||||
NetworkDecision::deny_with_source(
|
||||
reason.as_str(),
|
||||
NetworkDecisionSource::BaselinePolicy,
|
||||
),
|
||||
false,
|
||||
),
|
||||
};
|
||||
|
||||
let (policy_decision, source, reason) = match &decision {
|
||||
NetworkDecision::Allow => (
|
||||
POLICY_DECISION_ALLOW,
|
||||
if policy_override {
|
||||
NetworkDecisionSource::Decider
|
||||
} else {
|
||||
NetworkDecisionSource::BaselinePolicy
|
||||
},
|
||||
if policy_override {
|
||||
HostBlockReason::NotAllowed.as_str()
|
||||
} else {
|
||||
POLICY_REASON_ALLOW
|
||||
},
|
||||
),
|
||||
NetworkDecision::Deny {
|
||||
reason,
|
||||
source,
|
||||
decision,
|
||||
} => (decision.as_str(), *source, reason.as_str()),
|
||||
};
|
||||
|
||||
emit_policy_audit_event(
|
||||
state,
|
||||
PolicyAuditEventArgs {
|
||||
scope: POLICY_SCOPE_DOMAIN,
|
||||
decision: policy_decision,
|
||||
source: source.as_str(),
|
||||
reason,
|
||||
protocol: request.protocol,
|
||||
server_address: request.host.as_str(),
|
||||
server_port: request.port,
|
||||
method: request.method.as_deref(),
|
||||
client_addr: request.client_addr.as_deref(),
|
||||
policy_override,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(decision)
|
||||
}
|
||||
|
||||
fn map_decider_decision(decision: NetworkDecision) -> NetworkDecision {
|
||||
@@ -219,21 +371,244 @@ fn map_decider_decision(decision: NetworkDecision) -> NetworkDecision {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support {
|
||||
pub(crate) const POLICY_DECISION_EVENT_NAME: &str = super::POLICY_DECISION_EVENT_NAME;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tracing::Event;
|
||||
use tracing::Id;
|
||||
use tracing::Metadata;
|
||||
use tracing::Subscriber;
|
||||
use tracing::field::Field;
|
||||
use tracing::field::Visit;
|
||||
use tracing::span::Attributes;
|
||||
use tracing::span::Record;
|
||||
use tracing::subscriber::Interest;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct CapturedEvent {
|
||||
pub target: String,
|
||||
pub fields: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl CapturedEvent {
|
||||
pub fn field(&self, name: &str) -> Option<&str> {
|
||||
self.fields.get(name).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct EventCollector {
|
||||
events: Arc<Mutex<Vec<CapturedEvent>>>,
|
||||
next_span_id: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl EventCollector {
|
||||
fn events(&self) -> Vec<CapturedEvent> {
|
||||
self.events
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Subscriber for EventCollector {
|
||||
fn enabled(&self, _metadata: &Metadata<'_>) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn register_callsite(&self, _metadata: &'static Metadata<'static>) -> Interest {
|
||||
Interest::always()
|
||||
}
|
||||
|
||||
fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
|
||||
Some(tracing::level_filters::LevelFilter::TRACE)
|
||||
}
|
||||
|
||||
fn new_span(&self, _span: &Attributes<'_>) -> Id {
|
||||
Id::from_u64(self.next_span_id.fetch_add(1, Ordering::Relaxed) + 1)
|
||||
}
|
||||
|
||||
fn record(&self, _span: &Id, _values: &Record<'_>) {}
|
||||
|
||||
fn record_follows_from(&self, _span: &Id, _follows: &Id) {}
|
||||
|
||||
fn event(&self, event: &Event<'_>) {
|
||||
let mut visitor = FieldVisitor::default();
|
||||
event.record(&mut visitor);
|
||||
self.events
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push(CapturedEvent {
|
||||
target: event.metadata().target().to_string(),
|
||||
fields: visitor.fields,
|
||||
});
|
||||
}
|
||||
|
||||
fn enter(&self, _span: &Id) {}
|
||||
|
||||
fn exit(&self, _span: &Id) {}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FieldVisitor {
|
||||
fields: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl FieldVisitor {
|
||||
fn insert(&mut self, field: &Field, value: impl Into<String>) {
|
||||
self.fields.insert(field.name().to_string(), value.into());
|
||||
}
|
||||
}
|
||||
|
||||
impl Visit for FieldVisitor {
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
self.insert(field, value);
|
||||
}
|
||||
|
||||
fn record_bool(&mut self, field: &Field, value: bool) {
|
||||
self.insert(field, value.to_string());
|
||||
}
|
||||
|
||||
fn record_i64(&mut self, field: &Field, value: i64) {
|
||||
self.insert(field, value.to_string());
|
||||
}
|
||||
|
||||
fn record_u64(&mut self, field: &Field, value: u64) {
|
||||
self.insert(field, value.to_string());
|
||||
}
|
||||
|
||||
fn record_i128(&mut self, field: &Field, value: i128) {
|
||||
self.insert(field, value.to_string());
|
||||
}
|
||||
|
||||
fn record_u128(&mut self, field: &Field, value: u128) {
|
||||
self.insert(field, value.to_string());
|
||||
}
|
||||
|
||||
fn record_f64(&mut self, field: &Field, value: f64) {
|
||||
self.insert(field, value.to_string());
|
||||
}
|
||||
|
||||
fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
|
||||
self.insert(field, value.to_string());
|
||||
}
|
||||
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
|
||||
self.insert(field, format!("{value:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn capture_events<F, Fut, T>(f: F) -> (T, Vec<CapturedEvent>)
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = T>,
|
||||
{
|
||||
let collector = EventCollector::default();
|
||||
let _guard = tracing::subscriber::set_default(collector.clone());
|
||||
let output = f().await;
|
||||
let events = collector.events();
|
||||
(output, events)
|
||||
}
|
||||
|
||||
pub(crate) fn find_event_by_name<'a>(
|
||||
events: &'a [CapturedEvent],
|
||||
event_name: &str,
|
||||
) -> Option<&'a CapturedEvent> {
|
||||
events
|
||||
.iter()
|
||||
.find(|event| event.field("event.name") == Some(event_name))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::test_support::capture_events;
|
||||
use super::test_support::find_event_by_name;
|
||||
use super::*;
|
||||
use crate::config::NetworkMode;
|
||||
use crate::config::NetworkProxyConfig;
|
||||
use crate::config::NetworkProxySettings;
|
||||
use crate::reasons::REASON_DENIED;
|
||||
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED;
|
||||
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
|
||||
use crate::runtime::ConfigReloader;
|
||||
use crate::runtime::ConfigState;
|
||||
use crate::runtime::NetworkProxyAuditMetadata;
|
||||
use crate::state::NetworkProxyConstraints;
|
||||
use crate::state::build_config_state;
|
||||
use crate::state::network_proxy_state_for_policy;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluate_host_policy_invokes_decider_for_not_allowed() {
|
||||
const LEGACY_DOMAIN_POLICY_DECISION_EVENT_NAME: &str =
|
||||
"codex.network_proxy.domain_policy_decision";
|
||||
const LEGACY_BLOCK_DECISION_EVENT_NAME: &str = "codex.network_proxy.block_decision";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct StaticReloader {
|
||||
state: ConfigState,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ConfigReloader for StaticReloader {
|
||||
async fn maybe_reload(&self) -> anyhow::Result<Option<ConfigState>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn reload_now(&self) -> anyhow::Result<ConfigState> {
|
||||
Ok(self.state.clone())
|
||||
}
|
||||
|
||||
fn source_label(&self) -> String {
|
||||
"static test reloader".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn state_with_metadata(metadata: NetworkProxyAuditMetadata) -> NetworkProxyState {
|
||||
let network = NetworkProxySettings {
|
||||
enabled: true,
|
||||
mode: NetworkMode::Full,
|
||||
..NetworkProxySettings::default()
|
||||
};
|
||||
let config = NetworkProxyConfig { network };
|
||||
let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap();
|
||||
let reloader = Arc::new(StaticReloader {
|
||||
state: state.clone(),
|
||||
});
|
||||
NetworkProxyState::with_reloader_and_audit_metadata(state, reloader, metadata)
|
||||
}
|
||||
|
||||
fn is_rfc3339_utc_millis(timestamp: &str) -> bool {
|
||||
let bytes = timestamp.as_bytes();
|
||||
if bytes.len() != 24 {
|
||||
return false;
|
||||
}
|
||||
bytes[4] == b'-'
|
||||
&& bytes[7] == b'-'
|
||||
&& bytes[10] == b'T'
|
||||
&& bytes[13] == b':'
|
||||
&& bytes[16] == b':'
|
||||
&& bytes[19] == b'.'
|
||||
&& bytes[23] == b'Z'
|
||||
&& bytes.iter().enumerate().all(|(idx, value)| match idx {
|
||||
4 | 7 | 10 | 13 | 16 | 19 | 23 => true,
|
||||
_ => value.is_ascii_digit(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn evaluate_host_policy_emits_domain_event_for_decider_allow_override() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let decider: Arc<dyn NetworkPolicyDecider> = Arc::new({
|
||||
@@ -251,47 +626,75 @@ mod tests {
|
||||
host: "example.com".to_string(),
|
||||
port: 80,
|
||||
client_addr: None,
|
||||
method: Some("GET".to_string()),
|
||||
method: None,
|
||||
command: None,
|
||||
exec_policy_hint: None,
|
||||
});
|
||||
|
||||
let decision = evaluate_host_policy(&state, Some(&decider), &request)
|
||||
.await
|
||||
.unwrap();
|
||||
let (decision, events) = capture_events(|| async {
|
||||
evaluate_host_policy(&state, Some(&decider), &request)
|
||||
.await
|
||||
.unwrap()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(decision, NetworkDecision::Allow);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
|
||||
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
|
||||
.expect("expected policy decision audit event");
|
||||
assert_eq!(event.target, AUDIT_TARGET);
|
||||
assert!(event.target.starts_with("codex_otel."));
|
||||
assert_eq!(
|
||||
event.field("network.policy.scope"),
|
||||
Some(POLICY_SCOPE_DOMAIN)
|
||||
);
|
||||
assert_eq!(event.field("network.policy.decision"), Some("allow"));
|
||||
assert_eq!(event.field("network.policy.source"), Some("decider"));
|
||||
assert_eq!(
|
||||
event.field("network.policy.reason"),
|
||||
Some(REASON_NOT_ALLOWED)
|
||||
);
|
||||
assert_eq!(event.field("network.transport.protocol"), Some("http"));
|
||||
assert_eq!(event.field("server.address"), Some("example.com"));
|
||||
assert_eq!(event.field("server.port"), Some("80"));
|
||||
assert_eq!(event.field("http.request.method"), Some(DEFAULT_METHOD));
|
||||
assert_eq!(event.field("client.address"), Some(DEFAULT_CLIENT_ADDRESS));
|
||||
assert_eq!(event.field("network.policy.override"), Some("true"));
|
||||
let timestamp = event
|
||||
.field("event.timestamp")
|
||||
.expect("event timestamp should be present");
|
||||
assert!(is_rfc3339_utc_millis(timestamp));
|
||||
assert_eq!(
|
||||
find_event_by_name(&events, LEGACY_DOMAIN_POLICY_DECISION_EVENT_NAME),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
find_event_by_name(&events, LEGACY_BLOCK_DECISION_EVENT_NAME),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluate_host_policy_skips_decider_for_denied() {
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn evaluate_host_policy_emits_domain_event_for_baseline_deny() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
denied_domains: vec!["blocked.com".to_string()],
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let decider: Arc<dyn NetworkPolicyDecider> = Arc::new({
|
||||
let calls = calls.clone();
|
||||
move |_req| {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
async { NetworkDecision::Allow }
|
||||
}
|
||||
});
|
||||
|
||||
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "blocked.com".to_string(),
|
||||
port: 80,
|
||||
client_addr: None,
|
||||
client_addr: Some("127.0.0.1:1234".to_string()),
|
||||
method: Some("GET".to_string()),
|
||||
command: None,
|
||||
exec_policy_hint: None,
|
||||
});
|
||||
|
||||
let decision = evaluate_host_policy(&state, Some(&decider), &request)
|
||||
.await
|
||||
.unwrap();
|
||||
let (decision, events) = capture_events(|| async {
|
||||
evaluate_host_policy(&state, None, &request).await.unwrap()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
decision,
|
||||
NetworkDecision::Deny {
|
||||
@@ -300,25 +703,158 @@ mod tests {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
}
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
|
||||
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
|
||||
.expect("expected policy decision audit event");
|
||||
assert_eq!(event.field("network.policy.decision"), Some("deny"));
|
||||
assert_eq!(
|
||||
event.field("network.policy.source"),
|
||||
Some("baseline_policy")
|
||||
);
|
||||
assert_eq!(event.field("network.policy.reason"), Some(REASON_DENIED));
|
||||
assert_eq!(event.field("network.policy.override"), Some("false"));
|
||||
assert_eq!(event.field("http.request.method"), Some("GET"));
|
||||
assert_eq!(event.field("client.address"), Some("127.0.0.1:1234"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluate_host_policy_skips_decider_for_not_allowed_local() {
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn evaluate_host_policy_emits_domain_event_for_decider_ask() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
|
||||
let decider: Arc<dyn NetworkPolicyDecider> =
|
||||
Arc::new(|_req| async { NetworkDecision::ask(REASON_NOT_ALLOWED) });
|
||||
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "example.com".to_string(),
|
||||
port: 80,
|
||||
client_addr: None,
|
||||
method: Some("GET".to_string()),
|
||||
command: None,
|
||||
exec_policy_hint: None,
|
||||
});
|
||||
|
||||
let (decision, events) = capture_events(|| async {
|
||||
evaluate_host_policy(&state, Some(&decider), &request)
|
||||
.await
|
||||
.unwrap()
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
decision,
|
||||
NetworkDecision::Deny {
|
||||
reason: REASON_NOT_ALLOWED.to_string(),
|
||||
source: NetworkDecisionSource::Decider,
|
||||
decision: NetworkPolicyDecision::Ask,
|
||||
}
|
||||
);
|
||||
|
||||
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
|
||||
.expect("expected policy decision audit event");
|
||||
assert_eq!(event.field("network.policy.decision"), Some("ask"));
|
||||
assert_eq!(event.field("network.policy.source"), Some("decider"));
|
||||
assert_eq!(
|
||||
event.field("network.policy.reason"),
|
||||
Some(REASON_NOT_ALLOWED)
|
||||
);
|
||||
assert_eq!(event.field("network.policy.override"), Some("false"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn evaluate_host_policy_emits_metadata_fields() {
|
||||
let metadata = NetworkProxyAuditMetadata {
|
||||
conversation_id: Some("conversation-1".to_string()),
|
||||
app_version: Some("1.2.3".to_string()),
|
||||
user_account_id: Some("acct-1".to_string()),
|
||||
auth_mode: Some("Chatgpt".to_string()),
|
||||
originator: Some("codex_cli_rs".to_string()),
|
||||
user_email: Some("test@example.com".to_string()),
|
||||
terminal_type: Some("iTerm.app/3.6.5".to_string()),
|
||||
model: Some("gpt-5.3-codex".to_string()),
|
||||
slug: Some("gpt-5.3-codex".to_string()),
|
||||
};
|
||||
let state = state_with_metadata(metadata);
|
||||
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "example.com".to_string(),
|
||||
port: 80,
|
||||
client_addr: None,
|
||||
method: Some("GET".to_string()),
|
||||
command: None,
|
||||
exec_policy_hint: None,
|
||||
});
|
||||
|
||||
let (_decision, events) = capture_events(|| async {
|
||||
evaluate_host_policy(&state, None, &request).await.unwrap()
|
||||
})
|
||||
.await;
|
||||
|
||||
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
|
||||
.expect("expected policy decision audit event");
|
||||
assert_eq!(event.field("conversation.id"), Some("conversation-1"));
|
||||
assert_eq!(event.field("app.version"), Some("1.2.3"));
|
||||
assert_eq!(event.field("auth_mode"), Some("Chatgpt"));
|
||||
assert_eq!(event.field("originator"), Some("codex_cli_rs"));
|
||||
assert_eq!(event.field("user.account_id"), Some("acct-1"));
|
||||
assert_eq!(event.field("user.email"), Some("test@example.com"));
|
||||
assert_eq!(event.field("terminal.type"), Some("iTerm.app/3.6.5"));
|
||||
assert_eq!(event.field("model"), Some("gpt-5.3-codex"));
|
||||
assert_eq!(event.field("slug"), Some("gpt-5.3-codex"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn emit_block_decision_audit_event_emits_non_domain_event() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
|
||||
|
||||
let (_, events) = capture_events(|| async {
|
||||
emit_block_decision_audit_event(
|
||||
&state,
|
||||
BlockDecisionAuditEventArgs {
|
||||
source: NetworkDecisionSource::ModeGuard,
|
||||
reason: REASON_METHOD_NOT_ALLOWED,
|
||||
protocol: NetworkProtocol::Http,
|
||||
server_address: "unix-socket",
|
||||
server_port: 0,
|
||||
method: Some("POST"),
|
||||
client_addr: None,
|
||||
},
|
||||
);
|
||||
})
|
||||
.await;
|
||||
|
||||
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
|
||||
.expect("expected policy decision audit event");
|
||||
assert_eq!(event.target, AUDIT_TARGET);
|
||||
assert_eq!(
|
||||
event.field("network.policy.scope"),
|
||||
Some(POLICY_SCOPE_NON_DOMAIN)
|
||||
);
|
||||
assert_eq!(
|
||||
event.field("network.policy.decision"),
|
||||
Some(POLICY_DECISION_DENY)
|
||||
);
|
||||
assert_eq!(event.field("network.policy.source"), Some("mode_guard"));
|
||||
assert_eq!(
|
||||
event.field("network.policy.reason"),
|
||||
Some(REASON_METHOD_NOT_ALLOWED)
|
||||
);
|
||||
assert_eq!(event.field("network.transport.protocol"), Some("http"));
|
||||
assert_eq!(event.field("server.address"), Some("unix-socket"));
|
||||
assert_eq!(event.field("server.port"), Some("0"));
|
||||
assert_eq!(event.field("http.request.method"), Some("POST"));
|
||||
assert_eq!(event.field("client.address"), Some(DEFAULT_CLIENT_ADDRESS));
|
||||
assert_eq!(event.field("network.policy.override"), Some("false"));
|
||||
assert_eq!(
|
||||
find_event_by_name(&events, LEGACY_BLOCK_DECISION_EVENT_NAME),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn evaluate_host_policy_still_denies_not_allowed_local_without_decider_override() {
|
||||
let state = network_proxy_state_for_policy(NetworkProxySettings {
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
allow_local_binding: false,
|
||||
..NetworkProxySettings::default()
|
||||
});
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let decider: Arc<dyn NetworkPolicyDecider> = Arc::new({
|
||||
let calls = calls.clone();
|
||||
move |_req| {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
async { NetworkDecision::Allow }
|
||||
}
|
||||
});
|
||||
|
||||
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
|
||||
protocol: NetworkProtocol::Http,
|
||||
host: "127.0.0.1".to_string(),
|
||||
@@ -329,9 +865,7 @@ mod tests {
|
||||
exec_policy_hint: None,
|
||||
});
|
||||
|
||||
let decision = evaluate_host_policy(&state, Some(&decider), &request)
|
||||
.await
|
||||
.unwrap();
|
||||
let decision = evaluate_host_policy(&state, None, &request).await.unwrap();
|
||||
assert_eq!(
|
||||
decision,
|
||||
NetworkDecision::Deny {
|
||||
@@ -340,7 +874,6 @@ mod tests {
|
||||
decision: NetworkPolicyDecision::Deny,
|
||||
}
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user