Files
codex/codex-rs/network-proxy/src/state.rs
mcgrew-oai 9a393c9b6f 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>
2026-02-25 11:46:37 -05:00

327 lines
10 KiB
Rust

use crate::config::NetworkMode;
use crate::config::NetworkProxyConfig;
use crate::mitm::MitmState;
use crate::policy::DomainPattern;
use crate::policy::compile_globset;
use crate::runtime::ConfigState;
use serde::Deserialize;
use std::collections::HashSet;
use std::sync::Arc;
pub use crate::runtime::BlockedRequest;
pub use crate::runtime::BlockedRequestArgs;
pub use crate::runtime::NetworkProxyAuditMetadata;
pub use crate::runtime::NetworkProxyState;
#[cfg(test)]
pub(crate) use crate::runtime::network_proxy_state_for_policy;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct NetworkProxyConstraints {
pub enabled: Option<bool>,
pub mode: Option<NetworkMode>,
pub allow_upstream_proxy: Option<bool>,
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_non_loopback_admin: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
pub allowed_domains: Option<Vec<String>>,
pub denied_domains: Option<Vec<String>>,
pub allow_unix_sockets: Option<Vec<String>>,
pub allow_local_binding: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PartialNetworkProxyConfig {
#[serde(default)]
pub network: PartialNetworkConfig,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct PartialNetworkConfig {
pub enabled: Option<bool>,
pub mode: Option<NetworkMode>,
pub allow_upstream_proxy: Option<bool>,
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_non_loopback_admin: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
#[serde(default)]
pub allowed_domains: Option<Vec<String>>,
#[serde(default)]
pub denied_domains: Option<Vec<String>>,
#[serde(default)]
pub allow_unix_sockets: Option<Vec<String>>,
#[serde(default)]
pub allow_local_binding: Option<bool>,
}
pub fn build_config_state(
config: NetworkProxyConfig,
constraints: NetworkProxyConstraints,
) -> anyhow::Result<ConfigState> {
crate::config::validate_unix_socket_allowlist_paths(&config)?;
let deny_set = compile_globset(&config.network.denied_domains)?;
let allow_set = compile_globset(&config.network.allowed_domains)?;
let mitm = if config.network.mitm {
Some(Arc::new(MitmState::new(
config.network.allow_upstream_proxy,
)?))
} else {
None
};
Ok(ConfigState {
config,
allow_set,
deny_set,
mitm,
constraints,
blocked: std::collections::VecDeque::new(),
blocked_total: 0,
})
}
pub fn validate_policy_against_constraints(
config: &NetworkProxyConfig,
constraints: &NetworkProxyConstraints,
) -> Result<(), NetworkProxyConstraintError> {
fn invalid_value(
field_name: &'static str,
candidate: impl Into<String>,
allowed: impl Into<String>,
) -> NetworkProxyConstraintError {
NetworkProxyConstraintError::InvalidValue {
field_name,
candidate: candidate.into(),
allowed: allowed.into(),
}
}
fn validate<T>(
candidate: T,
validator: impl FnOnce(&T) -> Result<(), NetworkProxyConstraintError>,
) -> Result<(), NetworkProxyConstraintError> {
validator(&candidate)
}
let enabled = config.network.enabled;
if let Some(max_enabled) = constraints.enabled {
validate(enabled, move |candidate| {
if *candidate && !max_enabled {
Err(invalid_value(
"network.enabled",
"true",
"false (disabled by managed config)",
))
} else {
Ok(())
}
})?;
}
if let Some(max_mode) = constraints.mode {
validate(config.network.mode, move |candidate| {
if network_mode_rank(*candidate) > network_mode_rank(max_mode) {
Err(invalid_value(
"network.mode",
format!("{candidate:?}"),
format!("{max_mode:?} or more restrictive"),
))
} else {
Ok(())
}
})?;
}
let allow_upstream_proxy = constraints.allow_upstream_proxy;
validate(
config.network.allow_upstream_proxy,
move |candidate| match allow_upstream_proxy {
Some(true) | None => Ok(()),
Some(false) => {
if *candidate {
Err(invalid_value(
"network.allow_upstream_proxy",
"true",
"false (disabled by managed config)",
))
} else {
Ok(())
}
}
},
)?;
let allow_non_loopback_admin = constraints.dangerously_allow_non_loopback_admin;
validate(
config.network.dangerously_allow_non_loopback_admin,
move |candidate| match allow_non_loopback_admin {
Some(true) | None => Ok(()),
Some(false) => {
if *candidate {
Err(invalid_value(
"network.dangerously_allow_non_loopback_admin",
"true",
"false (disabled by managed config)",
))
} else {
Ok(())
}
}
},
)?;
let allow_non_loopback_proxy = constraints.dangerously_allow_non_loopback_proxy;
validate(
config.network.dangerously_allow_non_loopback_proxy,
move |candidate| match allow_non_loopback_proxy {
Some(true) | None => Ok(()),
Some(false) => {
if *candidate {
Err(invalid_value(
"network.dangerously_allow_non_loopback_proxy",
"true",
"false (disabled by managed config)",
))
} else {
Ok(())
}
}
},
)?;
let allow_all_unix_sockets = constraints
.dangerously_allow_all_unix_sockets
.unwrap_or(constraints.allow_unix_sockets.is_none());
validate(
config.network.dangerously_allow_all_unix_sockets,
move |candidate| {
if *candidate && !allow_all_unix_sockets {
Err(invalid_value(
"network.dangerously_allow_all_unix_sockets",
"true",
"false (disabled by managed config)",
))
} else {
Ok(())
}
},
)?;
if let Some(allow_local_binding) = constraints.allow_local_binding {
validate(config.network.allow_local_binding, move |candidate| {
if *candidate && !allow_local_binding {
Err(invalid_value(
"network.allow_local_binding",
"true",
"false (disabled by managed config)",
))
} else {
Ok(())
}
})?;
}
if let Some(allowed_domains) = &constraints.allowed_domains {
let managed_patterns: Vec<DomainPattern> = allowed_domains
.iter()
.map(|entry| DomainPattern::parse_for_constraints(entry))
.collect();
validate(config.network.allowed_domains.clone(), move |candidate| {
let mut invalid = Vec::new();
for entry in candidate {
let candidate_pattern = DomainPattern::parse_for_constraints(entry);
if !managed_patterns
.iter()
.any(|managed| managed.allows(&candidate_pattern))
{
invalid.push(entry.clone());
}
}
if invalid.is_empty() {
Ok(())
} else {
Err(invalid_value(
"network.allowed_domains",
format!("{invalid:?}"),
"subset of managed allowed_domains",
))
}
})?;
}
if let Some(denied_domains) = &constraints.denied_domains {
let required_set: HashSet<String> = denied_domains
.iter()
.map(|s| s.to_ascii_lowercase())
.collect();
validate(config.network.denied_domains.clone(), move |candidate| {
let candidate_set: HashSet<String> =
candidate.iter().map(|s| s.to_ascii_lowercase()).collect();
let missing: Vec<String> = required_set
.iter()
.filter(|entry| !candidate_set.contains(*entry))
.cloned()
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(invalid_value(
"network.denied_domains",
"missing managed denied_domains entries",
format!("{missing:?}"),
))
}
})?;
}
if let Some(allow_unix_sockets) = &constraints.allow_unix_sockets {
let allowed_set: HashSet<String> = allow_unix_sockets
.iter()
.map(|s| s.to_ascii_lowercase())
.collect();
validate(
config.network.allow_unix_sockets.clone(),
move |candidate| {
let mut invalid = Vec::new();
for entry in candidate {
if !allowed_set.contains(&entry.to_ascii_lowercase()) {
invalid.push(entry.clone());
}
}
if invalid.is_empty() {
Ok(())
} else {
Err(invalid_value(
"network.allow_unix_sockets",
format!("{invalid:?}"),
"subset of managed allow_unix_sockets",
))
}
},
)?;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum NetworkProxyConstraintError {
#[error("invalid value for {field_name}: {candidate} (allowed {allowed})")]
InvalidValue {
field_name: &'static str,
candidate: String,
allowed: String,
},
}
impl NetworkProxyConstraintError {
pub fn into_anyhow(self) -> anyhow::Error {
anyhow::anyhow!(self)
}
}
fn network_mode_rank(mode: NetworkMode) -> u8 {
match mode {
NetworkMode::Limited => 0,
NetworkMode::Full => 1,
}
}