mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
feat: persist sandbox security events
Co-authored-by: Codex noreply@openai.com
This commit is contained in:
co-authored by
Codex noreply@openai.com
parent
dd16e95b6c
commit
fdbd667179
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user