mirror of
https://github.com/openai/codex.git
synced 2026-02-25 18:23:47 +00:00
Compare commits
3 Commits
latest-alp
...
codex/dire
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02525c193a | ||
|
|
0f570e20d5 | ||
|
|
9a393c9b6f |
5
.github/workflows/rust-release.yml
vendored
5
.github/workflows/rust-release.yml
vendored
@@ -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
230
MODULE.bazel.lock
generated
File diff suppressed because one or more lines are too long
834
codex-rs/Cargo.lock
generated
834
codex-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
{
|
||||
|
||||
@@ -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}"))?,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
192
scripts/install/install.ps1
Normal 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
244
scripts/install/install.sh
Executable 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"
|
||||
Reference in New Issue
Block a user