Compare commits

...

1 Commits

Author SHA1 Message Date
viyatb-oai
df24c55fac feat(core): restore security audit monitor 2026-03-03 11:21:12 -08:00
16 changed files with 817 additions and 2 deletions

View File

@@ -1321,6 +1321,53 @@
},
"type": "object"
},
"SecurityAuditLogToml": {
"additionalProperties": false,
"properties": {
"dir": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Directory used for JSONL audit files. Defaults to `$CODEX_HOME/auditlog`."
},
"enabled": {
"description": "When `true`, append sanitized audit records to a local JSONL file.",
"type": "boolean"
}
},
"type": "object"
},
"SecurityToml": {
"additionalProperties": false,
"description": "Security audit settings loaded from config.toml.",
"properties": {
"auditlog": {
"allOf": [
{
"$ref": "#/definitions/SecurityAuditLogToml"
}
],
"description": "Optional audit log persistence settings."
},
"emit_core_events": {
"description": "When `true`, emit `EventMsg::Security` in addition to writing to the local audit trail.",
"type": "boolean"
},
"enabled": {
"description": "When `true`, enable the runtime security monitor.",
"type": "boolean"
},
"session_buffer_limit": {
"description": "Maximum number of audit records retained in memory for the current session.",
"format": "uint",
"minimum": 0.0,
"type": "integer"
}
},
"type": "object"
},
"ShellEnvironmentPolicyInherit": {
"oneOf": [
{
@@ -2108,6 +2155,14 @@
],
"description": "Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`."
},
"security": {
"allOf": [
{
"$ref": "#/definitions/SecurityToml"
}
],
"description": "Runtime security audit settings."
},
"shell_environment_policy": {
"allOf": [
{

View File

@@ -245,6 +245,7 @@ use crate::rollout::RolloutRecorderParams;
use crate::rollout::map_session_init_error;
use crate::rollout::metadata;
use crate::rollout::policy::EventPersistenceMode;
use crate::security::SecurityMonitor;
use crate::shell;
use crate::shell_snapshot::ShellSnapshot;
use crate::skills::SkillError;
@@ -1460,6 +1461,12 @@ impl Session {
} else {
(None, None)
};
let security_monitor = config.security.enabled.then(|| {
Arc::new(SecurityMonitor::new(
conversation_id,
config.security.clone(),
))
});
let services = SessionServices {
// Initialize the MCP connection manager with an uninitialized
@@ -1486,6 +1493,7 @@ impl Session {
legacy_notify_argv: config.notify.clone(),
}),
rollout: Mutex::new(rollout_recorder),
security_monitor,
user_shell: Arc::new(default_shell),
shell_snapshot_tx,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
@@ -2359,6 +2367,11 @@ impl Session {
/// Persist the event to rollout and send it to clients.
pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) {
let security_event = self
.services
.security_monitor
.as_ref()
.and_then(|monitor| monitor.capture(&msg));
let legacy_source = msg.clone();
let event = Event {
id: turn_context.sub_id.clone(),
@@ -2376,6 +2389,14 @@ impl Session {
};
self.send_event_raw(legacy_event).await;
}
if let Some(security_event) = security_event {
let security_event = Event {
id: turn_context.sub_id.clone(),
msg: EventMsg::Security(security_event),
};
self.send_event_raw(security_event).await;
}
}
async fn maybe_mirror_event_text_to_realtime(&self, msg: &EventMsg) {
@@ -5844,6 +5865,7 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::ApplyPatchApprovalRequest(_)
| EventMsg::DeprecationNotice(_)
| EventMsg::BackgroundEvent(_)
| EventMsg::Security(_)
| EventMsg::UndoStarted(_)
| EventMsg::UndoCompleted(_)
| EventMsg::StreamError(_)
@@ -8301,6 +8323,7 @@ mod tests {
legacy_notify_argv: config.notify.clone(),
}),
rollout: Mutex::new(None),
security_monitor: None,
user_shell: Arc::new(default_user_shell()),
shell_snapshot_tx: watch::channel(None).0,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
@@ -8469,6 +8492,7 @@ mod tests {
legacy_notify_argv: config.notify.clone(),
}),
rollout: Mutex::new(None),
security_monitor: None,
user_shell: Arc::new(default_user_shell()),
shell_snapshot_tx: watch::channel(None).0,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,

View File

@@ -18,6 +18,8 @@ use crate::config::types::OtelConfigToml;
use crate::config::types::OtelExporterKind;
use crate::config::types::PluginConfig;
use crate::config::types::SandboxWorkspaceWrite;
use crate::config::types::SecurityConfig;
use crate::config::types::SecurityToml;
use crate::config::types::ShellEnvironmentPolicy;
use crate::config::types::ShellEnvironmentPolicyToml;
use crate::config::types::SkillsConfig;
@@ -374,6 +376,9 @@ pub struct Config {
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
pub history: History,
/// Runtime security audit settings.
pub security: SecurityConfig,
/// When true, session is not persisted on disk. Default to `false`
pub ephemeral: bool,
@@ -1209,6 +1214,9 @@ pub struct ConfigToml {
/// Memories subsystem settings.
pub memories: Option<MemoriesToml>,
/// Runtime security audit settings.
pub security: Option<SecurityToml>,
/// User-level skill config entries keyed by SKILL.md path.
pub skills: Option<SkillsConfig>,
@@ -2083,6 +2091,10 @@ impl Config {
} else {
network.enabled().then_some(network)
};
let mut security: SecurityConfig = cfg.security.unwrap_or_default().into();
if security.auditlog.enabled && security.auditlog.dir.is_none() {
security.auditlog.dir = Some(codex_home.join("auditlog"));
}
let config = Self {
model,
@@ -2146,6 +2158,7 @@ impl Config {
log_dir,
config_layer_stack,
history,
security,
ephemeral: ephemeral.unwrap_or_default(),
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
codex_linux_sandbox_exe,
@@ -2557,6 +2570,41 @@ consolidation_model = "gpt-5"
);
}
#[test]
fn security_auditlog_defaults_to_codex_home_subdir() {
let codex_home = tempdir().expect("tempdir");
let cfg = Config::load_from_base_config_with_overrides(
ConfigToml {
security: Some(SecurityToml {
enabled: Some(true),
emit_core_events: Some(true),
session_buffer_limit: Some(64),
auditlog: Some(crate::config::types::SecurityAuditLogToml {
enabled: Some(true),
dir: None,
}),
}),
..Default::default()
},
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)
.expect("load config");
assert_eq!(
cfg.security,
SecurityConfig {
enabled: true,
emit_core_events: true,
session_buffer_limit: 64,
auditlog: crate::config::types::SecurityAuditLogConfig {
enabled: true,
dir: Some(codex_home.path().join("auditlog")),
},
}
);
}
#[test]
fn config_toml_deserializes_model_availability_nux() {
let toml = r#"
@@ -4905,6 +4953,7 @@ model_verbosity = "high"
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
security: SecurityConfig::default(),
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
@@ -5033,6 +5082,7 @@ model_verbosity = "high"
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
security: SecurityConfig::default(),
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
@@ -5159,6 +5209,7 @@ model_verbosity = "high"
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
security: SecurityConfig::default(),
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
@@ -5271,6 +5322,7 @@ model_verbosity = "high"
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
security: SecurityConfig::default(),
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,

View File

@@ -28,6 +28,7 @@ pub const DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS: i64 = 30;
pub const DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS: i64 = 6;
pub const DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION: usize = 256;
pub const DEFAULT_MEMORIES_MAX_UNUSED_DAYS: i64 = 30;
pub const DEFAULT_SECURITY_SESSION_BUFFER_LIMIT: usize = 128;
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "kebab-case")]
@@ -350,6 +351,79 @@ pub enum HistoryPersistence {
None,
}
/// Security audit settings loaded from config.toml.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SecurityToml {
/// When `true`, enable the runtime security monitor.
pub enabled: Option<bool>,
/// When `true`, emit `EventMsg::Security` in addition to writing to the local audit trail.
pub emit_core_events: Option<bool>,
/// Maximum number of audit records retained in memory for the current session.
pub session_buffer_limit: Option<usize>,
/// Optional audit log persistence settings.
pub auditlog: Option<SecurityAuditLogToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SecurityAuditLogToml {
/// When `true`, append sanitized audit records to a local JSONL file.
pub enabled: Option<bool>,
/// Directory used for JSONL audit files. Defaults to `$CODEX_HOME/auditlog`.
pub dir: Option<AbsolutePathBuf>,
}
/// Effective runtime security audit settings after defaults are applied.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecurityConfig {
pub enabled: bool,
pub emit_core_events: bool,
pub session_buffer_limit: usize,
pub auditlog: SecurityAuditLogConfig,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
enabled: false,
emit_core_events: false,
session_buffer_limit: DEFAULT_SECURITY_SESSION_BUFFER_LIMIT,
auditlog: SecurityAuditLogConfig::default(),
}
}
}
impl From<SecurityToml> for SecurityConfig {
fn from(toml: SecurityToml) -> Self {
let defaults = Self::default();
Self {
enabled: toml.enabled.unwrap_or(defaults.enabled),
emit_core_events: toml.emit_core_events.unwrap_or(defaults.emit_core_events),
session_buffer_limit: toml
.session_buffer_limit
.unwrap_or(defaults.session_buffer_limit)
.clamp(1, 1_024),
auditlog: toml.auditlog.unwrap_or_default().into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SecurityAuditLogConfig {
pub enabled: bool,
pub dir: Option<PathBuf>,
}
impl From<SecurityAuditLogToml> for SecurityAuditLogConfig {
fn from(toml: SecurityAuditLogToml) -> Self {
Self {
enabled: toml.enabled.unwrap_or(false),
dir: toml.dir.map(AbsolutePathBuf::into_path_buf),
}
}
}
// ===== Analytics configuration =====
/// Analytics settings loaded from config.toml. Fields are optional so we can apply defaults.

View File

@@ -82,6 +82,7 @@ mod event_mapping;
pub mod review_format;
pub mod review_prompts;
mod seatbelt_permissions;
mod security;
mod thread_manager;
pub mod web_search;
pub mod windows_sandbox_read_grants;

View File

@@ -148,6 +148,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
| EventMsg::StreamError(_)
| EventMsg::PatchApplyBegin(_)
| EventMsg::TurnDiff(_)
| EventMsg::Security(_)
| EventMsg::GetHistoryEntryResponse(_)
| EventMsg::UndoStarted(_)
| EventMsg::McpListToolsResponse(_)

View File

@@ -0,0 +1,171 @@
use std::collections::VecDeque;
use std::fs::OpenOptions;
use std::io::Error;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Mutex;
use serde::Deserialize;
use serde::Serialize;
use time::OffsetDateTime;
use tracing::warn;
#[cfg(test)]
use super::stats::SecurityStats;
use codex_protocol::protocol::SecurityEvent;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub(crate) struct AuditLog {
pub(crate) recorded_at: i64,
pub(crate) event: SecurityEvent,
}
pub(crate) struct AuditLogger {
session_buffer_limit: usize,
file_path: Option<PathBuf>,
records: Mutex<VecDeque<AuditLog>>,
}
impl AuditLogger {
pub(crate) fn new(session_buffer_limit: usize, file_path: Option<PathBuf>) -> Self {
Self {
session_buffer_limit,
file_path,
records: Mutex::new(VecDeque::with_capacity(session_buffer_limit)),
}
}
pub(crate) fn record(&self, event: SecurityEvent) {
let record = AuditLog {
recorded_at: OffsetDateTime::now_utc().unix_timestamp(),
event,
};
match self.records.lock() {
Ok(mut records) => {
records.push_back(record.clone());
while records.len() > self.session_buffer_limit {
records.pop_front();
}
}
Err(err) => {
warn!("failed to lock in-memory audit log buffer: {err}");
return;
}
}
if let Some(file_path) = self.file_path.as_deref()
&& let Err(err) = Self::append_record(file_path, &record)
{
warn!(
"failed to append audit log to {}: {err}",
file_path.display()
);
}
}
#[cfg(test)]
pub(crate) fn snapshot(&self) -> Vec<AuditLog> {
match self.records.lock() {
Ok(records) => records.iter().cloned().collect(),
Err(err) => {
warn!("failed to read in-memory audit log buffer: {err}");
Vec::new()
}
}
}
#[cfg(test)]
pub(crate) fn stats(&self) -> SecurityStats {
let snapshot = self.snapshot();
let allowed = snapshot
.iter()
.filter(|record| record.event.allowed == Some(true))
.count();
let denied = snapshot
.iter()
.filter(|record| record.event.allowed == Some(false))
.count();
SecurityStats {
total: snapshot.len(),
allowed,
denied,
}
}
fn append_record(file_path: &Path, record: &AuditLog) -> std::io::Result<()> {
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent)?;
}
let serialized = serde_json::to_string(record)
.map_err(|err| Error::other(format!("failed to serialize audit log: {err}")))?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(file_path)?;
writeln!(file, "{serialized}")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::AuditLogger;
use codex_protocol::protocol::SecurityEvent;
use codex_protocol::protocol::SecurityEventKind;
use pretty_assertions::assert_eq;
use tempfile::NamedTempFile;
use tempfile::TempDir;
fn test_event(action: &str) -> SecurityEvent {
SecurityEvent {
kind: SecurityEventKind::Command,
action: action.to_owned(),
turn_id: "turn-1".to_owned(),
call_id: Some("call-1".to_owned()),
allowed: None,
target: Some("echo hello".to_owned()),
details: None,
duration_ms: None,
}
}
#[test]
fn record_keeps_a_bounded_buffer() {
let logger = AuditLogger::new(2, None);
logger.record(test_event("one"));
logger.record(test_event("two"));
logger.record(test_event("three"));
let snapshot = logger.snapshot();
assert_eq!(2, snapshot.len());
assert_eq!("two", snapshot[0].event.action);
assert_eq!("three", snapshot[1].event.action);
assert_eq!(2, logger.stats().total);
}
#[test]
fn record_appends_jsonl_when_enabled() {
let dir = TempDir::new().expect("tempdir");
let file_path = dir.path().join("audit.jsonl");
let logger = AuditLogger::new(2, Some(file_path.clone()));
logger.record(test_event("one"));
let contents = std::fs::read_to_string(file_path).expect("auditlog");
assert!(contents.contains("\"action\":\"one\""));
}
#[test]
fn record_is_best_effort_when_file_write_fails() {
let not_a_directory = NamedTempFile::new().expect("temp file");
let impossible_path = not_a_directory.path().join("audit.jsonl");
let logger = AuditLogger::new(2, Some(impossible_path));
logger.record(test_event("one"));
let snapshot = logger.snapshot();
assert_eq!(1, snapshot.len());
assert_eq!("one", snapshot[0].event.action);
}
}

View File

@@ -0,0 +1,7 @@
mod audit_logger;
mod monitor;
mod redactor;
#[cfg(test)]
mod stats;
pub(crate) use monitor::SecurityMonitor;

View File

@@ -0,0 +1,287 @@
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use crate::config::types::SecurityConfig;
use codex_protocol::ThreadId;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::ExecCommandStatus;
use codex_protocol::protocol::SecurityEvent;
use codex_protocol::protocol::SecurityEventKind;
#[cfg(test)]
use super::audit_logger::AuditLog;
use super::audit_logger::AuditLogger;
use super::redactor::SecurityRedactor;
#[cfg(test)]
use super::stats::SecurityStats;
pub(crate) struct SecurityMonitor {
emit_core_events: bool,
in_capture: AtomicBool,
logger: AuditLogger,
redactor: SecurityRedactor,
}
impl SecurityMonitor {
pub(crate) fn new(thread_id: ThreadId, config: SecurityConfig) -> Self {
let file_path = if config.auditlog.enabled {
let base_dir = config
.auditlog
.dir
.clone()
.unwrap_or_else(|| PathBuf::from("auditlog"));
Some(base_dir.join(format!("{thread_id}.jsonl")))
} else {
None
};
Self {
emit_core_events: config.emit_core_events,
in_capture: AtomicBool::new(false),
logger: AuditLogger::new(config.session_buffer_limit, file_path),
redactor: SecurityRedactor,
}
}
pub(crate) fn capture(&self, event: &EventMsg) -> Option<SecurityEvent> {
if self.in_capture.swap(true, Ordering::SeqCst) {
return None;
}
let security_event = self.to_security_event(event);
if let Some(security_event) = security_event.as_ref() {
self.logger.record(security_event.clone());
}
self.in_capture.store(false, Ordering::SeqCst);
if self.emit_core_events {
security_event
} else {
None
}
}
#[cfg(test)]
pub(crate) fn snapshot(&self) -> Vec<AuditLog> {
self.logger.snapshot()
}
#[cfg(test)]
pub(crate) fn stats(&self) -> SecurityStats {
self.logger.stats()
}
fn to_security_event(&self, event: &EventMsg) -> Option<SecurityEvent> {
match event {
EventMsg::ExecCommandBegin(event) => Some(SecurityEvent {
kind: SecurityEventKind::Command,
action: "exec_command_begin".to_owned(),
turn_id: event.turn_id.clone(),
call_id: Some(event.call_id.clone()),
allowed: None,
target: self.redactor.sanitize_command(&event.command),
details: Some(format!("cwd={}", self.redactor.sanitize_path(&event.cwd))),
duration_ms: None,
}),
EventMsg::ExecCommandEnd(event) => Some(self.exec_command_end_event(event)),
EventMsg::ExecApprovalRequest(event) => Some(self.exec_approval_request_event(event)),
EventMsg::ApplyPatchApprovalRequest(event) => {
Some(self.apply_patch_approval_request_event(event))
}
EventMsg::Security(_) => None,
_ => None,
}
}
fn exec_command_end_event(&self, event: &ExecCommandEndEvent) -> SecurityEvent {
let status = match event.status {
ExecCommandStatus::Completed => "completed",
ExecCommandStatus::Failed => "failed",
ExecCommandStatus::Declined => "declined",
};
let duration_ms = u64::try_from(event.duration.as_millis()).unwrap_or(u64::MAX);
SecurityEvent {
kind: SecurityEventKind::Command,
action: "exec_command_end".to_owned(),
turn_id: event.turn_id.clone(),
call_id: Some(event.call_id.clone()),
allowed: None,
target: self.redactor.sanitize_command(&event.command),
details: Some(
self.redactor
.sanitize_text(&format!("status={status} exit_code={}", event.exit_code)),
),
duration_ms: Some(duration_ms),
}
}
fn exec_approval_request_event(&self, event: &ExecApprovalRequestEvent) -> SecurityEvent {
let mut details = event
.reason
.as_deref()
.map(|reason| self.redactor.sanitize_text(reason));
if details.is_none()
&& let Some(context) = event.network_approval_context.as_ref()
{
details = Some(self.redactor.sanitize_text(&context.host));
}
SecurityEvent {
kind: SecurityEventKind::Permission,
action: "exec_approval_request".to_owned(),
turn_id: event.turn_id.clone(),
call_id: Some(event.effective_approval_id()),
allowed: None,
target: self.redactor.sanitize_command(&event.command),
details,
duration_ms: None,
}
}
fn apply_patch_approval_request_event(
&self,
event: &ApplyPatchApprovalRequestEvent,
) -> SecurityEvent {
let mut details = event
.reason
.as_deref()
.map(|reason| self.redactor.sanitize_text(reason));
if details.is_none()
&& let Some(grant_root) = event.grant_root.as_deref()
{
details = Some(format!(
"grant_root={}",
self.redactor.sanitize_path(grant_root)
));
}
SecurityEvent {
kind: SecurityEventKind::Permission,
action: "apply_patch_approval_request".to_owned(),
turn_id: event.turn_id.clone(),
call_id: Some(event.call_id.clone()),
allowed: None,
target: Some(format!("{} file(s)", event.changes.len())),
details,
duration_ms: None,
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use super::SecurityMonitor;
use crate::config::types::SecurityAuditLogConfig;
use crate::config::types::SecurityConfig;
use codex_protocol::ThreadId;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::ExecCommandSource;
use codex_protocol::protocol::ExecCommandStatus;
use codex_protocol::protocol::SecurityEvent;
use codex_protocol::protocol::SecurityEventKind;
use pretty_assertions::assert_eq;
#[test]
fn capture_records_and_emits_core_event() {
let monitor = SecurityMonitor::new(
ThreadId::new(),
SecurityConfig {
enabled: true,
emit_core_events: true,
session_buffer_limit: 4,
auditlog: SecurityAuditLogConfig::default(),
},
);
let event = EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: "call-1".to_owned(),
process_id: None,
turn_id: "turn-1".to_owned(),
command: vec![
"echo".to_owned(),
"sk-abcdefghijklmnopqrstuvwxyz123456".to_owned(),
],
cwd: PathBuf::from("/tmp/workspace"),
parsed_cmd: Vec::new(),
source: ExecCommandSource::Agent,
interaction_input: None,
stdout: String::new(),
stderr: String::new(),
aggregated_output: String::new(),
exit_code: 0,
duration: Duration::from_millis(25),
formatted_output: String::new(),
status: ExecCommandStatus::Completed,
});
let security_event = monitor.capture(&event).expect("security event");
assert_eq!(SecurityEventKind::Command, security_event.kind);
assert_eq!("exec_command_end", security_event.action);
assert_eq!(Some(25), security_event.duration_ms);
assert_eq!(
Some("echo [REDACTED_SECRET]".to_owned()),
security_event.target
);
let snapshot = monitor.snapshot();
assert_eq!(1, snapshot.len());
assert_eq!(security_event, snapshot[0].event);
assert_eq!(1, monitor.stats().total);
}
#[test]
fn security_events_are_not_re_emitted() {
let monitor = SecurityMonitor::new(ThreadId::new(), SecurityConfig::default());
let event = EventMsg::Security(SecurityEvent {
kind: SecurityEventKind::Command,
action: "existing".to_owned(),
turn_id: "turn-1".to_owned(),
call_id: None,
allowed: None,
target: None,
details: None,
duration_ms: None,
});
assert_eq!(None, monitor.capture(&event));
assert_eq!(0, monitor.snapshot().len());
}
#[test]
fn apply_patch_details_fall_back_to_grant_root() {
let monitor = SecurityMonitor::new(
ThreadId::new(),
SecurityConfig {
enabled: true,
emit_core_events: true,
session_buffer_limit: 4,
auditlog: SecurityAuditLogConfig::default(),
},
);
let event = EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id: "call-1".to_owned(),
turn_id: "turn-1".to_owned(),
changes: HashMap::new(),
reason: None,
grant_root: Some(PathBuf::from("/tmp/workspace")),
});
let security_event = monitor.capture(&event).expect("security event");
assert_eq!(SecurityEventKind::Permission, security_event.kind);
assert_eq!(
Some("grant_root=/tmp/workspace".to_owned()),
security_event.details
);
}
}

View File

@@ -0,0 +1,55 @@
use std::path::Path;
use codex_secrets::redact_secrets;
const MAX_PREVIEW_CHARS: usize = 256;
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct SecurityRedactor;
impl SecurityRedactor {
pub(crate) fn sanitize_command(self, command: &[String]) -> Option<String> {
(!command.is_empty()).then(|| self.sanitize_text(&command.join(" ")))
}
pub(crate) fn sanitize_path(self, path: &Path) -> String {
let display = dunce::simplified(path).display().to_string();
self.sanitize_text(&display)
}
pub(crate) fn sanitize_text(self, text: &str) -> String {
let redacted = redact_secrets(text.to_owned());
self.truncate(redacted)
}
fn truncate(self, value: String) -> String {
let mut chars = value.chars();
let truncated: String = chars.by_ref().take(MAX_PREVIEW_CHARS).collect();
if chars.next().is_some() {
format!("{truncated}...")
} else {
value
}
}
}
#[cfg(test)]
mod tests {
use super::SecurityRedactor;
use pretty_assertions::assert_eq;
#[test]
fn redact_secrets_is_applied() {
let redactor = SecurityRedactor;
let value = redactor.sanitize_text("Bearer abcdefghijklmnopqrstuvwxyz123456");
assert_eq!("Bearer [REDACTED_SECRET]", value);
}
#[test]
fn values_are_truncated() {
let redactor = SecurityRedactor;
let value = redactor.sanitize_text(&"a".repeat(300));
assert_eq!(259, value.len());
assert!(value.ends_with("..."));
}
}

View File

@@ -0,0 +1,6 @@
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SecurityStats {
pub(crate) total: usize,
pub(crate) allowed: usize,
pub(crate) denied: usize,
}

View File

@@ -13,6 +13,7 @@ use crate::mcp::McpManager;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::models_manager::manager::ModelsManager;
use crate::plugins::PluginsManager;
use crate::security::SecurityMonitor;
use crate::skills::SkillsManager;
use crate::state_db::StateDbHandle;
use crate::tools::network_approval::NetworkApprovalService;
@@ -39,6 +40,7 @@ pub(crate) struct SessionServices {
pub(crate) analytics_events_client: AnalyticsEventsClient,
pub(crate) hooks: Hooks,
pub(crate) rollout: Mutex<Option<RolloutRecorder>>,
pub(crate) security_monitor: Option<Arc<SecurityMonitor>>,
pub(crate) user_shell: Arc<crate::shell::Shell>,
pub(crate) shell_snapshot_tx: watch::Sender<Option<Arc<crate::shell_snapshot::ShellSnapshot>>>,
pub(crate) show_raw_agent_reasoning: bool,

View File

@@ -855,7 +855,8 @@ impl EventProcessor for EventProcessorWithHumanOutput {
| EventMsg::RealtimeConversationRealtime(_)
| EventMsg::RealtimeConversationClosed(_)
| EventMsg::DynamicToolCallRequest(_)
| EventMsg::DynamicToolCallResponse(_) => {}
| EventMsg::DynamicToolCallResponse(_)
| EventMsg::Security(_) => {}
}
CodexStatus::Running
}
@@ -932,6 +933,7 @@ impl EventProcessorWithHumanOutput {
| EventMsg::RequestUserInput(_)
| EventMsg::DynamicToolCallRequest(_)
| EventMsg::DynamicToolCallResponse(_)
| EventMsg::Security(_)
)
}

View File

@@ -348,6 +348,7 @@ async fn run_codex_tool_session_inner(
| EventMsg::GetHistoryEntryResponse(_)
| EventMsg::PlanUpdate(_)
| EventMsg::TurnAborted(_)
| EventMsg::Security(_)
| EventMsg::UserMessage(_)
| EventMsg::ShutdownComplete
| EventMsg::ViewImageToolCall(_)

View File

@@ -1077,6 +1077,9 @@ pub enum EventMsg {
/// Notification that a patch application has finished.
PatchApplyEnd(PatchApplyEndEvent),
/// Sanitized security-relevant activity emitted by the runtime monitor.
Security(SecurityEvent),
TurnDiff(TurnDiffEvent),
/// Response to GetHistoryEntryRequest.
@@ -2498,6 +2501,46 @@ pub enum PatchApplyStatus {
Declined,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
pub enum SecurityEventKind {
Network,
File,
Command,
Permission,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct SecurityEvent {
/// Broad category for the security-relevant activity.
pub kind: SecurityEventKind,
/// Stable action name for the concrete event that was observed.
pub action: String,
/// Turn ID associated with the event when available.
pub turn_id: String,
/// Related call identifier when the event can be tied to a tool/command.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub call_id: Option<String>,
/// Whether the action was allowed or denied when that distinction exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub allowed: Option<bool>,
/// Short sanitized preview of the primary target.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub target: Option<String>,
/// Small sanitized details blob for human-readable audit timelines.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub details: Option<String>,
/// Duration in milliseconds for events that span time.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub duration_ms: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct TurnDiffEvent {
pub unified_diff: String,
@@ -3512,6 +3555,39 @@ mod tests {
Ok(())
}
#[test]
fn serialize_security_event() -> Result<()> {
let event = Event {
id: "1234".to_string(),
msg: EventMsg::Security(SecurityEvent {
kind: SecurityEventKind::Command,
action: "exec_command_end".to_string(),
turn_id: "turn-1".to_string(),
call_id: Some("call-1".to_string()),
allowed: None,
target: Some("git status".to_string()),
details: Some("status=completed exit_code=0".to_string()),
duration_ms: Some(12),
}),
};
let expected = json!({
"id": "1234",
"msg": {
"type": "security",
"kind": "command",
"action": "exec_command_end",
"turn_id": "turn-1",
"call_id": "call-1",
"target": "git status",
"details": "status=completed exit_code=0",
"duration_ms": 12,
}
});
assert_eq!(expected, serde_json::to_value(&event)?);
Ok(())
}
#[test]
fn token_usage_info_new_or_append_updates_context_window_when_provided() {
let initial = Some(TokenUsageInfo {

View File

@@ -4545,7 +4545,8 @@ impl ChatWidget {
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_)
| EventMsg::DynamicToolCallRequest(_)
| EventMsg::DynamicToolCallResponse(_) => {}
| EventMsg::DynamicToolCallResponse(_)
| EventMsg::Security(_) => {}
EventMsg::RealtimeConversationStarted(ev) => {
if !from_replay {
self.on_realtime_conversation_started(ev);