Compare commits

...

3 Commits

Author SHA1 Message Date
Edward Frazer
02525c193a Add Windows install script 2026-02-25 09:35:25 -08:00
Edward Frazer
0f570e20d5 Add macOS and Linux install script 2026-02-25 09:35:25 -08:00
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
19 changed files with 2033 additions and 657 deletions

View File

@@ -494,6 +494,11 @@ jobs:
--package codex-responses-api-proxy \
--package codex-sdk
- name: Stage installer scripts
run: |
cp scripts/install/install.sh dist/install.sh
cp scripts/install/install.ps1 dist/install.ps1
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:

230
MODULE.bazel.lock generated

File diff suppressed because one or more lines are too long

834
codex-rs/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -190,6 +190,7 @@ use codex_core::auth::login_with_chatgpt_auth_tokens;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigService;
use codex_core::config::NetworkProxyAuditMetadata;
use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::types::McpServerTransportConfig;
@@ -1751,6 +1752,7 @@ impl CodexMessageProcessor {
None,
None,
managed_network_requirements_enabled,
NetworkProxyAuditMetadata::default(),
)
.await
{

View File

@@ -7,6 +7,7 @@ use std::path::PathBuf;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::NetworkProxyAuditMetadata;
use codex_core::exec_env::create_env;
use codex_core::landlock::spawn_command_under_linux_sandbox;
#[cfg(target_os = "macos")]
@@ -223,6 +224,7 @@ async fn run_command_under_sandbox(
None,
None,
managed_network_requirements_enabled,
NetworkProxyAuditMetadata::default(),
)
.await
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?,

View File

@@ -58,6 +58,7 @@ use codex_hooks::HookResult;
use codex_hooks::Hooks;
use codex_hooks::HooksConfig;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyAuditMetadata;
use codex_network_proxy::normalize_host;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ExecPolicyAmendment;
@@ -877,6 +878,7 @@ impl Session {
network_policy_decider: Option<Arc<dyn codex_network_proxy::NetworkPolicyDecider>>,
blocked_request_observer: Option<Arc<dyn codex_network_proxy::BlockedRequestObserver>>,
managed_network_requirements_enabled: bool,
audit_metadata: NetworkProxyAuditMetadata,
) -> anyhow::Result<(StartedNetworkProxy, SessionNetworkProxyRuntime)> {
let network_proxy = spec
.start_proxy(
@@ -884,6 +886,7 @@ impl Session {
network_policy_decider,
blocked_request_observer,
managed_network_requirements_enabled,
audit_metadata,
)
.await
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?;
@@ -1198,21 +1201,37 @@ impl Session {
let auth = auth.as_ref();
let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from);
let account_id = auth.and_then(CodexAuth::get_account_id);
let account_email = auth.and_then(CodexAuth::get_account_email);
let originator = crate::default_client::originator().value;
let terminal_type = terminal::user_agent();
let session_model = session_configuration.collaboration_mode.model().to_string();
let mut otel_manager = OtelManager::new(
conversation_id,
session_configuration.collaboration_mode.model(),
session_configuration.collaboration_mode.model(),
auth.and_then(CodexAuth::get_account_id),
auth.and_then(CodexAuth::get_account_email),
session_model.as_str(),
session_model.as_str(),
account_id.clone(),
account_email.clone(),
auth_mode,
crate::default_client::originator().value,
originator.clone(),
config.otel.log_user_prompt,
terminal::user_agent(),
terminal_type.clone(),
session_configuration.session_source.clone(),
);
if let Some(service_name) = session_configuration.metrics_service_name.as_deref() {
otel_manager = otel_manager.with_metrics_service_name(service_name);
}
let network_proxy_audit_metadata = NetworkProxyAuditMetadata {
conversation_id: Some(conversation_id.to_string()),
app_version: Some(env!("CARGO_PKG_VERSION").to_string()),
user_account_id: account_id,
auth_mode: auth_mode.map(|mode| mode.to_string()),
originator: Some(originator),
user_email: account_email,
terminal_type: Some(terminal_type),
model: Some(session_model.clone()),
slug: Some(session_model),
};
config.features.emit_metrics(&otel_manager);
otel_manager.counter(
"codex.thread.started",
@@ -1319,6 +1338,7 @@ impl Session {
network_policy_decider.as_ref().map(Arc::clone),
blocked_request_observer.as_ref().map(Arc::clone),
managed_network_requirements_enabled,
network_proxy_audit_metadata,
)
.await?;
(Some(network_proxy), Some(session_network_proxy))

View File

@@ -100,6 +100,7 @@ pub mod types;
pub use codex_config::Constrained;
pub use codex_config::ConstraintError;
pub use codex_config::ConstraintResult;
pub use codex_network_proxy::NetworkProxyAuditMetadata;
pub use network_proxy_spec::NetworkProxySpec;
pub use network_proxy_spec::StartedNetworkProxy;

View File

@@ -6,6 +6,7 @@ use codex_network_proxy::ConfigState;
use codex_network_proxy::NetworkDecision;
use codex_network_proxy::NetworkPolicyDecider;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyAuditMetadata;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkProxyConstraints;
use codex_network_proxy::NetworkProxyHandle;
@@ -106,13 +107,9 @@ impl NetworkProxySpec {
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
enable_network_approval_flow: bool,
audit_metadata: NetworkProxyAuditMetadata,
) -> std::io::Result<StartedNetworkProxy> {
let state =
build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| {
std::io::Error::other(format!("failed to build network proxy state: {err}"))
})?;
let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone()));
let state = NetworkProxyState::with_reloader(state, reloader);
let state = self.build_state_with_audit_metadata(audit_metadata)?;
let mut builder = NetworkProxy::builder().state(Arc::new(state));
if enable_network_approval_flow
&& matches!(
@@ -142,6 +139,22 @@ impl NetworkProxySpec {
Ok(StartedNetworkProxy::new(proxy, handle))
}
fn build_state_with_audit_metadata(
&self,
audit_metadata: NetworkProxyAuditMetadata,
) -> std::io::Result<NetworkProxyState> {
let state =
build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| {
std::io::Error::other(format!("failed to build network proxy state: {err}"))
})?;
let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone()));
Ok(NetworkProxyState::with_reloader_and_audit_metadata(
state,
reloader,
audit_metadata,
))
}
fn apply_requirements(
mut config: NetworkProxyConfig,
requirements: &NetworkConstraints,
@@ -205,3 +218,28 @@ impl NetworkProxySpec {
(config, constraints)
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn build_state_with_audit_metadata_threads_metadata_to_state() {
let spec = NetworkProxySpec {
config: NetworkProxyConfig::default(),
constraints: NetworkProxyConstraints::default(),
};
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()),
..NetworkProxyAuditMetadata::default()
};
let state = spec
.build_state_with_audit_metadata(metadata.clone())
.expect("state should build");
assert_eq!(state.audit_metadata(), &metadata);
}
}

View File

@@ -15,6 +15,7 @@ workspace = true
anyhow = { workspace = true }
async-trait = { workspace = true }
clap = { workspace = true, features = ["derive"] }
chrono = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-home-dir = { workspace = true }
codex-utils-rustls-provider = { workspace = true }

View File

@@ -137,6 +137,45 @@ the decider can auto-allow network requests originating from that command.
**Important:** Explicit deny rules still win. The decider only gets a chance to override
`not_allowed` (allowlist misses), not `denied` or `not_allowed_local`.
## OTEL Audit Events (embedded/managed)
When `codex-network-proxy` is embedded in managed Codex runtime, policy decisions emit structured
OTEL-compatible events with `target=codex_otel.network_proxy`.
Event name:
- `codex.network_proxy.policy_decision`
- emitted for each policy decision (`domain` and `non_domain`).
- `network.policy.scope = "domain"` for host-policy evaluations (`evaluate_host_policy`).
- `network.policy.scope = "non_domain"` for mode-guard/proxy-state checks (including unix-socket guard paths and unix-socket allow decisions).
Common fields:
- `event.name`
- `event.timestamp` (RFC3339 UTC, millisecond precision)
- optional metadata:
- `conversation.id`
- `app.version`
- `user.account_id`
- policy/network:
- `network.policy.scope` (`domain` or `non_domain`)
- `network.policy.decision` (`allow`, `deny`, or `ask`)
- `network.policy.source` (`baseline_policy`, `mode_guard`, `proxy_state`, `decider`)
- `network.policy.reason`
- `network.transport.protocol`
- `server.address`
- `server.port`
- `http.request.method` (defaults to `"none"` when absent)
- `client.address` (defaults to `"unknown"` when absent)
- `network.policy.override` (`true` only when decider-allow overrides baseline `not_allowed`)
Unix-socket block-path audits use sentinel endpoint values:
- `server.address = "unix-socket"`
- `server.port = 0`
Audit events intentionally avoid logging full URL/path/query data.
## Admin API
The admin API is a small HTTP server intended for debugging and runtime adjustments.

View File

@@ -1,5 +1,6 @@
use crate::config::NetworkMode;
use crate::mitm;
use crate::network_policy::BlockDecisionAuditEventArgs;
use crate::network_policy::NetworkDecision;
use crate::network_policy::NetworkDecisionSource;
use crate::network_policy::NetworkPolicyDecider;
@@ -7,12 +8,15 @@ use crate::network_policy::NetworkPolicyDecision;
use crate::network_policy::NetworkPolicyRequest;
use crate::network_policy::NetworkPolicyRequestArgs;
use crate::network_policy::NetworkProtocol;
use crate::network_policy::emit_allow_decision_audit_event;
use crate::network_policy::emit_block_decision_audit_event;
use crate::network_policy::evaluate_host_policy;
use crate::policy::normalize_host;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
use crate::reasons::REASON_MITM_REQUIRED;
use crate::reasons::REASON_NOT_ALLOWED;
use crate::reasons::REASON_PROXY_DISABLED;
use crate::reasons::REASON_UNIX_SOCKET_UNSUPPORTED;
use crate::responses::PolicyDecisionDetails;
use crate::responses::blocked_header_value;
use crate::responses::blocked_message_with_policy;
@@ -176,6 +180,7 @@ async fn http_connect_accept(
client_addr(&req),
Some("CONNECT".to_string()),
NetworkProtocol::HttpsConnect,
None,
)
.await);
}
@@ -247,6 +252,18 @@ async fn http_connect_accept(
if mode == NetworkMode::Limited && mitm_state.is_none() {
// Limited mode is designed to be read-only. Without MITM, a CONNECT tunnel would hide the
// inner HTTP method/headers from the proxy, effectively bypassing method policy.
emit_http_block_decision_audit_event(
&app_state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_MITM_REQUIRED,
protocol: NetworkProtocol::HttpsConnect,
server_address: host.as_str(),
server_port: authority.port,
method: Some("CONNECT"),
client_addr: client.as_deref(),
},
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_MITM_REQUIRED,
@@ -449,10 +466,23 @@ async fn http_plain_proxy(
client_addr(&req),
Some(req.method().as_str().to_string()),
NetworkProtocol::Http,
Some(("unix-socket", 0)),
)
.await);
}
if !method_allowed {
emit_http_block_decision_audit_event(
&app_state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_METHOD_NOT_ALLOWED,
protocol: NetworkProtocol::Http,
server_address: "unix-socket",
server_port: 0,
method: Some(req.method().as_str()),
client_addr: client.as_deref(),
},
);
let client = client.as_deref().unwrap_or_default();
let method = req.method();
warn!(
@@ -462,6 +492,18 @@ async fn http_plain_proxy(
}
if !unix_socket_permissions_supported() {
emit_http_block_decision_audit_event(
&app_state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ProxyState,
reason: REASON_UNIX_SOCKET_UNSUPPORTED,
protocol: NetworkProtocol::Http,
server_address: "unix-socket",
server_port: 0,
method: Some(req.method().as_str()),
client_addr: client.as_deref(),
},
);
warn!("unix socket proxy unsupported on this platform (path={socket_path})");
return Ok(text_response(
StatusCode::NOT_IMPLEMENTED,
@@ -471,6 +513,18 @@ async fn http_plain_proxy(
return match app_state.is_unix_socket_allowed(&socket_path).await {
Ok(true) => {
emit_http_allow_decision_audit_event(
&app_state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ProxyState,
reason: "allow",
protocol: NetworkProtocol::Http,
server_address: "unix-socket",
server_port: 0,
method: Some(req.method().as_str()),
client_addr: client.as_deref(),
},
);
let client = client.as_deref().unwrap_or_default();
info!("unix socket allowed (client={client}, path={socket_path})");
match proxy_via_unix_socket(req, &socket_path).await {
@@ -485,6 +539,18 @@ async fn http_plain_proxy(
}
}
Ok(false) => {
emit_http_block_decision_audit_event(
&app_state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ProxyState,
reason: REASON_NOT_ALLOWED,
protocol: NetworkProtocol::Http,
server_address: "unix-socket",
server_port: 0,
method: Some(req.method().as_str()),
client_addr: client.as_deref(),
},
);
let client = client.as_deref().unwrap_or_default();
warn!("unix socket blocked (client={client}, path={socket_path})");
Ok(json_blocked("unix-socket", REASON_NOT_ALLOWED, None))
@@ -528,6 +594,7 @@ async fn http_plain_proxy(
client_addr(&req),
Some(req.method().as_str().to_string()),
NetworkProtocol::Http,
None,
)
.await);
}
@@ -581,6 +648,18 @@ async fn http_plain_proxy(
}
if !method_allowed {
emit_http_block_decision_audit_event(
&app_state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_METHOD_NOT_ALLOWED,
protocol: NetworkProtocol::Http,
server_address: host.as_str(),
server_port: port,
method: Some(req.method().as_str()),
client_addr: client.as_deref(),
},
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_METHOD_NOT_ALLOWED,
@@ -794,7 +873,23 @@ async fn proxy_disabled_response(
client: Option<String>,
method: Option<String>,
protocol: NetworkProtocol,
audit_endpoint_override: Option<(&str, u16)>,
) -> Response {
let (audit_server_address, audit_server_port) =
audit_endpoint_override.unwrap_or((host.as_str(), port));
emit_http_block_decision_audit_event(
app_state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ProxyState,
reason: REASON_PROXY_DISABLED,
protocol,
server_address: audit_server_address,
server_port: audit_server_port,
method: method.as_deref(),
client_addr: client.as_deref(),
},
);
let blocked_host = host.clone();
let _ = app_state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
@@ -837,6 +932,20 @@ fn text_response(status: StatusCode, body: &str) -> Response {
.unwrap_or_else(|_| Response::new(Body::from(body.to_string())))
}
fn emit_http_block_decision_audit_event(
app_state: &NetworkProxyState,
args: BlockDecisionAuditEventArgs<'_>,
) {
emit_block_decision_audit_event(app_state, args);
}
fn emit_http_allow_decision_audit_event(
app_state: &NetworkProxyState,
args: BlockDecisionAuditEventArgs<'_>,
) {
emit_allow_decision_audit_event(app_state, args);
}
#[derive(Serialize)]
struct BlockedResponse<'a> {
status: &'static str,
@@ -911,6 +1020,80 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test(flavor = "current_thread")]
async fn http_plain_proxy_blocks_unix_socket_when_method_not_allowed() {
let state = Arc::new(network_proxy_state_for_policy(
NetworkProxySettings::default(),
));
state
.set_network_mode(NetworkMode::Limited)
.await
.expect("network mode should update");
let mut req = Request::builder()
.method(Method::POST)
.uri("http://example.com")
.header("x-unix-socket", "/tmp/test.sock")
.body(Body::empty())
.expect("request should build");
req.extensions_mut().insert(state);
let response = http_plain_proxy(None, req).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-method-policy"
);
}
#[tokio::test(flavor = "current_thread")]
async fn http_plain_proxy_rejects_unix_socket_when_not_allowlisted() {
let state = Arc::new(network_proxy_state_for_policy(
NetworkProxySettings::default(),
));
let mut req = Request::builder()
.method(Method::GET)
.uri("http://example.com")
.header("x-unix-socket", "/tmp/test.sock")
.body(Body::empty())
.expect("request should build");
req.extensions_mut().insert(state);
let response = http_plain_proxy(None, req).await.unwrap();
if cfg!(target_os = "macos") {
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-allowlist"
);
} else {
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
}
}
#[cfg(target_os = "macos")]
#[tokio::test(flavor = "current_thread")]
async fn http_plain_proxy_attempts_allowed_unix_socket_proxy() {
let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
allow_unix_sockets: vec!["/tmp/test.sock".to_string()],
..NetworkProxySettings::default()
}));
let mut req = Request::builder()
.method(Method::GET)
.uri("http://example.com")
.header("x-unix-socket", "/tmp/test.sock")
.body(Body::empty())
.expect("request should build");
req.extensions_mut().insert(state);
let response = http_plain_proxy(None, req).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
}
#[tokio::test]
async fn http_connect_accept_denies_denylisted_host() {
let policy = NetworkProxySettings {

View File

@@ -43,6 +43,7 @@ pub use runtime::BlockedRequestObserver;
pub use runtime::ConfigReloader;
pub use runtime::ConfigState;
pub use runtime::NetworkProxyState;
pub use state::NetworkProxyAuditMetadata;
pub use state::NetworkProxyConstraintError;
pub use state::NetworkProxyConstraints;
pub use state::PartialNetworkConfig;

View File

@@ -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]

View File

@@ -5,3 +5,4 @@ pub(crate) const REASON_NOT_ALLOWED: &str = "not_allowed";
pub(crate) const REASON_NOT_ALLOWED_LOCAL: &str = "not_allowed_local";
pub(crate) const REASON_POLICY_DENIED: &str = "policy_denied";
pub(crate) const REASON_PROXY_DISABLED: &str = "proxy_disabled";
pub(crate) const REASON_UNIX_SOCKET_UNSUPPORTED: &str = "unix_socket_unsupported";

View File

@@ -38,6 +38,19 @@ const MAX_BLOCKED_EVENTS: usize = 200;
const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
const NETWORK_POLICY_VIOLATION_PREFIX: &str = "CODEX_NETWORK_POLICY_VIOLATION";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct NetworkProxyAuditMetadata {
pub conversation_id: Option<String>,
pub app_version: Option<String>,
pub user_account_id: Option<String>,
pub auth_mode: Option<String>,
pub originator: Option<String>,
pub user_email: Option<String>,
pub terminal_type: Option<String>,
pub model: Option<String>,
pub slug: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HostBlockReason {
Denied,
@@ -187,6 +200,7 @@ pub struct NetworkProxyState {
state: Arc<RwLock<ConfigState>>,
reloader: Arc<dyn ConfigReloader>,
blocked_request_observer: Arc<RwLock<Option<Arc<dyn BlockedRequestObserver>>>>,
audit_metadata: NetworkProxyAuditMetadata,
}
impl std::fmt::Debug for NetworkProxyState {
@@ -203,24 +217,57 @@ impl Clone for NetworkProxyState {
state: self.state.clone(),
reloader: self.reloader.clone(),
blocked_request_observer: self.blocked_request_observer.clone(),
audit_metadata: self.audit_metadata.clone(),
}
}
}
impl NetworkProxyState {
pub fn with_reloader(state: ConfigState, reloader: Arc<dyn ConfigReloader>) -> Self {
Self::with_reloader_and_blocked_observer(state, reloader, None)
Self::with_reloader_and_audit_metadata(
state,
reloader,
NetworkProxyAuditMetadata::default(),
)
}
pub fn with_reloader_and_blocked_observer(
state: ConfigState,
reloader: Arc<dyn ConfigReloader>,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
) -> Self {
Self::with_reloader_and_audit_metadata_and_blocked_observer(
state,
reloader,
NetworkProxyAuditMetadata::default(),
blocked_request_observer,
)
}
pub fn with_reloader_and_audit_metadata(
state: ConfigState,
reloader: Arc<dyn ConfigReloader>,
audit_metadata: NetworkProxyAuditMetadata,
) -> Self {
Self::with_reloader_and_audit_metadata_and_blocked_observer(
state,
reloader,
audit_metadata,
None,
)
}
pub fn with_reloader_and_audit_metadata_and_blocked_observer(
state: ConfigState,
reloader: Arc<dyn ConfigReloader>,
audit_metadata: NetworkProxyAuditMetadata,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
) -> Self {
Self {
state: Arc::new(RwLock::new(state)),
reloader,
blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)),
audit_metadata,
}
}
@@ -232,6 +279,10 @@ impl NetworkProxyState {
*observer = blocked_request_observer;
}
pub fn audit_metadata(&self) -> &NetworkProxyAuditMetadata {
&self.audit_metadata
}
pub async fn current_cfg(&self) -> Result<NetworkProxyConfig> {
// Callers treat `NetworkProxyState` as a live view of policy. We reload-on-demand so edits to
// `config.toml` (including Codex-managed writes) take effect without a restart.

View File

@@ -1,4 +1,5 @@
use crate::config::NetworkMode;
use crate::network_policy::BlockDecisionAuditEventArgs;
use crate::network_policy::NetworkDecision;
use crate::network_policy::NetworkDecisionSource;
use crate::network_policy::NetworkPolicyDecider;
@@ -6,6 +7,7 @@ use crate::network_policy::NetworkPolicyDecision;
use crate::network_policy::NetworkPolicyRequest;
use crate::network_policy::NetworkPolicyRequestArgs;
use crate::network_policy::NetworkProtocol;
use crate::network_policy::emit_block_decision_audit_event;
use crate::network_policy::evaluate_host_policy;
use crate::policy::normalize_host;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
@@ -152,6 +154,15 @@ async fn handle_socks5_tcp(
match app_state.enabled().await {
Ok(true) => {}
Ok(false) => {
emit_socks_block_decision_audit_event(
&app_state,
NetworkDecisionSource::ProxyState,
REASON_PROXY_DISABLED,
NetworkProtocol::Socks5Tcp,
host.as_str(),
port,
client.as_deref(),
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_PROXY_DISABLED,
@@ -185,6 +196,15 @@ async fn handle_socks5_tcp(
match app_state.network_mode().await {
Ok(NetworkMode::Limited) => {
emit_socks_block_decision_audit_event(
&app_state,
NetworkDecisionSource::ModeGuard,
REASON_METHOD_NOT_ALLOWED,
NetworkProtocol::Socks5Tcp,
host.as_str(),
port,
client.as_deref(),
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_METHOD_NOT_ALLOWED,
@@ -298,6 +318,15 @@ async fn inspect_socks5_udp(
match state.enabled().await {
Ok(true) => {}
Ok(false) => {
emit_socks_block_decision_audit_event(
&state,
NetworkDecisionSource::ProxyState,
REASON_PROXY_DISABLED,
NetworkProtocol::Socks5Udp,
host.as_str(),
port,
client.as_deref(),
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_PROXY_DISABLED,
@@ -331,6 +360,15 @@ async fn inspect_socks5_udp(
match state.network_mode().await {
Ok(NetworkMode::Limited) => {
emit_socks_block_decision_audit_event(
&state,
NetworkDecisionSource::ModeGuard,
REASON_METHOD_NOT_ALLOWED,
NetworkProtocol::Socks5Udp,
host.as_str(),
port,
client.as_deref(),
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_METHOD_NOT_ALLOWED,
@@ -413,9 +451,159 @@ async fn inspect_socks5_udp(
}
}
fn emit_socks_block_decision_audit_event(
state: &NetworkProxyState,
source: NetworkDecisionSource,
reason: &str,
protocol: NetworkProtocol,
host: &str,
port: u16,
client_addr: Option<&str>,
) {
emit_block_decision_audit_event(
state,
BlockDecisionAuditEventArgs {
source,
reason,
protocol,
server_address: host,
server_port: port,
method: None,
client_addr,
},
);
}
fn policy_denied_error(reason: &str, details: &PolicyDecisionDetails<'_>) -> io::Error {
io::Error::new(
io::ErrorKind::PermissionDenied,
blocked_message_with_policy(reason, details),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::NetworkMode;
use crate::config::NetworkProxyConfig;
use crate::config::NetworkProxySettings;
use crate::network_policy::test_support::POLICY_DECISION_EVENT_NAME;
use crate::network_policy::test_support::capture_events;
use crate::network_policy::test_support::find_event_by_name;
use crate::runtime::ConfigReloader;
use crate::runtime::ConfigState;
use crate::state::NetworkProxyConstraints;
use crate::state::build_config_state;
use async_trait::async_trait;
use pretty_assertions::assert_eq;
use rama_core::extensions::Extensions;
use rama_core::extensions::ExtensionsMut;
use rama_net::address::HostWithPort;
use rama_net::address::SocketAddress;
use rama_socks5::server::udp::RelayDirection;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::sync::Arc;
#[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_for_settings(network: NetworkProxySettings) -> Arc<NetworkProxyState> {
let config = NetworkProxyConfig { network };
let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap();
let reloader = Arc::new(StaticReloader {
state: state.clone(),
});
Arc::new(NetworkProxyState::with_reloader(state, reloader))
}
#[tokio::test(flavor = "current_thread")]
async fn handle_socks5_tcp_emits_block_decision_for_proxy_disabled() {
let state = state_for_settings(NetworkProxySettings {
enabled: false,
mode: NetworkMode::Full,
..NetworkProxySettings::default()
});
let mut request =
TcpRequest::new(HostWithPort::try_from("example.com:443").expect("valid authority"));
request.extensions_mut().insert(state.clone());
let (result, events) = capture_events(|| async {
handle_socks5_tcp(request, TcpConnector::default(), None).await
})
.await;
assert!(result.is_err(), "proxy-disabled request should be denied");
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
.expect("expected policy decision event");
assert_eq!(event.field("network.policy.scope"), Some("non_domain"));
assert_eq!(event.field("network.policy.decision"), Some("deny"));
assert_eq!(event.field("network.policy.source"), Some("proxy_state"));
assert_eq!(
event.field("network.policy.reason"),
Some(REASON_PROXY_DISABLED)
);
assert_eq!(
event.field("network.transport.protocol"),
Some("socks5_tcp")
);
assert_eq!(event.field("server.address"), Some("example.com"));
assert_eq!(event.field("server.port"), Some("443"));
assert_eq!(event.field("http.request.method"), Some("none"));
assert_eq!(event.field("client.address"), Some("unknown"));
}
#[tokio::test(flavor = "current_thread")]
async fn inspect_socks5_udp_emits_block_decision_for_mode_guard_deny() {
let state = state_for_settings(NetworkProxySettings {
enabled: true,
mode: NetworkMode::Limited,
..NetworkProxySettings::default()
});
let request = RelayRequest {
direction: RelayDirection::South,
server_address: SocketAddress::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 53),
payload: Default::default(),
extensions: Extensions::new(),
};
let (result, events) =
capture_events(|| async { inspect_socks5_udp(request, state, None).await }).await;
assert!(result.is_err(), "limited-mode UDP request should be denied");
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
.expect("expected policy decision event");
assert_eq!(event.field("network.policy.scope"), Some("non_domain"));
assert_eq!(event.field("network.policy.decision"), Some("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("socks5_udp")
);
assert_eq!(event.field("server.address"), Some("93.184.216.34"));
assert_eq!(event.field("server.port"), Some("53"));
assert_eq!(event.field("http.request.method"), Some("none"));
assert_eq!(event.field("client.address"), Some("unknown"));
}
}

View File

@@ -10,6 +10,7 @@ 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;

192
scripts/install/install.ps1 Normal file
View File

@@ -0,0 +1,192 @@
param(
[Parameter(Position=0)]
[string]$Version = "latest"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
function Write-Step {
param(
[string]$Message
)
Write-Host "==> $Message"
}
function Normalize-Version {
param(
[string]$RawVersion
)
if ([string]::IsNullOrWhiteSpace($RawVersion) -or $RawVersion -eq "latest") {
return "latest"
}
if ($RawVersion.StartsWith("rust-v")) {
return $RawVersion.Substring(6)
}
if ($RawVersion.StartsWith("v")) {
return $RawVersion.Substring(1)
}
return $RawVersion
}
function Get-ReleaseUrl {
param(
[string]$AssetName,
[string]$ResolvedVersion
)
return "https://github.com/openai/codex/releases/download/rust-v$ResolvedVersion/$AssetName"
}
function Path-Contains {
param(
[string]$PathValue,
[string]$Entry
)
if ([string]::IsNullOrWhiteSpace($PathValue)) {
return $false
}
$needle = $Entry.TrimEnd("\")
foreach ($segment in $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries)) {
if ($segment.TrimEnd("\") -ieq $needle) {
return $true
}
}
return $false
}
function Resolve-Version {
$normalizedVersion = Normalize-Version -RawVersion $Version
if ($normalizedVersion -ne "latest") {
return $normalizedVersion
}
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/openai/codex/releases/latest"
if (-not $release.tag_name) {
Write-Error "Failed to resolve the latest Codex release version."
exit 1
}
return (Normalize-Version -RawVersion $release.tag_name)
}
if ($env:OS -ne "Windows_NT") {
Write-Error "install.ps1 supports Windows only. Use install.sh on macOS or Linux."
exit 1
}
if (-not [Environment]::Is64BitOperatingSystem) {
Write-Error "Codex requires a 64-bit version of Windows."
exit 1
}
$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
$target = $null
$platformLabel = $null
$npmTag = $null
switch ($architecture) {
"Arm64" {
$target = "aarch64-pc-windows-msvc"
$platformLabel = "Windows (ARM64)"
$npmTag = "win32-arm64"
}
"X64" {
$target = "x86_64-pc-windows-msvc"
$platformLabel = "Windows (x64)"
$npmTag = "win32-x64"
}
default {
Write-Error "Unsupported architecture: $architecture"
exit 1
}
}
if ([string]::IsNullOrWhiteSpace($env:CODEX_INSTALL_DIR)) {
$installDir = Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin"
} else {
$installDir = $env:CODEX_INSTALL_DIR
}
$codexPath = Join-Path $installDir "codex.exe"
$installMode = if (Test-Path $codexPath) { "Updating" } else { "Installing" }
Write-Step "$installMode Codex CLI"
Write-Step "Detected platform: $platformLabel"
New-Item -ItemType Directory -Force -Path $installDir | Out-Null
$resolvedVersion = Resolve-Version
Write-Step "Resolved version: $resolvedVersion"
$packageAsset = "codex-npm-$npmTag-$resolvedVersion.tgz"
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("codex-install-" + [System.Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
try {
$archivePath = Join-Path $tempDir $packageAsset
$extractDir = Join-Path $tempDir "extract"
$url = Get-ReleaseUrl -AssetName $packageAsset -ResolvedVersion $resolvedVersion
Write-Step "Downloading Codex CLI"
Invoke-WebRequest -Uri $url -OutFile $archivePath
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
tar -xzf $archivePath -C $extractDir
$vendorRoot = Join-Path $extractDir "package/vendor/$target"
Write-Step "Installing to $installDir"
$copyMap = @{
"codex/codex.exe" = "codex.exe"
"codex/codex-command-runner.exe" = "codex-command-runner.exe"
"codex/codex-windows-sandbox-setup.exe" = "codex-windows-sandbox-setup.exe"
"path/rg.exe" = "rg.exe"
}
foreach ($relativeSource in $copyMap.Keys) {
$sourcePath = Join-Path $vendorRoot $relativeSource
$destinationPath = Join-Path $installDir $copyMap[$relativeSource]
Move-Item -Force $sourcePath $destinationPath
}
} finally {
Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue
}
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$pathNeedsNewShell = $false
if (-not (Path-Contains -PathValue $userPath -Entry $installDir)) {
if ([string]::IsNullOrWhiteSpace($userPath)) {
$newUserPath = $installDir
} else {
$newUserPath = "$userPath;$installDir"
}
[Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")
if (-not (Path-Contains -PathValue $env:Path -Entry $installDir)) {
$env:Path = "$env:Path;$installDir"
}
Write-Step "PATH updated for future PowerShell sessions."
$pathNeedsNewShell = $true
} elseif (Path-Contains -PathValue $env:Path -Entry $installDir) {
Write-Step "$installDir is already on PATH."
} else {
Write-Step "PATH is already configured for future PowerShell sessions."
$pathNeedsNewShell = $true
}
if ($pathNeedsNewShell) {
Write-Step ('Run now: $env:Path = "{0};$env:Path"; codex' -f $installDir)
Write-Step "Or open a new PowerShell window and run: codex"
} else {
Write-Step "Run: codex"
}
Write-Host "Codex CLI $resolvedVersion installed successfully."

244
scripts/install/install.sh Executable file
View File

@@ -0,0 +1,244 @@
#!/bin/sh
set -eu
VERSION="${1:-latest}"
INSTALL_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}"
path_action="already"
path_profile=""
step() {
printf '==> %s\n' "$1"
}
normalize_version() {
case "$1" in
"" | latest)
printf 'latest\n'
;;
rust-v*)
printf '%s\n' "${1#rust-v}"
;;
v*)
printf '%s\n' "${1#v}"
;;
*)
printf '%s\n' "$1"
;;
esac
}
download_file() {
url="$1"
output="$2"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url" -o "$output"
return
fi
if command -v wget >/dev/null 2>&1; then
wget -q -O "$output" "$url"
return
fi
echo "curl or wget is required to install Codex." >&2
exit 1
}
download_text() {
url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url"
return
fi
if command -v wget >/dev/null 2>&1; then
wget -q -O - "$url"
return
fi
echo "curl or wget is required to install Codex." >&2
exit 1
}
add_to_path() {
path_action="already"
path_profile=""
case ":$PATH:" in
*":$INSTALL_DIR:"*)
return
;;
esac
profile="$HOME/.profile"
case "${SHELL:-}" in
*/zsh)
profile="$HOME/.zshrc"
;;
*/bash)
profile="$HOME/.bashrc"
;;
esac
path_profile="$profile"
path_line="export PATH=\"$INSTALL_DIR:\$PATH\""
if [ -f "$profile" ] && grep -F "$path_line" "$profile" >/dev/null 2>&1; then
path_action="configured"
return
fi
{
printf '\n# Added by Codex installer\n'
printf '%s\n' "$path_line"
} >>"$profile"
path_action="added"
}
release_url_for_asset() {
asset="$1"
resolved_version="$2"
printf 'https://github.com/openai/codex/releases/download/rust-v%s/%s\n' "$resolved_version" "$asset"
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "$1 is required to install Codex." >&2
exit 1
fi
}
require_command mktemp
require_command tar
resolve_version() {
normalized_version="$(normalize_version "$VERSION")"
if [ "$normalized_version" != "latest" ]; then
printf '%s\n' "$normalized_version"
return
fi
release_json="$(download_text "https://api.github.com/repos/openai/codex/releases/latest")"
resolved="$(printf '%s\n' "$release_json" | sed -n 's/.*"tag_name":[[:space:]]*"rust-v\([^"]*\)".*/\1/p' | head -n 1)"
if [ -z "$resolved" ]; then
echo "Failed to resolve the latest Codex release version." >&2
exit 1
fi
printf '%s\n' "$resolved"
}
case "$(uname -s)" in
Darwin)
os="darwin"
;;
Linux)
os="linux"
;;
*)
echo "install.sh supports macOS and Linux. Use install.ps1 on Windows." >&2
exit 1
;;
esac
case "$(uname -m)" in
x86_64 | amd64)
arch="x86_64"
;;
arm64 | aarch64)
arch="aarch64"
;;
*)
echo "Unsupported architecture: $(uname -m)" >&2
exit 1
;;
esac
if [ "$os" = "darwin" ] && [ "$arch" = "x86_64" ]; then
if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" = "1" ]; then
arch="aarch64"
fi
fi
if [ "$os" = "darwin" ]; then
if [ "$arch" = "aarch64" ]; then
npm_tag="darwin-arm64"
vendor_target="aarch64-apple-darwin"
platform_label="macOS (Apple Silicon)"
else
npm_tag="darwin-x64"
vendor_target="x86_64-apple-darwin"
platform_label="macOS (Intel)"
fi
else
if [ "$arch" = "aarch64" ]; then
npm_tag="linux-arm64"
vendor_target="aarch64-unknown-linux-musl"
platform_label="Linux (ARM64)"
else
npm_tag="linux-x64"
vendor_target="x86_64-unknown-linux-musl"
platform_label="Linux (x64)"
fi
fi
if [ -x "$INSTALL_DIR/codex" ]; then
install_mode="Updating"
else
install_mode="Installing"
fi
step "$install_mode Codex CLI"
step "Detected platform: $platform_label"
resolved_version="$(resolve_version)"
asset="codex-npm-$npm_tag-$resolved_version.tgz"
download_url="$(release_url_for_asset "$asset" "$resolved_version")"
step "Resolved version: $resolved_version"
tmp_dir="$(mktemp -d)"
cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT INT TERM
archive_path="$tmp_dir/$asset"
step "Downloading Codex CLI"
download_file "$download_url" "$archive_path"
tar -xzf "$archive_path" -C "$tmp_dir"
step "Installing to $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
cp "$tmp_dir/package/vendor/$vendor_target/codex/codex" "$INSTALL_DIR/codex"
cp "$tmp_dir/package/vendor/$vendor_target/path/rg" "$INSTALL_DIR/rg"
chmod 0755 "$INSTALL_DIR/codex"
chmod 0755 "$INSTALL_DIR/rg"
add_to_path
case "$path_action" in
added)
step "PATH updated for future shells in $path_profile"
step "Run now: export PATH=\"$INSTALL_DIR:\$PATH\" && codex"
step "Or open a new terminal and run: codex"
;;
configured)
step "PATH is already configured for future shells in $path_profile"
step "Run now: export PATH=\"$INSTALL_DIR:\$PATH\" && codex"
step "Or open a new terminal and run: codex"
;;
*)
step "$INSTALL_DIR is already on PATH"
step "Run: codex"
;;
esac
printf 'Codex CLI %s installed successfully.\n' "$resolved_version"