feat: persist sandbox security events

Co-authored-by: Codex noreply@openai.com
This commit is contained in:
viyatb-oai
2026-07-07 12:50:20 -07:00
co-authored by Codex noreply@openai.com
parent dd16e95b6c
commit fdbd667179
21 changed files with 882 additions and 21 deletions
+20 -3
View File
@@ -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<RawExecToolCallOutput, CodexErr>,
sandbox_type: SandboxType,
duration: Duration,
sandbox_violation_context: Option<&crate::security_events::SandboxViolationAuditContext>,
) -> Result<ExecToolCallOutput> {
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,
+1
View File
@@ -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;
+6
View File
@@ -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<SandboxViolationAuditContext>,
}
#[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<WindowsSandboxFilesystemOverrides>,
pub(crate) sandbox_violation_context: Option<SandboxViolationAuditContext>,
pub arg0: Option<String>,
pub(crate) exec_server_sandbox: Option<FileSystemSandboxContext>,
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,
+238
View File
@@ -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<StateDbHandle>,
pub(crate) metadata: NetworkProxyAuditMetadata,
pub(crate) turn_id: Option<String>,
pub(crate) call_id: Option<String>,
pub(crate) tool_name: Option<String>,
}
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<StateDbHandle>,
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<i64>,
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)
}
+1
View File
@@ -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,
+11 -1
View File
@@ -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<NetworkApprovalService>,
sandbox_violation_context: SandboxViolationAuditContext,
) -> Arc<dyn BlockedRequestObserver> {
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;
}
})
@@ -234,7 +234,7 @@ impl ToolRuntime<ApplyPatchRequest, ApplyPatchRuntimeOutput> for ApplyPatchRunti
&mut self,
req: &ApplyPatchRequest,
attempt: &SandboxAttempt<'_>,
_ctx: &ToolCtx,
ctx: &ToolCtx,
) -> Result<ApplyPatchRuntimeOutput, ToolError> {
let started_at = Instant::now();
let fs = req.turn_environment.environment.get_filesystem();
@@ -268,7 +268,13 @@ impl ToolRuntime<ApplyPatchRequest, ApplyPatchRuntimeOutput> 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,
@@ -318,6 +318,9 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> 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(
@@ -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<AbsolutePathBuf>,
codex_linux_sandbox_exe: Option<PathBuf>,
use_legacy_landlock: bool,
sandbox_violation_context: Option<crate::security_events::SandboxViolationAuditContext>,
}
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<ParsedShellCommand, ToolEr
))
}
fn map_exec_result(
async fn map_exec_result(
sandbox: SandboxType,
result: ExecResult,
sandbox_violation_context: Option<&crate::security_events::SandboxViolationAuditContext>,
) -> Result<ExecToolCallOutput, ToolError> {
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,
@@ -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");
@@ -102,6 +102,7 @@ pub struct UnifiedExecRuntime<'a> {
fn unified_exec_options(
network_denial_cancellation_token: Option<CancellationToken>,
sandbox_violation_context: Option<crate::security_events::SandboxViolationAuditContext>,
) -> 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<UnifiedExecRequest, UnifiedExecProcess> 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<UnifiedExecRequest, UnifiedExecProcess> 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 {
+19 -2
View File
@@ -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<ProcessState>,
output_task: Option<JoinHandle<()>>,
sandbox_type: SandboxType,
sandbox_violation_context: Option<SandboxViolationAuditContext>,
_spawn_lifecycle: Option<SpawnLifecycleHandle>,
}
@@ -104,6 +106,7 @@ impl UnifiedExecProcess {
fn new(
process_handle: ProcessHandle,
sandbox_type: SandboxType,
sandbox_violation_context: Option<SandboxViolationAuditContext>,
spawn_lifecycle: Option<SpawnLifecycleHandle>,
) -> 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<SandboxViolationAuditContext>,
spawn_lifecycle: SpawnLifecycleHandle,
) -> Result<Self, UnifiedExecError> {
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<SandboxViolationAuditContext>,
) -> Result<Self, UnifiedExecError> {
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();
@@ -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(
@@ -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,
@@ -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]
@@ -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);
+5
View File
@@ -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;
+6
View File
@@ -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;
+157
View File
@@ -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<Self> {
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<Self> {
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<String>,
pub turn_id: Option<String>,
pub call_id: Option<String>,
pub tool_name: Option<String>,
pub resource: Option<SecurityEventResource>,
pub sandbox_type: Option<String>,
pub reason: Option<String>,
pub path: Option<String>,
pub host: Option<String>,
pub port: Option<u16>,
pub protocol: Option<String>,
pub method: Option<String>,
pub decision: Option<String>,
pub source: Option<String>,
pub review_id: Option<String>,
pub reviewer: Option<String>,
pub review_decision: Option<String>,
pub details_json: Option<String>,
}
/// 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<String>,
pub turn_id: Option<String>,
pub call_id: Option<String>,
pub tool_name: Option<String>,
pub resource: Option<SecurityEventResource>,
pub sandbox_type: Option<String>,
pub reason: Option<String>,
pub path: Option<String>,
pub host: Option<String>,
pub port: Option<u16>,
pub protocol: Option<String>,
pub method: Option<String>,
pub decision: Option<String>,
pub source: Option<String>,
pub review_id: Option<String>,
pub reviewer: Option<String>,
pub review_decision: Option<String>,
pub details_json: Option<String>,
}
impl SecurityEvent {
pub(crate) fn from_row(row: SqliteRow) -> anyhow::Result<Self> {
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<String> = 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::<Option<i64>, _>("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<String>,
pub kind: Option<SecurityEventKind>,
pub limit: Option<u32>,
}
+5
View File
@@ -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;
@@ -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<Vec<SecurityEvent>> {
let mut builder = QueryBuilder::<Sqlite>::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::<anyhow::Result<Vec<_>>>()?;
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;
}
}