mirror of
https://github.com/openai/codex.git
synced 2026-05-01 18:06:47 +00:00
Compare commits
1 Commits
iceweasel/
...
codex/viya
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df24c55fac |
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
171
codex-rs/core/src/security/audit_logger.rs
Normal file
171
codex-rs/core/src/security/audit_logger.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
7
codex-rs/core/src/security/mod.rs
Normal file
7
codex-rs/core/src/security/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod audit_logger;
|
||||
mod monitor;
|
||||
mod redactor;
|
||||
#[cfg(test)]
|
||||
mod stats;
|
||||
|
||||
pub(crate) use monitor::SecurityMonitor;
|
||||
287
codex-rs/core/src/security/monitor.rs
Normal file
287
codex-rs/core/src/security/monitor.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
55
codex-rs/core/src/security/redactor.rs
Normal file
55
codex-rs/core/src/security/redactor.rs
Normal 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("..."));
|
||||
}
|
||||
}
|
||||
6
codex-rs/core/src/security/stats.rs
Normal file
6
codex-rs/core/src/security/stats.rs
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(_)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user