**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>
306 lines
9.3 KiB
Rust
306 lines
9.3 KiB
Rust
#[cfg(target_os = "macos")]
|
|
mod pid_tracker;
|
|
#[cfg(target_os = "macos")]
|
|
mod seatbelt;
|
|
|
|
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")]
|
|
use codex_core::seatbelt::spawn_command_under_seatbelt;
|
|
use codex_core::spawn::StdioPolicy;
|
|
use codex_protocol::config_types::SandboxMode;
|
|
use codex_utils_cli::CliConfigOverrides;
|
|
|
|
use crate::LandlockCommand;
|
|
use crate::SeatbeltCommand;
|
|
use crate::WindowsCommand;
|
|
use crate::exit_status::handle_exit_status;
|
|
|
|
#[cfg(target_os = "macos")]
|
|
use seatbelt::DenialLogger;
|
|
|
|
#[cfg(target_os = "macos")]
|
|
pub async fn run_command_under_seatbelt(
|
|
command: SeatbeltCommand,
|
|
codex_linux_sandbox_exe: Option<PathBuf>,
|
|
) -> anyhow::Result<()> {
|
|
let SeatbeltCommand {
|
|
full_auto,
|
|
log_denials,
|
|
config_overrides,
|
|
command,
|
|
} = command;
|
|
run_command_under_sandbox(
|
|
full_auto,
|
|
command,
|
|
config_overrides,
|
|
codex_linux_sandbox_exe,
|
|
SandboxType::Seatbelt,
|
|
log_denials,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub async fn run_command_under_seatbelt(
|
|
_command: SeatbeltCommand,
|
|
_codex_linux_sandbox_exe: Option<PathBuf>,
|
|
) -> anyhow::Result<()> {
|
|
anyhow::bail!("Seatbelt sandbox is only available on macOS");
|
|
}
|
|
|
|
pub async fn run_command_under_landlock(
|
|
command: LandlockCommand,
|
|
codex_linux_sandbox_exe: Option<PathBuf>,
|
|
) -> anyhow::Result<()> {
|
|
let LandlockCommand {
|
|
full_auto,
|
|
config_overrides,
|
|
command,
|
|
} = command;
|
|
run_command_under_sandbox(
|
|
full_auto,
|
|
command,
|
|
config_overrides,
|
|
codex_linux_sandbox_exe,
|
|
SandboxType::Landlock,
|
|
false,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn run_command_under_windows(
|
|
command: WindowsCommand,
|
|
codex_linux_sandbox_exe: Option<PathBuf>,
|
|
) -> anyhow::Result<()> {
|
|
let WindowsCommand {
|
|
full_auto,
|
|
config_overrides,
|
|
command,
|
|
} = command;
|
|
run_command_under_sandbox(
|
|
full_auto,
|
|
command,
|
|
config_overrides,
|
|
codex_linux_sandbox_exe,
|
|
SandboxType::Windows,
|
|
false,
|
|
)
|
|
.await
|
|
}
|
|
|
|
enum SandboxType {
|
|
#[cfg(target_os = "macos")]
|
|
Seatbelt,
|
|
Landlock,
|
|
Windows,
|
|
}
|
|
|
|
async fn run_command_under_sandbox(
|
|
full_auto: bool,
|
|
command: Vec<String>,
|
|
config_overrides: CliConfigOverrides,
|
|
codex_linux_sandbox_exe: Option<PathBuf>,
|
|
sandbox_type: SandboxType,
|
|
log_denials: bool,
|
|
) -> anyhow::Result<()> {
|
|
let sandbox_mode = create_sandbox_mode(full_auto);
|
|
let config = Config::load_with_cli_overrides_and_harness_overrides(
|
|
config_overrides
|
|
.parse_overrides()
|
|
.map_err(anyhow::Error::msg)?,
|
|
ConfigOverrides {
|
|
sandbox_mode: Some(sandbox_mode),
|
|
codex_linux_sandbox_exe,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
// In practice, this should be `std::env::current_dir()` because this CLI
|
|
// does not support `--cwd`, but let's use the config value for consistency.
|
|
let cwd = config.cwd.clone();
|
|
// For now, we always use the same cwd for both the command and the
|
|
// sandbox policy. In the future, we could add a CLI option to set them
|
|
// separately.
|
|
let sandbox_policy_cwd = cwd.clone();
|
|
|
|
let stdio_policy = StdioPolicy::Inherit;
|
|
let env = create_env(&config.permissions.shell_environment_policy, None);
|
|
|
|
// Special-case Windows sandbox: execute and exit the process to emulate inherited stdio.
|
|
if let SandboxType::Windows = sandbox_type {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
|
|
use codex_protocol::config_types::WindowsSandboxLevel;
|
|
use codex_windows_sandbox::run_windows_sandbox_capture;
|
|
use codex_windows_sandbox::run_windows_sandbox_capture_elevated;
|
|
|
|
let policy_str = serde_json::to_string(config.permissions.sandbox_policy.get())?;
|
|
|
|
let sandbox_cwd = sandbox_policy_cwd.clone();
|
|
let cwd_clone = cwd.clone();
|
|
let env_map = env.clone();
|
|
let command_vec = command.clone();
|
|
let base_dir = config.codex_home.clone();
|
|
let use_elevated = matches!(
|
|
WindowsSandboxLevel::from_config(&config),
|
|
WindowsSandboxLevel::Elevated
|
|
);
|
|
|
|
// Preflight audit is invoked elsewhere at the appropriate times.
|
|
let res = tokio::task::spawn_blocking(move || {
|
|
if use_elevated {
|
|
run_windows_sandbox_capture_elevated(
|
|
policy_str.as_str(),
|
|
&sandbox_cwd,
|
|
base_dir.as_path(),
|
|
command_vec,
|
|
&cwd_clone,
|
|
env_map,
|
|
None,
|
|
)
|
|
} else {
|
|
run_windows_sandbox_capture(
|
|
policy_str.as_str(),
|
|
&sandbox_cwd,
|
|
base_dir.as_path(),
|
|
command_vec,
|
|
&cwd_clone,
|
|
env_map,
|
|
None,
|
|
)
|
|
}
|
|
})
|
|
.await;
|
|
|
|
let capture = match res {
|
|
Ok(Ok(v)) => v,
|
|
Ok(Err(err)) => {
|
|
eprintln!("windows sandbox failed: {err}");
|
|
std::process::exit(1);
|
|
}
|
|
Err(join_err) => {
|
|
eprintln!("windows sandbox join error: {join_err}");
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
if !capture.stdout.is_empty() {
|
|
use std::io::Write;
|
|
let _ = std::io::stdout().write_all(&capture.stdout);
|
|
}
|
|
if !capture.stderr.is_empty() {
|
|
use std::io::Write;
|
|
let _ = std::io::stderr().write_all(&capture.stderr);
|
|
}
|
|
|
|
std::process::exit(capture.exit_code);
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
anyhow::bail!("Windows sandbox is only available on Windows");
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
let mut denial_logger = log_denials.then(DenialLogger::new).flatten();
|
|
#[cfg(not(target_os = "macos"))]
|
|
let _ = log_denials;
|
|
|
|
let managed_network_requirements_enabled = config.managed_network_requirements_enabled();
|
|
|
|
// This proxy should only live for the lifetime of the child process.
|
|
let network_proxy = match config.permissions.network.as_ref() {
|
|
Some(spec) => Some(
|
|
spec.start_proxy(
|
|
config.permissions.sandbox_policy.get(),
|
|
None,
|
|
None,
|
|
managed_network_requirements_enabled,
|
|
NetworkProxyAuditMetadata::default(),
|
|
)
|
|
.await
|
|
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?,
|
|
),
|
|
None => None,
|
|
};
|
|
let network = network_proxy
|
|
.as_ref()
|
|
.map(codex_core::config::StartedNetworkProxy::proxy);
|
|
|
|
let mut child = match sandbox_type {
|
|
#[cfg(target_os = "macos")]
|
|
SandboxType::Seatbelt => {
|
|
spawn_command_under_seatbelt(
|
|
command,
|
|
cwd,
|
|
config.permissions.sandbox_policy.get(),
|
|
sandbox_policy_cwd.as_path(),
|
|
stdio_policy,
|
|
network.as_ref(),
|
|
env,
|
|
)
|
|
.await?
|
|
}
|
|
SandboxType::Landlock => {
|
|
use codex_core::features::Feature;
|
|
#[expect(clippy::expect_used)]
|
|
let codex_linux_sandbox_exe = config
|
|
.codex_linux_sandbox_exe
|
|
.expect("codex-linux-sandbox executable not found");
|
|
let use_bwrap_sandbox = config.features.enabled(Feature::UseLinuxSandboxBwrap);
|
|
spawn_command_under_linux_sandbox(
|
|
codex_linux_sandbox_exe,
|
|
command,
|
|
cwd,
|
|
config.permissions.sandbox_policy.get(),
|
|
sandbox_policy_cwd.as_path(),
|
|
use_bwrap_sandbox,
|
|
stdio_policy,
|
|
network.as_ref(),
|
|
env,
|
|
)
|
|
.await?
|
|
}
|
|
SandboxType::Windows => {
|
|
unreachable!("Windows sandbox should have been handled above");
|
|
}
|
|
};
|
|
|
|
#[cfg(target_os = "macos")]
|
|
if let Some(denial_logger) = &mut denial_logger {
|
|
denial_logger.on_child_spawn(&child);
|
|
}
|
|
|
|
let status = child.wait().await?;
|
|
|
|
#[cfg(target_os = "macos")]
|
|
if let Some(denial_logger) = denial_logger {
|
|
let denials = denial_logger.finish().await;
|
|
eprintln!("\n=== Sandbox denials ===");
|
|
if denials.is_empty() {
|
|
eprintln!("None found.");
|
|
} else {
|
|
for seatbelt::SandboxDenial { name, capability } in denials {
|
|
eprintln!("({name}) {capability}");
|
|
}
|
|
}
|
|
}
|
|
|
|
handle_exit_status(status);
|
|
}
|
|
|
|
pub fn create_sandbox_mode(full_auto: bool) -> SandboxMode {
|
|
if full_auto {
|
|
SandboxMode::WorkspaceWrite
|
|
} else {
|
|
SandboxMode::ReadOnly
|
|
}
|
|
}
|