From fdbd66717969cc0568e3653e794f735457eee69d Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 27 Apr 2026 13:42:01 -0700 Subject: [PATCH] feat: persist sandbox security events Co-authored-by: Codex noreply@openai.com --- codex-rs/core/src/exec.rs | 23 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/sandboxing/mod.rs | 6 + codex-rs/core/src/security_events.rs | 238 ++++++++++++++ codex-rs/core/src/tasks/user_shell.rs | 1 + codex-rs/core/src/tools/network_approval.rs | 12 +- .../core/src/tools/runtimes/apply_patch.rs | 10 +- codex-rs/core/src/tools/runtimes/shell.rs | 3 + .../tools/runtimes/shell/unix_escalation.rs | 29 +- .../runtimes/shell/unix_escalation_tests.rs | 6 +- .../core/src/tools/runtimes/unified_exec.rs | 14 +- codex-rs/core/src/unified_exec/process.rs | 21 +- .../core/src/unified_exec/process_manager.rs | 15 +- .../src/unified_exec/process_manager_tests.rs | 1 + .../core/src/unified_exec/process_tests.rs | 10 +- .../state/migrations/0030_security_events.sql | 30 ++ codex-rs/state/src/lib.rs | 5 + codex-rs/state/src/model/mod.rs | 6 + codex-rs/state/src/model/security_event.rs | 157 +++++++++ codex-rs/state/src/runtime.rs | 5 + codex-rs/state/src/runtime/security_events.rs | 310 ++++++++++++++++++ 21 files changed, 882 insertions(+), 21 deletions(-) create mode 100644 codex-rs/core/src/security_events.rs create mode 100644 codex-rs/state/migrations/0030_security_events.sql create mode 100644 codex-rs/state/src/model/security_event.rs create mode 100644 codex-rs/state/src/runtime/security_events.rs diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index d010ac92b7..05f10519a2 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -384,6 +384,7 @@ pub fn build_exec_request( let options = ExecOptions { expiration, capture_policy, + sandbox_violation_context: None, }; let mut exec_req = manager .transform(SandboxTransformRequest { @@ -458,6 +459,7 @@ pub(crate) async fn execute_exec_request( network_sandbox_policy, windows_sandbox_filesystem_overrides, network_environment_id, + sandbox_violation_context, arg0, exec_server_sandbox: _, exec_server_enforce_managed_network: _, @@ -502,7 +504,13 @@ pub(crate) async fn execute_exec_request( ) .await; let duration = start.elapsed(); - finalize_exec_result(raw_output_result, sandbox, duration) + finalize_exec_result( + raw_output_result, + sandbox, + duration, + sandbox_violation_context.as_ref(), + ) + .await } #[allow(clippy::too_many_arguments)] @@ -763,10 +771,11 @@ async fn exec_windows_sandbox( }) } -fn finalize_exec_result( +async fn finalize_exec_result( raw_output_result: std::result::Result, sandbox_type: SandboxType, duration: Duration, + sandbox_violation_context: Option<&crate::security_events::SandboxViolationAuditContext>, ) -> Result { match raw_output_result { Ok(raw_output) => { @@ -808,7 +817,15 @@ fn finalize_exec_result( } if is_likely_sandbox_denied(sandbox_type, &exec_output) { - record_filesystem_sandbox_violation(sandbox_type, &exec_output); + if let Some(violation) = + record_filesystem_sandbox_violation(sandbox_type, &exec_output) + { + crate::security_events::record_sandbox_violation_audit( + sandbox_violation_context, + &codex_sandboxing::SandboxViolationEvent::FileSystem(violation), + ) + .await; + } return Err(CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(exec_output), network_policy_decision: None, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 91ccbd6e63..3fb2c9a51b 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -87,6 +87,7 @@ pub(crate) mod mentions { } mod sandbox_tags; pub mod sandboxing; +pub(crate) mod security_events; mod session_prefix; mod session_startup_prewarm; pub mod skills; diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 1c570e1bbb..eda14e1e68 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -11,6 +11,7 @@ use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::exec::StdoutStream; use crate::exec::execute_exec_request; +use crate::security_events::SandboxViolationAuditContext; #[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; @@ -34,6 +35,7 @@ use std::collections::HashMap; pub(crate) struct ExecOptions { pub(crate) expiration: ExecExpiration, pub(crate) capture_policy: ExecCapturePolicy, + pub(crate) sandbox_violation_context: Option, } #[derive(Clone, Debug)] @@ -61,6 +63,7 @@ pub struct ExecRequest { pub file_system_sandbox_policy: FileSystemSandboxPolicy, pub network_sandbox_policy: NetworkSandboxPolicy, pub(crate) windows_sandbox_filesystem_overrides: Option, + pub(crate) sandbox_violation_context: Option, pub arg0: Option, pub(crate) exec_server_sandbox: Option, pub(crate) exec_server_enforce_managed_network: bool, @@ -106,6 +109,7 @@ impl ExecRequest { file_system_sandbox_policy, network_sandbox_policy, windows_sandbox_filesystem_overrides: None, + sandbox_violation_context: None, arg0, exec_server_sandbox: None, exec_server_enforce_managed_network: false, @@ -136,6 +140,7 @@ impl ExecRequest { let ExecOptions { expiration, capture_policy, + sandbox_violation_context, } = options; if !network_sandbox_policy.is_enabled() { env.insert( @@ -165,6 +170,7 @@ impl ExecRequest { file_system_sandbox_policy, network_sandbox_policy, windows_sandbox_filesystem_overrides: None, + sandbox_violation_context, arg0, exec_server_sandbox: None, exec_server_enforce_managed_network: false, diff --git a/codex-rs/core/src/security_events.rs b/codex-rs/core/src/security_events.rs new file mode 100644 index 0000000000..cfd4a9d465 --- /dev/null +++ b/codex-rs/core/src/security_events.rs @@ -0,0 +1,238 @@ +use crate::tools::sandboxing::ToolCtx; +use chrono::SecondsFormat; +use chrono::Utc; +use codex_login::CodexAuth; +use codex_login::default_client::originator; +use codex_network_proxy::NetworkProxyAuditMetadata; +use codex_rollout::state_db::StateDbHandle; +use codex_sandboxing::SandboxViolationEvent; +use codex_state::SecurityEventCreateParams; +use codex_state::SecurityEventKind; +use codex_state::SecurityEventResource; +use codex_terminal_detection::user_agent; + +const AUDIT_TARGET: &str = "codex_otel.sandbox_violation"; +const SANDBOX_VIOLATION_EVENT_NAME: &str = "codex.sandbox.violation"; + +#[derive(Clone)] +pub(crate) struct SandboxViolationAuditContext { + pub(crate) state_db: Option, + pub(crate) metadata: NetworkProxyAuditMetadata, + pub(crate) turn_id: Option, + pub(crate) call_id: Option, + pub(crate) tool_name: Option, +} + +impl std::fmt::Debug for SandboxViolationAuditContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SandboxViolationAuditContext") + .field("has_state_db", &self.state_db.is_some()) + .field("metadata", &self.metadata) + .field("turn_id", &self.turn_id) + .field("call_id", &self.call_id) + .field("tool_name", &self.tool_name) + .finish() + } +} + +impl SandboxViolationAuditContext { + pub(crate) fn from_tool_ctx(ctx: &ToolCtx) -> Self { + let auth = ctx.session.services.auth_manager.auth_cached(); + let auth_mode = ctx + .session + .services + .auth_manager + .auth_mode() + .map(|mode| mode.to_string()); + let metadata = NetworkProxyAuditMetadata { + conversation_id: Some(ctx.session.conversation_id.to_string()), + app_version: Some(env!("CARGO_PKG_VERSION").to_string()), + user_account_id: auth.as_ref().and_then(CodexAuth::get_account_id), + auth_mode, + originator: Some(originator().value), + user_email: auth.as_ref().and_then(CodexAuth::get_account_email), + terminal_type: Some(user_agent()), + model: Some(ctx.turn.model_info.slug.clone()), + slug: Some(ctx.turn.model_info.slug.clone()), + }; + Self { + state_db: ctx.session.services.state_db.clone(), + metadata, + turn_id: Some(ctx.turn.sub_id.clone()), + call_id: Some(ctx.call_id.clone()), + tool_name: Some(ctx.tool_name.clone()), + } + } + + pub(crate) fn from_network_proxy( + state_db: Option, + metadata: NetworkProxyAuditMetadata, + ) -> Self { + Self { + state_db, + metadata, + turn_id: None, + call_id: None, + tool_name: None, + } + } +} + +pub(crate) async fn record_sandbox_violation_audit( + context: Option<&SandboxViolationAuditContext>, + event: &SandboxViolationEvent, +) { + emit_sandbox_violation_audit_event(context, event); + + let Some(context) = context else { + return; + }; + let Some(state_db) = context.state_db.as_deref() else { + return; + }; + let event = security_event_create_params(context, event); + if let Err(err) = state_db.insert_security_event(&event).await { + tracing::warn!("failed to persist sandbox violation security event: {err}"); + } +} + +fn security_event_create_params( + context: &SandboxViolationAuditContext, + event: &SandboxViolationEvent, +) -> SecurityEventCreateParams { + match event { + SandboxViolationEvent::FileSystem(violation) => SecurityEventCreateParams { + created_at: Utc::now().timestamp(), + kind: SecurityEventKind::SandboxViolation, + thread_id: context.metadata.conversation_id.clone(), + turn_id: context.turn_id.clone(), + call_id: context.call_id.clone(), + tool_name: context.tool_name.clone(), + resource: Some(SecurityEventResource::FileSystem), + sandbox_type: Some(violation.sandbox_type.as_metric_tag().to_string()), + reason: Some(violation.reason.as_str().to_string()), + path: violation.path.clone(), + host: None, + port: None, + protocol: None, + method: None, + decision: None, + source: None, + review_id: None, + reviewer: None, + review_decision: None, + details_json: None, + }, + SandboxViolationEvent::Network(violation) => SecurityEventCreateParams { + created_at: violation.timestamp, + kind: SecurityEventKind::SandboxViolation, + thread_id: context.metadata.conversation_id.clone(), + turn_id: context.turn_id.clone(), + call_id: context.call_id.clone(), + tool_name: context.tool_name.clone(), + resource: Some(SecurityEventResource::Network), + sandbox_type: None, + reason: Some(violation.reason.clone()), + path: None, + host: Some(violation.host.clone()), + port: violation.port, + protocol: Some(violation.protocol.clone()), + method: violation.method.clone(), + decision: violation.decision.clone(), + source: violation.source.clone(), + review_id: None, + reviewer: None, + review_decision: None, + details_json: None, + }, + } +} + +fn emit_sandbox_violation_audit_event( + context: Option<&SandboxViolationAuditContext>, + event: &SandboxViolationEvent, +) { + let metadata = context.map(|context| &context.metadata); + let turn_id = context.and_then(|context| context.turn_id.as_deref()); + let call_id = context.and_then(|context| context.call_id.as_deref()); + let tool_name = context.and_then(|context| context.tool_name.as_deref()); + let fields = SandboxViolationAuditFields::new(event); + + tracing::event!( + target: AUDIT_TARGET, + tracing::Level::INFO, + event.name = SANDBOX_VIOLATION_EVENT_NAME, + event.timestamp = %audit_timestamp(), + conversation.id = metadata.and_then(|metadata| metadata.conversation_id.as_deref()), + app.version = metadata.and_then(|metadata| metadata.app_version.as_deref()), + auth_mode = metadata.and_then(|metadata| metadata.auth_mode.as_deref()), + originator = metadata.and_then(|metadata| metadata.originator.as_deref()), + user.account_id = metadata.and_then(|metadata| metadata.user_account_id.as_deref()), + user.email = metadata.and_then(|metadata| metadata.user_email.as_deref()), + terminal.type = metadata.and_then(|metadata| metadata.terminal_type.as_deref()), + model = metadata.and_then(|metadata| metadata.model.as_deref()), + slug = metadata.and_then(|metadata| metadata.slug.as_deref()), + security.event.kind = SecurityEventKind::SandboxViolation.as_str(), + sandbox.resource = fields.resource.as_str(), + sandbox.type = fields.sandbox_type, + sandbox.reason = fields.reason, + tool.call_id = call_id, + tool.name = tool_name, + turn.id = turn_id, + file.path = fields.path, + server.address = fields.host, + server.port = fields.port, + network.transport.protocol = fields.protocol, + http.request.method = fields.method, + network.policy.decision = fields.decision, + network.policy.source = fields.source, + ); +} + +struct SandboxViolationAuditFields<'a> { + resource: SecurityEventResource, + sandbox_type: Option<&'a str>, + reason: &'a str, + path: Option<&'a str>, + host: Option<&'a str>, + port: Option, + protocol: Option<&'a str>, + method: Option<&'a str>, + decision: Option<&'a str>, + source: Option<&'a str>, +} + +impl<'a> SandboxViolationAuditFields<'a> { + fn new(event: &'a SandboxViolationEvent) -> Self { + match event { + SandboxViolationEvent::FileSystem(violation) => Self { + resource: SecurityEventResource::FileSystem, + sandbox_type: Some(violation.sandbox_type.as_metric_tag()), + reason: violation.reason.as_str(), + path: violation.path.as_deref(), + host: None, + port: None, + protocol: None, + method: None, + decision: None, + source: None, + }, + SandboxViolationEvent::Network(violation) => Self { + resource: SecurityEventResource::Network, + sandbox_type: None, + reason: violation.reason.as_str(), + path: None, + host: Some(violation.host.as_str()), + port: violation.port.map(i64::from), + protocol: Some(violation.protocol.as_str()), + method: violation.method.as_deref(), + decision: violation.decision.as_deref(), + source: violation.source.as_deref(), + }, + } + } +} + +fn audit_timestamp() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) +} diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index a15c53e16b..e55f453193 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -227,6 +227,7 @@ pub(crate) async fn execute_user_shell_command( file_system_sandbox_policy: permission_profile.file_system_sandbox_policy(), network_sandbox_policy: permission_profile.network_sandbox_policy(), windows_sandbox_filesystem_overrides: None, + sandbox_violation_context: None, arg0: None, exec_server_sandbox: None, exec_server_enforce_managed_network: false, diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index e8aca75407..717269fab5 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -7,6 +7,8 @@ use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::hook_runtime::run_permission_request_hooks; use crate::network_policy_decision::denied_network_policy_message; +use crate::security_events::SandboxViolationAuditContext; +use crate::security_events::record_sandbox_violation_audit; use crate::session::session::Session; use crate::tools::sandboxing::PermissionRequestPayload; use crate::tools::sandboxing::ToolError; @@ -28,6 +30,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::WarningEvent; use codex_sandboxing::SandboxType; +use codex_sandboxing::SandboxViolationEvent; use codex_sandboxing::record_network_sandbox_violation; use indexmap::IndexMap; use std::collections::HashMap; @@ -821,11 +824,18 @@ impl NetworkApprovalService { pub(crate) fn build_blocked_request_observer( network_approval: Arc, + sandbox_violation_context: SandboxViolationAuditContext, ) -> Arc { Arc::new(move |blocked: BlockedRequest| { let network_approval = Arc::clone(&network_approval); + let sandbox_violation_context = sandbox_violation_context.clone(); async move { - record_network_sandbox_violation(&blocked); + let violation = record_network_sandbox_violation(&blocked); + record_sandbox_violation_audit( + Some(&sandbox_violation_context), + &SandboxViolationEvent::Network(violation), + ) + .await; network_approval.record_blocked_request(blocked).await; } }) diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 26626f7053..7e324280eb 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -234,7 +234,7 @@ impl ToolRuntime for ApplyPatchRunti &mut self, req: &ApplyPatchRequest, attempt: &SandboxAttempt<'_>, - _ctx: &ToolCtx, + ctx: &ToolCtx, ) -> Result { let started_at = Instant::now(); let fs = req.turn_environment.environment.get_filesystem(); @@ -268,7 +268,13 @@ impl ToolRuntime for ApplyPatchRunti timed_out: false, }; if failed && is_likely_sandbox_denied(attempt.sandbox, &output) { - record_filesystem_sandbox_violation(attempt.sandbox, &output); + if let Some(violation) = record_filesystem_sandbox_violation(attempt.sandbox, &output) { + crate::security_events::record_sandbox_violation_audit( + Some(&crate::security_events::SandboxViolationAuditContext::from_tool_ctx(ctx)), + &codex_sandboxing::SandboxViolationEvent::FileSystem(violation), + ) + .await; + } return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(output), network_policy_decision: None, diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 3ff50f3f51..64edc75561 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -318,6 +318,9 @@ impl ToolRuntime for ShellRuntime { let options = ExecOptions { expiration, capture_policy: ExecCapturePolicy::ShellTool, + sandbox_violation_context: Some( + crate::security_events::SandboxViolationAuditContext::from_tool_ctx(ctx), + ), }; let env = attempt .env_for( diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 657a249c70..434cbce5ec 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -139,6 +139,9 @@ pub(super) async fn try_run_zsh_fork( let options = ExecOptions { expiration: req.timeout_ms.into(), capture_policy: ExecCapturePolicy::ShellTool, + sandbox_violation_context: Some( + crate::security_events::SandboxViolationAuditContext::from_tool_ctx(ctx), + ), }; let sandbox_exec_request = attempt .env_for( @@ -166,6 +169,7 @@ pub(super) async fn try_run_zsh_fork( file_system_sandbox_policy, network_sandbox_policy, windows_sandbox_filesystem_overrides: _windows_sandbox_filesystem_overrides, + sandbox_violation_context, arg0, exec_server_sandbox: _, exec_server_enforce_managed_network: _, @@ -203,6 +207,7 @@ pub(super) async fn try_run_zsh_fork( windows_sandbox_workspace_roots, codex_linux_sandbox_exe: ctx.turn.config.codex_linux_sandbox_exe.clone(), use_legacy_landlock: ctx.turn.config.features.use_legacy_landlock(), + sandbox_violation_context, }; let main_execve_wrapper_exe = ctx .session @@ -254,13 +259,20 @@ pub(super) async fn try_run_zsh_fork( main_execve_wrapper_exe, escalation_policy, ); + let sandbox_violation_context = command_executor.sandbox_violation_context.clone(); let exec_result = escalate_server .exec(exec_params, cancel_token, Arc::new(command_executor)) .await .map_err(|err| ToolError::Rejected(err.to_string()))?; - map_exec_result(attempt.sandbox, exec_result).map(Some) + map_exec_result( + attempt.sandbox, + exec_result, + sandbox_violation_context.as_ref(), + ) + .await + .map(Some) } pub(crate) async fn prepare_unified_exec_zsh_fork( @@ -316,6 +328,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( windows_sandbox_workspace_roots: exec_request.windows_sandbox_workspace_roots.clone(), codex_linux_sandbox_exe: ctx.turn.config.codex_linux_sandbox_exe.clone(), use_legacy_landlock: ctx.turn.config.features.use_legacy_landlock(), + sandbox_violation_context: exec_request.sandbox_violation_context.clone(), }; let escalation_policy = CoreShellActionProvider { policy: Arc::clone(&exec_policy), @@ -825,6 +838,7 @@ struct CoreShellCommandExecutor { windows_sandbox_workspace_roots: Vec, codex_linux_sandbox_exe: Option, use_legacy_landlock: bool, + sandbox_violation_context: Option, } struct PrepareSandboxedExecParams<'a> { @@ -901,6 +915,7 @@ impl CoreShellCommandExecutor { file_system_sandbox_policy: self.file_system_sandbox_policy.clone(), network_sandbox_policy: self.network_sandbox_policy, windows_sandbox_filesystem_overrides: None, + sandbox_violation_context: self.sandbox_violation_context.clone(), arg0: self.arg0.clone(), exec_server_sandbox: None, exec_server_enforce_managed_network: false, @@ -1019,6 +1034,7 @@ impl CoreShellCommandExecutor { let options = ExecOptions { expiration: ExecExpiration::DefaultTimeout, capture_policy: ExecCapturePolicy::ShellTool, + sandbox_violation_context: self.sandbox_violation_context.clone(), }; let exec_request = sandbox_manager.transform(SandboxTransformRequest { command, @@ -1095,9 +1111,10 @@ fn extract_shell_script(command: &[String]) -> Result, ) -> Result { let output = ExecToolCallOutput { exit_code: result.exit_code, @@ -1115,7 +1132,13 @@ fn map_exec_result( } if is_likely_sandbox_denied(sandbox, &output) { - record_filesystem_sandbox_violation(sandbox, &output); + if let Some(violation) = record_filesystem_sandbox_violation(sandbox, &output) { + crate::security_events::record_sandbox_violation_audit( + sandbox_violation_context, + &codex_sandboxing::SandboxViolationEvent::FileSystem(violation), + ) + .await; + } return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { output: Box::new(output), network_policy_decision: None, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 5478c1d946..d3d4627bb0 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -262,8 +262,8 @@ fn commands_for_intercepted_exec_policy_parses_plain_shell_wrappers() { assert!(!candidate_commands.used_complex_parsing); } -#[test] -fn map_exec_result_preserves_stdout_and_stderr() { +#[tokio::test] +async fn map_exec_result_preserves_stdout_and_stderr() { let out = map_exec_result( SandboxType::None, ExecResult { @@ -274,7 +274,9 @@ fn map_exec_result_preserves_stdout_and_stderr() { duration: Duration::from_millis(1), timed_out: false, }, + /*sandbox_violation_context*/ None, ) + .await .unwrap(); assert_eq!(out.stdout.text, "out"); diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index a1b9b33c6d..bd186fa10a 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -102,6 +102,7 @@ pub struct UnifiedExecRuntime<'a> { fn unified_exec_options( network_denial_cancellation_token: Option, + sandbox_violation_context: Option, ) -> ExecOptions { let mut expiration = ExecExpiration::DefaultTimeout; if let Some(cancellation) = network_denial_cancellation_token { @@ -110,6 +111,7 @@ fn unified_exec_options( ExecOptions { expiration, capture_policy: ExecCapturePolicy::ShellTool, + sandbox_violation_context, } } @@ -401,7 +403,10 @@ impl<'a> ToolRuntime for UnifiedExecRunt } error @ ToolError::Codex(_) => error, })?; - let options = unified_exec_options(attempt.network_denial_cancellation_token.clone()); + let options = unified_exec_options( + attempt.network_denial_cancellation_token.clone(), + Some(crate::security_events::SandboxViolationAuditContext::from_tool_ctx(ctx)), + ); let mut exec_env = attempt .env_for( command, @@ -467,7 +472,10 @@ impl<'a> ToolRuntime for UnifiedExecRunt } error @ ToolError::Codex(_) => error, })?; - let options = unified_exec_options(attempt.network_denial_cancellation_token.clone()); + let options = unified_exec_options( + attempt.network_denial_cancellation_token.clone(), + Some(crate::security_events::SandboxViolationAuditContext::from_tool_ctx(ctx)), + ); self.manager .open_session_with_exec_env( req.process_id, @@ -511,7 +519,7 @@ mod tests { #[test] fn unified_exec_options_combines_default_timeout_with_network_denial_cancellation() { let cancellation = CancellationToken::new(); - let options = unified_exec_options(Some(cancellation.clone())); + let options = unified_exec_options(Some(cancellation.clone()), None); assert_eq!(options.capture_policy, ExecCapturePolicy::ShellTool); match options.expiration { diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index fddbdbe96d..9a8c56b062 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -12,6 +12,7 @@ use tokio::task::JoinHandle; use tokio::time::Duration; use tokio_util::sync::CancellationToken; +use crate::security_events::SandboxViolationAuditContext; use codex_exec_server::ExecProcess; use codex_exec_server::ExecProcessEvent; use codex_exec_server::ProcessSignal as ExecServerProcessSignal; @@ -87,6 +88,7 @@ pub(crate) struct UnifiedExecProcess { state_rx: watch::Receiver, output_task: Option>, sandbox_type: SandboxType, + sandbox_violation_context: Option, _spawn_lifecycle: Option, } @@ -104,6 +106,7 @@ impl UnifiedExecProcess { fn new( process_handle: ProcessHandle, sandbox_type: SandboxType, + sandbox_violation_context: Option, spawn_lifecycle: Option, ) -> Self { let output_buffer = Arc::new(Mutex::new(HeadTailBuffer::default())); @@ -128,6 +131,7 @@ impl UnifiedExecProcess { state_rx, output_task: None, sandbox_type, + sandbox_violation_context, _spawn_lifecycle: spawn_lifecycle, } } @@ -302,7 +306,15 @@ impl UnifiedExecProcess { }; let likely_sandbox_denial = is_likely_sandbox_denied(sandbox_type, &exec_output); if likely_sandbox_denial { - record_filesystem_sandbox_violation(sandbox_type, &exec_output); + if let Some(violation) = + record_filesystem_sandbox_violation(sandbox_type, &exec_output) + { + crate::security_events::record_sandbox_violation_audit( + self.sandbox_violation_context.as_ref(), + &codex_sandboxing::SandboxViolationEvent::FileSystem(violation), + ) + .await; + } } if executor_reported_denial || likely_sandbox_denial { let snippet = formatted_truncate_text( @@ -322,6 +334,7 @@ impl UnifiedExecProcess { pub(super) async fn from_spawned( spawned: SpawnedPty, sandbox_type: SandboxType, + sandbox_violation_context: Option, spawn_lifecycle: SpawnLifecycleHandle, ) -> Result { let SpawnedPty { @@ -334,6 +347,7 @@ impl UnifiedExecProcess { let mut managed = Self::new( ProcessHandle::Local(Box::new(process_handle)), sandbox_type, + sandbox_violation_context, Some(spawn_lifecycle), ); managed.output_task = Some(Self::spawn_local_output_task( @@ -381,11 +395,14 @@ impl UnifiedExecProcess { pub(super) async fn from_exec_server_started( started: StartedExecProcess, + sandbox_type: SandboxType, + sandbox_violation_context: Option, ) -> Result { let process_handle = ProcessHandle::ExecServer(Arc::clone(&started.process)); let mut managed = Self::new( process_handle, - SandboxType::None, + sandbox_type, + sandbox_violation_context, /*spawn_lifecycle*/ None, ); let output_handles = managed.output_handles(); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index fe3ec45759..75e7aded01 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1056,7 +1056,12 @@ impl UnifiedExecProcessManager { .await .map_err(|err| UnifiedExecError::create_process(err.to_string()))?; spawn_lifecycle.after_spawn(); - return UnifiedExecProcess::from_exec_server_started(started).await; + return UnifiedExecProcess::from_exec_server_started( + started, + request.sandbox, + request.sandbox_violation_context.clone(), + ) + .await; } // TODO(anp): Keep PathUri through the local PTY/process launch boundary. @@ -1096,7 +1101,13 @@ impl UnifiedExecProcessManager { let spawned = spawn_result.map_err(|err| UnifiedExecError::create_process(err.to_string()))?; spawn_lifecycle.after_spawn(); - UnifiedExecProcess::from_spawned(spawned, request.sandbox, spawn_lifecycle).await + UnifiedExecProcess::from_spawned( + spawned, + request.sandbox, + request.sandbox_violation_context.clone(), + spawn_lifecycle, + ) + .await } pub(super) async fn open_session_with_sandbox( diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 20a930b552..02dcf48214 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -170,6 +170,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { file_system_sandbox_policy, network_sandbox_policy, windows_sandbox_filesystem_overrides: None, + sandbox_violation_context: None, arg0: None, exec_server_sandbox: None, exec_server_enforce_managed_network: true, diff --git a/codex-rs/core/src/unified_exec/process_tests.rs b/codex-rs/core/src/unified_exec/process_tests.rs index af0cb62741..c288c629ef 100644 --- a/codex-rs/core/src/unified_exec/process_tests.rs +++ b/codex-rs/core/src/unified_exec/process_tests.rs @@ -102,9 +102,13 @@ async fn remote_process( }), }; - UnifiedExecProcess::from_exec_server_started(started) - .await - .expect("remote process should start") + UnifiedExecProcess::from_exec_server_started( + started, + SandboxType::None, + /*sandbox_violation_context*/ None, + ) + .await + .expect("remote process should start") } #[tokio::test] diff --git a/codex-rs/state/migrations/0030_security_events.sql b/codex-rs/state/migrations/0030_security_events.sql new file mode 100644 index 0000000000..0cadc7899c --- /dev/null +++ b/codex-rs/state/migrations/0030_security_events.sql @@ -0,0 +1,30 @@ +CREATE TABLE security_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at INTEGER NOT NULL, + kind TEXT NOT NULL, + thread_id TEXT, + turn_id TEXT, + call_id TEXT, + tool_name TEXT, + resource TEXT, + sandbox_type TEXT, + reason TEXT, + path TEXT, + host TEXT, + port INTEGER, + protocol TEXT, + method TEXT, + decision TEXT, + source TEXT, + review_id TEXT, + reviewer TEXT, + review_decision TEXT, + details_json TEXT +); + +CREATE INDEX idx_security_events_thread_created + ON security_events(thread_id, created_at DESC, id DESC); + +CREATE INDEX idx_security_events_kind_created + ON security_events(kind, created_at DESC, id DESC); + diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index cdfcd29c50..83820cbf6d 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -22,6 +22,11 @@ pub use model::LogEntry; pub use model::LogQuery; pub use model::LogRow; pub use model::Phase2JobClaimOutcome; +pub use model::SecurityEvent; +pub use model::SecurityEventCreateParams; +pub use model::SecurityEventKind; +pub use model::SecurityEventQuery; +pub use model::SecurityEventResource; /// Preferred entrypoint: owns configuration and metrics. pub use runtime::StateRuntime; diff --git a/codex-rs/state/src/model/mod.rs b/codex-rs/state/src/model/mod.rs index 544ef0b8d5..7c3ab9196a 100644 --- a/codex-rs/state/src/model/mod.rs +++ b/codex-rs/state/src/model/mod.rs @@ -3,6 +3,7 @@ mod backfill_state; mod graph; mod log; mod memories; +mod security_event; mod thread_goal; mod thread_metadata; @@ -24,6 +25,11 @@ pub use memories::Stage1JobClaim; pub use memories::Stage1JobClaimOutcome; pub use memories::Stage1Output; pub use memories::Stage1StartupClaimParams; +pub use security_event::SecurityEvent; +pub use security_event::SecurityEventCreateParams; +pub use security_event::SecurityEventKind; +pub use security_event::SecurityEventQuery; +pub use security_event::SecurityEventResource; pub use thread_goal::ThreadGoal; pub use thread_goal::ThreadGoalStatus; pub use thread_metadata::Anchor; diff --git a/codex-rs/state/src/model/security_event.rs b/codex-rs/state/src/model/security_event.rs new file mode 100644 index 0000000000..98e57750d3 --- /dev/null +++ b/codex-rs/state/src/model/security_event.rs @@ -0,0 +1,157 @@ +use anyhow::anyhow; +use sqlx::Row; +use sqlx::sqlite::SqliteRow; + +/// High-level class for security-relevant audit rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecurityEventKind { + /// Sandbox enforcement observed and blocked an attempted action. + SandboxViolation, + /// A reviewer assessed a proposed action. + GuardianAssessment, +} + +impl SecurityEventKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::SandboxViolation => "sandbox_violation", + Self::GuardianAssessment => "guardian_assessment", + } + } + + pub fn from_db_value(value: &str) -> Option { + match value { + "sandbox_violation" => Some(Self::SandboxViolation), + "guardian_assessment" => Some(Self::GuardianAssessment), + _ => None, + } + } +} + +/// Resource class for sandbox-related security events. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecurityEventResource { + FileSystem, + Network, +} + +impl SecurityEventResource { + pub const fn as_str(self) -> &'static str { + match self { + Self::FileSystem => "filesystem", + Self::Network => "network", + } + } + + pub fn from_db_value(value: &str) -> Option { + match value { + "filesystem" => Some(Self::FileSystem), + "network" => Some(Self::Network), + _ => None, + } + } +} + +/// Insert payload for a security event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecurityEventCreateParams { + pub created_at: i64, + pub kind: SecurityEventKind, + pub thread_id: Option, + pub turn_id: Option, + pub call_id: Option, + pub tool_name: Option, + pub resource: Option, + pub sandbox_type: Option, + pub reason: Option, + pub path: Option, + pub host: Option, + pub port: Option, + pub protocol: Option, + pub method: Option, + pub decision: Option, + pub source: Option, + pub review_id: Option, + pub reviewer: Option, + pub review_decision: Option, + pub details_json: Option, +} + +/// Persisted security event row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecurityEvent { + pub id: i64, + pub created_at: i64, + pub kind: SecurityEventKind, + pub thread_id: Option, + pub turn_id: Option, + pub call_id: Option, + pub tool_name: Option, + pub resource: Option, + pub sandbox_type: Option, + pub reason: Option, + pub path: Option, + pub host: Option, + pub port: Option, + pub protocol: Option, + pub method: Option, + pub decision: Option, + pub source: Option, + pub review_id: Option, + pub reviewer: Option, + pub review_decision: Option, + pub details_json: Option, +} + +impl SecurityEvent { + pub(crate) fn from_row(row: SqliteRow) -> anyhow::Result { + let kind_text: String = row.try_get("kind")?; + let kind = SecurityEventKind::from_db_value(&kind_text) + .ok_or_else(|| anyhow!("unknown security event kind `{kind_text}`"))?; + let resource_text: Option = row.try_get("resource")?; + let resource = resource_text + .map(|resource_text| { + SecurityEventResource::from_db_value(&resource_text) + .ok_or_else(|| anyhow!("unknown security event resource `{resource_text}`")) + }) + .transpose()?; + let port = row + .try_get::, _>("port")? + .map(|port| { + u16::try_from(port).map_err(|_| anyhow!("security event port out of range {port}")) + }) + .transpose()?; + + Ok(Self { + id: row.try_get("id")?, + created_at: row.try_get("created_at")?, + kind, + thread_id: row.try_get("thread_id")?, + turn_id: row.try_get("turn_id")?, + call_id: row.try_get("call_id")?, + tool_name: row.try_get("tool_name")?, + resource, + sandbox_type: row.try_get("sandbox_type")?, + reason: row.try_get("reason")?, + path: row.try_get("path")?, + host: row.try_get("host")?, + port, + protocol: row.try_get("protocol")?, + method: row.try_get("method")?, + decision: row.try_get("decision")?, + source: row.try_get("source")?, + review_id: row.try_get("review_id")?, + reviewer: row.try_get("reviewer")?, + review_decision: row.try_get("review_decision")?, + details_json: row.try_get("details_json")?, + }) + } +} + +/// Filters for reading security events. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SecurityEventQuery { + pub thread_id: Option, + pub kind: Option, + pub limit: Option, +} diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 4b1d264d0b..223372fc00 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -12,6 +12,10 @@ use crate::LogQuery; use crate::LogRow; use crate::MEMORIES_DB_FILENAME; use crate::STATE_DB_FILENAME; +use crate::STATE_DB_VERSION; +use crate::SecurityEvent; +use crate::SecurityEventCreateParams; +use crate::SecurityEventQuery; use crate::SortKey; use crate::ThreadMetadata; use crate::ThreadMetadataBuilder; @@ -66,6 +70,7 @@ mod logs; mod memories; mod recovery; mod remote_control; +mod security_events; #[cfg(test)] mod test_support; mod threads; diff --git a/codex-rs/state/src/runtime/security_events.rs b/codex-rs/state/src/runtime/security_events.rs new file mode 100644 index 0000000000..1372d2614c --- /dev/null +++ b/codex-rs/state/src/runtime/security_events.rs @@ -0,0 +1,310 @@ +use super::*; +#[cfg(test)] +use crate::SecurityEventKind; +use crate::SecurityEventResource; + +impl StateRuntime { + /// Persist a security-relevant audit event. + pub async fn insert_security_event( + &self, + event: &SecurityEventCreateParams, + ) -> anyhow::Result<()> { + sqlx::query( + r#" +INSERT INTO security_events ( + created_at, + kind, + thread_id, + turn_id, + call_id, + tool_name, + resource, + sandbox_type, + reason, + path, + host, + port, + protocol, + method, + decision, + source, + review_id, + reviewer, + review_decision, + details_json +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(event.created_at) + .bind(event.kind.as_str()) + .bind(event.thread_id.as_deref()) + .bind(event.turn_id.as_deref()) + .bind(event.call_id.as_deref()) + .bind(event.tool_name.as_deref()) + .bind(event.resource.map(SecurityEventResource::as_str)) + .bind(event.sandbox_type.as_deref()) + .bind(event.reason.as_deref()) + .bind(event.path.as_deref()) + .bind(event.host.as_deref()) + .bind(event.port.map(i64::from)) + .bind(event.protocol.as_deref()) + .bind(event.method.as_deref()) + .bind(event.decision.as_deref()) + .bind(event.source.as_deref()) + .bind(event.review_id.as_deref()) + .bind(event.reviewer.as_deref()) + .bind(event.review_decision.as_deref()) + .bind(event.details_json.as_deref()) + .execute(self.pool.as_ref()) + .await?; + Ok(()) + } + + /// List security events with optional thread/kind filters. + pub async fn list_security_events( + &self, + query: &SecurityEventQuery, + ) -> anyhow::Result> { + let mut builder = QueryBuilder::::new( + r#" +SELECT + id, + created_at, + kind, + thread_id, + turn_id, + call_id, + tool_name, + resource, + sandbox_type, + reason, + path, + host, + port, + protocol, + method, + decision, + source, + review_id, + reviewer, + review_decision, + details_json +FROM security_events + "#, + ); + let mut has_where = false; + if let Some(thread_id) = query.thread_id.as_deref() { + builder.push(" WHERE "); + builder.push("thread_id = ").push_bind(thread_id); + has_where = true; + } + if let Some(kind) = query.kind { + if has_where { + builder.push(" AND "); + } else { + builder.push(" WHERE "); + } + builder.push("kind = ").push_bind(kind.as_str()); + } + builder.push(" ORDER BY created_at DESC, id DESC"); + if let Some(limit) = query.limit { + builder.push(" LIMIT ").push_bind(i64::from(limit)); + } + + let rows = builder + .build() + .fetch_all(self.pool.as_ref()) + .await? + .into_iter() + .map(SecurityEvent::from_row) + .collect::>>()?; + Ok(rows) + } +} + +#[cfg(test)] +mod tests { + use super::StateRuntime; + use super::test_support::unique_temp_dir; + use super::*; + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn security_events_round_trip_and_filter() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("initialize runtime"); + + runtime + .insert_security_event(&SecurityEventCreateParams { + created_at: 1_700_000_000, + kind: SecurityEventKind::SandboxViolation, + thread_id: Some("thread-1".to_string()), + turn_id: Some("turn-1".to_string()), + call_id: Some("call-1".to_string()), + tool_name: Some("shell".to_string()), + resource: Some(SecurityEventResource::FileSystem), + sandbox_type: Some("macos_seatbelt".to_string()), + reason: Some("permission_denied".to_string()), + path: Some("/private/var/db".to_string()), + host: None, + port: None, + protocol: None, + method: None, + decision: None, + source: None, + review_id: None, + reviewer: None, + review_decision: None, + details_json: None, + }) + .await + .expect("insert filesystem event"); + runtime + .insert_security_event(&SecurityEventCreateParams { + created_at: 1_700_000_001, + kind: SecurityEventKind::SandboxViolation, + thread_id: Some("thread-1".to_string()), + turn_id: Some("turn-2".to_string()), + call_id: Some("call-2".to_string()), + tool_name: Some("shell".to_string()), + resource: Some(SecurityEventResource::Network), + sandbox_type: None, + reason: Some("not_allowed".to_string()), + path: None, + host: Some("example.com".to_string()), + port: Some(443), + protocol: Some("https_connect".to_string()), + method: Some("CONNECT".to_string()), + decision: Some("deny".to_string()), + source: Some("proxy_state".to_string()), + review_id: None, + reviewer: None, + review_decision: None, + details_json: None, + }) + .await + .expect("insert network event"); + runtime + .insert_security_event(&SecurityEventCreateParams { + created_at: 1_700_000_002, + kind: SecurityEventKind::GuardianAssessment, + thread_id: Some("thread-2".to_string()), + turn_id: Some("turn-3".to_string()), + call_id: Some("call-3".to_string()), + tool_name: Some("shell".to_string()), + resource: None, + sandbox_type: None, + reason: None, + path: None, + host: None, + port: None, + protocol: None, + method: None, + decision: None, + source: None, + review_id: Some("review-1".to_string()), + reviewer: Some("auto_review".to_string()), + review_decision: Some("denied".to_string()), + details_json: None, + }) + .await + .expect("insert guardian event"); + + assert_eq!( + runtime + .list_security_events(&SecurityEventQuery { + thread_id: Some("thread-1".to_string()), + kind: Some(SecurityEventKind::SandboxViolation), + limit: None, + }) + .await + .expect("list sandbox events"), + vec![ + SecurityEvent { + id: 2, + created_at: 1_700_000_001, + kind: SecurityEventKind::SandboxViolation, + thread_id: Some("thread-1".to_string()), + turn_id: Some("turn-2".to_string()), + call_id: Some("call-2".to_string()), + tool_name: Some("shell".to_string()), + resource: Some(SecurityEventResource::Network), + sandbox_type: None, + reason: Some("not_allowed".to_string()), + path: None, + host: Some("example.com".to_string()), + port: Some(443), + protocol: Some("https_connect".to_string()), + method: Some("CONNECT".to_string()), + decision: Some("deny".to_string()), + source: Some("proxy_state".to_string()), + review_id: None, + reviewer: None, + review_decision: None, + details_json: None, + }, + SecurityEvent { + id: 1, + created_at: 1_700_000_000, + kind: SecurityEventKind::SandboxViolation, + thread_id: Some("thread-1".to_string()), + turn_id: Some("turn-1".to_string()), + call_id: Some("call-1".to_string()), + tool_name: Some("shell".to_string()), + resource: Some(SecurityEventResource::FileSystem), + sandbox_type: Some("macos_seatbelt".to_string()), + reason: Some("permission_denied".to_string()), + path: Some("/private/var/db".to_string()), + host: None, + port: None, + protocol: None, + method: None, + decision: None, + source: None, + review_id: None, + reviewer: None, + review_decision: None, + details_json: None, + }, + ] + ); + + assert_eq!( + runtime + .list_security_events(&SecurityEventQuery { + thread_id: None, + kind: Some(SecurityEventKind::GuardianAssessment), + limit: Some(1), + }) + .await + .expect("list guardian events"), + vec![SecurityEvent { + id: 3, + created_at: 1_700_000_002, + kind: SecurityEventKind::GuardianAssessment, + thread_id: Some("thread-2".to_string()), + turn_id: Some("turn-3".to_string()), + call_id: Some("call-3".to_string()), + tool_name: Some("shell".to_string()), + resource: None, + sandbox_type: None, + reason: None, + path: None, + host: None, + port: None, + protocol: None, + method: None, + decision: None, + source: None, + review_id: Some("review-1".to_string()), + reviewer: Some("auto_review".to_string()), + review_decision: Some("denied".to_string()), + details_json: None, + }] + ); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } +}