Compare commits

...

10 Commits

Author SHA1 Message Date
Fouad Matin
f08cb68a62 - 2025-10-05 14:57:50 -07:00
Fouad Matin
357612da38 fix: ci 2025-10-04 15:01:48 -07:00
Fouad Matin
07442e4533 fix: ci 2025-10-04 13:12:54 -07:00
Fouad Matin
9f850f8bb6 address feedback 2025-10-04 09:35:06 -07:00
Fouad Matin
662bc2c9ab fix: clippy 2025-10-03 16:23:13 -07:00
Fouad Matin
81ec812bcc - 2025-10-03 15:57:21 -07:00
Fouad Matin
deebfb9d37 - 2025-10-03 15:53:04 -07:00
Fouad Matin
7d3cf212e1 fix: clippy 2025-10-03 13:46:25 -07:00
Fouad Matin
ec9bf6f53e - 2025-10-03 13:37:05 -07:00
Fouad Matin
2c668fa4a1 add [admin] config 2025-10-03 13:07:23 -07:00
21 changed files with 963 additions and 39 deletions

12
codex-rs/Cargo.lock generated
View File

@@ -1042,7 +1042,9 @@ dependencies = [
"env-flags",
"escargot",
"eventsource-stream",
"fd-lock",
"futures",
"gethostname",
"indexmap 2.10.0",
"landlock",
"libc",
@@ -1061,6 +1063,7 @@ dependencies = [
"serde_json",
"serial_test",
"sha1",
"shellexpand",
"shlex",
"similar",
"strum_macros 0.27.2",
@@ -5341,6 +5344,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
[[package]]
name = "shellexpand"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
dependencies = [
"dirs",
]
[[package]]
name = "shlex"
version = "1.3.0"

View File

@@ -106,6 +106,7 @@ env_logger = "0.11.5"
escargot = "0.5"
eventsource-stream = "0.2.3"
futures = { version = "0.3", default-features = false }
fd-lock = "4.0.4"
icu_decimal = "2.0.0"
icu_locale_core = "2.0.0"
ignore = "0.4.23"
@@ -151,6 +152,7 @@ serde_with = "3.14"
serial_test = "3.2.0"
sha1 = "0.10.6"
sha2 = "0.10"
shellexpand = "3.1.0"
shlex = "1.3.0"
similar = "2.7.0"
starlark = "0.13.0"

View File

@@ -31,7 +31,9 @@ dirs = { workspace = true }
dunce = { workspace = true }
env-flags = { workspace = true }
eventsource-stream = { workspace = true }
fd-lock = { workspace = true }
futures = { workspace = true }
gethostname = "0.4"
indexmap = { workspace = true }
libc = { workspace = true }
mcp-types = { workspace = true }
@@ -40,6 +42,7 @@ portable-pty = { workspace = true }
rand = { workspace = true }
regex-lite = { workspace = true }
reqwest = { workspace = true, features = ["json", "stream"] }
shellexpand = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha1 = { workspace = true }

View File

@@ -0,0 +1,448 @@
use crate::config_types::AdminAuditEventKind;
use crate::config_types::AdminAuditToml;
use crate::config_types::AdminConfigToml;
use crate::exec::ExecParams;
use crate::exec::SandboxType;
use crate::path_utils::expand_tilde;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy;
use chrono::DateTime;
use chrono::Utc;
use fd_lock::RwLock;
use gethostname::gethostname;
use reqwest::Client;
use serde::Serialize;
use std::collections::HashSet;
use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::io::{self};
use std::path::Path;
use std::path::PathBuf;
use tokio::runtime::Handle;
use tracing::warn;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
#[derive(Debug, Clone, PartialEq, Default)]
pub struct AdminControls {
pub danger: DangerControls,
pub audit: Option<AdminAuditConfig>,
pub pending: Vec<PendingAdminAction>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct DangerControls {
pub disallow_full_access: bool,
pub allow_with_reason: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AdminAuditConfig {
pub log_file: Option<PathBuf>,
pub log_endpoint: Option<String>,
pub log_events: HashSet<AdminAuditEventKind>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PendingAdminAction {
Danger(DangerPending),
}
#[derive(Debug, Clone, PartialEq)]
pub struct DangerPending {
pub source: DangerRequestSource,
pub requested_sandbox: SandboxPolicy,
pub requested_approval: AskForApproval,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DangerRequestSource {
Startup,
Resume,
Approvals,
ExecCli,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DangerDecision {
Allowed,
RequiresJustification,
Denied,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DangerAuditAction {
Requested,
Approved,
Cancelled,
Denied,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "audit_kind", rename_all = "snake_case")]
pub enum AdminAuditPayload {
Danger {
action: DangerAuditAction,
justification: Option<String>,
requested_by: DangerRequestSource,
sandbox_policy: SandboxPolicy,
approval_policy: AskForApproval,
},
Command {
command: Vec<String>,
command_cwd: PathBuf,
cli_cwd: PathBuf,
sandbox_type: SandboxType,
sandbox_policy: SandboxPolicy,
escalated: bool,
justification: Option<String>,
},
}
#[derive(Debug, Clone, Serialize)]
pub struct AdminAuditRecord {
timestamp: DateTime<Utc>,
username: String,
hostname: String,
#[serde(flatten)]
payload: AdminAuditPayload,
}
impl AdminControls {
pub fn from_toml(raw: Option<AdminConfigToml>) -> io::Result<Self> {
let raw = raw.unwrap_or_default();
let danger = DangerControls {
disallow_full_access: raw.disallow_danger_full_access.unwrap_or(false),
allow_with_reason: raw.allow_danger_with_reason.unwrap_or(false),
};
let audit = match raw.audit {
Some(audit_raw) => AdminAuditConfig::from_toml(audit_raw)?,
None => None,
};
Ok(Self {
danger,
audit,
pending: Vec::new(),
})
}
pub fn decision_for_danger(&self) -> DangerDecision {
if !self.danger.disallow_full_access {
DangerDecision::Allowed
} else if self.danger.allow_with_reason {
DangerDecision::RequiresJustification
} else {
DangerDecision::Denied
}
}
pub fn has_pending_danger(&self) -> bool {
self.pending
.iter()
.any(|action| matches!(action, PendingAdminAction::Danger(_)))
}
pub fn take_pending_danger(&mut self) -> Option<DangerPending> {
self.pending
.extract_if(.., |action| matches!(action, PendingAdminAction::Danger(_)))
.next()
.map(|action| match action {
PendingAdminAction::Danger(pending) => pending,
})
}
pub fn peek_pending_danger(&self) -> Option<&DangerPending> {
self.pending
.iter()
.map(|action| match action {
PendingAdminAction::Danger(pending) => pending,
})
.next()
}
}
impl AdminAuditConfig {
pub fn from_toml(raw: AdminAuditToml) -> io::Result<Option<Self>> {
let AdminAuditToml {
log_file,
log_endpoint,
log_events,
} = raw;
let log_file = match log_file {
Some(path) => {
let trimmed = path.trim();
if trimmed.is_empty() {
None
} else {
Some(expand_tilde(trimmed)?)
}
}
None => None,
};
let log_endpoint = log_endpoint
.map(|endpoint| endpoint.trim().to_string())
.filter(|s| !s.is_empty());
if log_file.is_none() && log_endpoint.is_none() {
return Ok(None);
}
let log_events = log_events.into_iter().collect();
Ok(Some(Self {
log_file,
log_endpoint,
log_events,
}))
}
pub fn should_log(&self, kind: AdminAuditEventKind) -> bool {
self.log_events.is_empty() || self.log_events.contains(&kind)
}
}
impl AdminAuditPayload {
pub fn kind(&self) -> AdminAuditEventKind {
match self {
AdminAuditPayload::Danger { .. } => AdminAuditEventKind::Danger,
AdminAuditPayload::Command { .. } => AdminAuditEventKind::Command,
}
}
}
impl AdminAuditRecord {
fn new(payload: AdminAuditPayload) -> Self {
Self {
timestamp: Utc::now(),
username: current_username(),
hostname: current_hostname(),
payload,
}
}
}
pub fn log_admin_event(config: &AdminAuditConfig, payload: AdminAuditPayload) {
let kind = payload.kind();
if !config.should_log(kind) {
return;
}
let record = AdminAuditRecord::new(payload);
if let Some(path) = &config.log_file
&& let Err(err) = append_record_to_file(path, &record)
{
warn!(
"failed to write admin audit event to {}: {err:?}",
path.display()
);
}
if let Some(endpoint) = &config.log_endpoint {
if Handle::try_current().is_ok() {
let endpoint = endpoint.clone();
tokio::spawn(async move {
if let Err(err) = send_record_to_endpoint(&endpoint, record).await {
warn!("failed to post admin audit event to {endpoint}: {err:?}");
}
});
} else {
warn!(
"admin audit HTTP logging requested for {endpoint}, but no async runtime is available",
);
}
}
}
fn append_record_to_file(path: &Path, record: &AdminAuditRecord) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut options = OpenOptions::new();
options.create(true).append(true).write(true);
#[cfg(unix)]
{
options.mode(0o600);
}
let file = options.open(path)?;
let mut lock = RwLock::new(file);
let mut guard = lock.write()?;
let line = serde_json::to_string(record).map_err(io::Error::other)?;
guard.write_all(line.as_bytes())?;
guard.write_all(b"\n")?;
guard.flush()?;
Ok(())
}
async fn send_record_to_endpoint(
endpoint: &str,
record: AdminAuditRecord,
) -> Result<(), reqwest::Error> {
Client::new().post(endpoint).json(&record).send().await?;
Ok(())
}
fn current_username() -> String {
env_var("USER")
.or_else(|| env_var("USERNAME"))
.unwrap_or_else(|| "unknown".to_string())
}
fn current_hostname() -> String {
gethostname()
.into_string()
.ok()
.filter(|value| !value.is_empty())
.or_else(|| env_var("HOSTNAME"))
.or_else(|| env_var("COMPUTERNAME"))
.unwrap_or_else(|| "unknown".to_string())
}
fn env_var(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|value| !value.is_empty())
}
pub fn build_danger_audit_payload(
pending: &DangerPending,
action: DangerAuditAction,
justification: Option<String>,
) -> AdminAuditPayload {
AdminAuditPayload::Danger {
action,
justification,
requested_by: pending.source,
sandbox_policy: pending.requested_sandbox.clone(),
approval_policy: pending.requested_approval,
}
}
pub fn build_command_audit_payload(
params: &ExecParams,
sandbox_type: SandboxType,
sandbox_policy: &SandboxPolicy,
cli_cwd: &Path,
) -> AdminAuditPayload {
AdminAuditPayload::Command {
command: params.command.clone(),
command_cwd: params.cwd.clone(),
cli_cwd: cli_cwd.to_path_buf(),
sandbox_type,
sandbox_policy: sandbox_policy.clone(),
escalated: params.with_escalated_permissions.unwrap_or(false),
justification: params.justification.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
#[test]
fn danger_payload_serializes_expected_fields() {
let pending = DangerPending {
source: DangerRequestSource::Approvals,
requested_sandbox: SandboxPolicy::DangerFullAccess,
requested_approval: AskForApproval::Never,
};
let payload = build_danger_audit_payload(
&pending,
DangerAuditAction::Requested,
Some("reason".to_string()),
);
let record = AdminAuditRecord::new(payload);
let value = serde_json::to_value(record).expect("serialize record");
assert_eq!(
value.get("audit_kind"),
Some(&Value::String("danger".to_string()))
);
assert_eq!(
value.get("action"),
Some(&Value::String("requested".to_string()))
);
assert_eq!(
value.get("requested_by"),
Some(&Value::String("approvals".to_string()))
);
assert_eq!(
value.get("approval_policy"),
Some(&Value::String("never".to_string()))
);
assert_eq!(
value.get("sandbox_policy").and_then(|sp| sp.get("mode")),
Some(&Value::String("danger-full-access".to_string()))
);
assert_eq!(
value.get("justification"),
Some(&Value::String("reason".to_string()))
);
}
#[test]
fn command_payload_serializes_expected_fields() {
let mut env = HashMap::new();
env.insert("PATH".to_string(), "/usr/bin".to_string());
let params = ExecParams {
command: vec!["echo".to_string(), "hello".to_string()],
cwd: PathBuf::from("/tmp"),
timeout_ms: Some(1000),
env,
with_escalated_permissions: Some(true),
justification: Some("investigation".to_string()),
};
let sandbox_policy = SandboxPolicy::new_workspace_write_policy();
let payload = build_command_audit_payload(
&params,
SandboxType::MacosSeatbelt,
&sandbox_policy,
Path::new("/workspace"),
);
let record = AdminAuditRecord::new(payload);
let value = serde_json::to_value(record).expect("serialize record");
assert_eq!(
value.get("audit_kind"),
Some(&Value::String("command".to_string()))
);
assert_eq!(
value.get("command"),
Some(&serde_json::json!(["echo", "hello"]))
);
assert_eq!(
value.get("command_cwd"),
Some(&Value::String("/tmp".to_string()))
);
assert_eq!(
value.get("cli_cwd"),
Some(&Value::String("/workspace".to_string()))
);
assert_eq!(
value.get("sandbox_type"),
Some(&Value::String("macos-seatbelt".to_string()))
);
assert_eq!(
value.get("sandbox_policy").and_then(|sp| sp.get("mode")),
Some(&Value::String("workspace-write".to_string()))
);
assert_eq!(value.get("escalated"), Some(&Value::Bool(true)));
assert_eq!(
value.get("justification"),
Some(&Value::String("investigation".to_string()))
);
}
}

View File

@@ -468,6 +468,7 @@ impl Session {
turn_context.sandbox_policy.clone(),
turn_context.cwd.clone(),
config.codex_linux_sandbox_exe.clone(),
config.admin.audit.clone(),
)),
};
@@ -2711,6 +2712,7 @@ mod tests {
turn_context.sandbox_policy.clone(),
turn_context.cwd.clone(),
None,
config.admin.audit.clone(),
)),
};
let session = Session {
@@ -2784,6 +2786,7 @@ mod tests {
config.sandbox_policy.clone(),
config.cwd.clone(),
None,
config.admin.audit.clone(),
)),
};
let session = Arc::new(Session {

View File

@@ -1,8 +1,17 @@
use crate::admin_controls::AdminControls;
use crate::admin_controls::DangerAuditAction;
use crate::admin_controls::DangerDecision;
use crate::admin_controls::DangerPending;
use crate::admin_controls::DangerRequestSource;
use crate::admin_controls::PendingAdminAction;
use crate::admin_controls::build_danger_audit_payload;
use crate::admin_controls::log_admin_event;
use crate::config_loader::LoadedConfigLayers;
pub use crate::config_loader::load_config_as_toml;
use crate::config_loader::load_config_layers_with_overrides;
use crate::config_loader::merge_toml_values;
use crate::config_profile::ConfigProfile;
use crate::config_types::AdminConfigToml;
use crate::config_types::DEFAULT_OTEL_ENVIRONMENT;
use crate::config_types::History;
use crate::config_types::McpServerConfig;
@@ -213,6 +222,9 @@ pub struct Config {
/// OTEL configuration (exporter type, endpoint, headers, etc.).
pub otel: crate::config_types::OtelConfig,
/// Administrator-controlled options and audit configuration.
pub admin: AdminControls,
}
impl Config {
@@ -734,6 +746,10 @@ pub struct ConfigToml {
/// OTEL configuration.
pub otel: Option<crate::config_types::OtelConfigToml>,
/// Administrator-level controls applied to all users on this host.
#[serde(default)]
pub admin: Option<AdminConfigToml>,
}
impl From<ConfigToml> for UserSavedConfig {
@@ -922,7 +938,68 @@ impl Config {
None => ConfigProfile::default(),
};
let sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode);
let resolved_approval_policy = approval_policy
.or(config_profile.approval_policy)
.or(cfg.approval_policy)
.unwrap_or_else(AskForApproval::default);
let mut admin = AdminControls::from_toml(cfg.admin.clone())?;
let mut sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode);
if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) {
match admin.decision_for_danger() {
DangerDecision::Allowed => {
if let Some(audit) = admin.audit.as_ref() {
let pending = DangerPending {
source: DangerRequestSource::Startup,
requested_sandbox: SandboxPolicy::DangerFullAccess,
requested_approval: resolved_approval_policy,
};
log_admin_event(
audit,
build_danger_audit_payload(&pending, DangerAuditAction::Approved, None),
);
}
}
DangerDecision::RequiresJustification => {
let pending = DangerPending {
source: DangerRequestSource::Startup,
requested_sandbox: SandboxPolicy::DangerFullAccess,
requested_approval: resolved_approval_policy,
};
if let Some(audit) = admin.audit.as_ref() {
log_admin_event(
audit,
build_danger_audit_payload(
&pending,
DangerAuditAction::Requested,
None,
),
);
}
admin.pending.push(PendingAdminAction::Danger(pending));
sandbox_policy = SandboxPolicy::new_workspace_write_policy();
}
DangerDecision::Denied => {
if let Some(audit) = admin.audit.as_ref() {
let pending = DangerPending {
source: DangerRequestSource::Startup,
requested_sandbox: SandboxPolicy::DangerFullAccess,
requested_approval: resolved_approval_policy,
};
log_admin_event(
audit,
build_danger_audit_payload(&pending, DangerAuditAction::Denied, None),
);
}
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"danger-full-access is disabled by administrator policy",
));
}
}
}
let mut model_providers = built_in_model_providers();
// Merge user-defined providers into the built-in list.
@@ -1031,10 +1108,7 @@ impl Config {
model_provider_id,
model_provider,
cwd: resolved_cwd,
approval_policy: approval_policy
.or(config_profile.approval_policy)
.or(cfg.approval_policy)
.unwrap_or_else(AskForApproval::default),
approval_policy: resolved_approval_policy,
sandbox_policy,
shell_environment_policy,
notify: cfg.notify,
@@ -1109,6 +1183,7 @@ impl Config {
exporter,
}
},
admin,
};
Ok(config)
}
@@ -1218,6 +1293,7 @@ pub fn log_dir(cfg: &Config) -> std::io::Result<PathBuf> {
#[cfg(test)]
mod tests {
use crate::admin_controls::AdminControls;
use crate::config_types::HistoryPersistence;
use crate::config_types::Notifications;
@@ -1885,6 +1961,7 @@ model_verbosity = "high"
disable_paste_burst: false,
tui_notifications: Default::default(),
otel: OtelConfig::default(),
admin: AdminControls::default(),
},
o3_profile_config
);
@@ -1946,6 +2023,7 @@ model_verbosity = "high"
disable_paste_burst: false,
tui_notifications: Default::default(),
otel: OtelConfig::default(),
admin: AdminControls::default(),
};
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
@@ -2022,6 +2100,7 @@ model_verbosity = "high"
disable_paste_burst: false,
tui_notifications: Default::default(),
otel: OtelConfig::default(),
admin: AdminControls::default(),
};
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
@@ -2084,6 +2163,7 @@ model_verbosity = "high"
disable_paste_burst: false,
tui_notifications: Default::default(),
otel: OtelConfig::default(),
admin: AdminControls::default(),
};
assert_eq!(expected_gpt5_profile_config, gpt5_profile_config);

View File

@@ -563,3 +563,34 @@ mod tests {
.expect_err("should reject bearer token for stdio transport");
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
pub struct AdminConfigToml {
#[serde(default)]
pub disallow_danger_full_access: Option<bool>,
#[serde(default)]
pub allow_danger_with_reason: Option<bool>,
#[serde(default)]
pub audit: Option<AdminAuditToml>,
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
pub struct AdminAuditToml {
#[serde(default)]
pub log_file: Option<String>,
#[serde(default)]
pub log_endpoint: Option<String>,
#[serde(default)]
pub log_events: Vec<AdminAuditEventKind>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AdminAuditEventKind {
Danger,
Command,
}

View File

@@ -27,6 +27,7 @@ use crate::protocol::SandboxPolicy;
use crate::seatbelt::spawn_command_under_seatbelt;
use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
use serde::Serialize;
const DEFAULT_TIMEOUT_MS: u64 = 10_000;
@@ -61,7 +62,8 @@ impl ExecParams {
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SandboxType {
None,

View File

@@ -6,7 +6,11 @@ use std::time::Duration;
use super::backends::ExecutionMode;
use super::backends::backend_for_mode;
use super::cache::ApprovalCache;
use crate::admin_controls::AdminAuditConfig;
use crate::admin_controls::build_command_audit_payload;
use crate::admin_controls::log_admin_event;
use crate::codex::Session;
use crate::config_types::AdminAuditEventKind;
use crate::error::CodexErr;
use crate::error::SandboxErr;
use crate::error::get_error_message_ui;
@@ -31,6 +35,7 @@ pub(crate) struct ExecutorConfig {
pub(crate) sandbox_policy: SandboxPolicy,
pub(crate) sandbox_cwd: PathBuf,
codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) admin_audit: Option<AdminAuditConfig>,
}
impl ExecutorConfig {
@@ -38,11 +43,13 @@ impl ExecutorConfig {
sandbox_policy: SandboxPolicy,
sandbox_cwd: PathBuf,
codex_linux_sandbox_exe: Option<PathBuf>,
admin_audit: Option<AdminAuditConfig>,
) -> Self {
Self {
sandbox_policy,
sandbox_cwd,
codex_linux_sandbox_exe,
admin_audit,
}
}
}
@@ -222,6 +229,17 @@ impl Executor {
config: &ExecutorConfig,
stdout_stream: Option<StdoutStream>,
) -> Result<ExecToolCallOutput, CodexErr> {
if let Some(admin_audit) = config.admin_audit.as_ref()
&& admin_audit.should_log(AdminAuditEventKind::Command)
{
let payload = build_command_audit_payload(
&params,
sandbox,
&config.sandbox_policy,
&config.sandbox_cwd,
);
log_admin_event(admin_audit, payload);
}
process_exec_tool_call(
params,
sandbox,

View File

@@ -207,7 +207,7 @@ mod tests {
action,
user_explicitly_approved_this_action: true,
};
let cfg = ExecutorConfig::new(SandboxPolicy::ReadOnly, std::env::temp_dir(), None);
let cfg = ExecutorConfig::new(SandboxPolicy::ReadOnly, std::env::temp_dir(), None, None);
let request = ExecutionRequest {
params: ExecParams {
command: vec!["apply_patch".into()],
@@ -250,7 +250,12 @@ mod tests {
action,
user_explicitly_approved_this_action: false,
};
let cfg = ExecutorConfig::new(SandboxPolicy::DangerFullAccess, std::env::temp_dir(), None);
let cfg = ExecutorConfig::new(
SandboxPolicy::DangerFullAccess,
std::env::temp_dir(),
None,
None,
);
let request = ExecutionRequest {
params: ExecParams {
command: vec!["apply_patch".into()],
@@ -294,7 +299,7 @@ mod tests {
action,
user_explicitly_approved_this_action: false,
};
let cfg = ExecutorConfig::new(SandboxPolicy::ReadOnly, std::env::temp_dir(), None);
let cfg = ExecutorConfig::new(SandboxPolicy::ReadOnly, std::env::temp_dir(), None, None);
let request = ExecutionRequest {
params: ExecParams {
command: vec!["apply_patch".into()],
@@ -333,7 +338,12 @@ mod tests {
#[tokio::test]
async fn select_shell_autoapprove_in_danger_mode() {
let (session, ctx) = make_session_and_context();
let cfg = ExecutorConfig::new(SandboxPolicy::DangerFullAccess, std::env::temp_dir(), None);
let cfg = ExecutorConfig::new(
SandboxPolicy::DangerFullAccess,
std::env::temp_dir(),
None,
None,
);
let request = ExecutionRequest {
params: ExecParams {
command: vec!["some-unknown".into()],
@@ -369,7 +379,7 @@ mod tests {
#[tokio::test]
async fn select_shell_escalates_on_failure_with_platform_sandbox() {
let (session, ctx) = make_session_and_context();
let cfg = ExecutorConfig::new(SandboxPolicy::ReadOnly, std::env::temp_dir(), None);
let cfg = ExecutorConfig::new(SandboxPolicy::ReadOnly, std::env::temp_dir(), None, None);
let request = ExecutionRequest {
params: ExecParams {
// Unknown command => untrusted but not flagged dangerous

View File

@@ -5,6 +5,7 @@
// the TUI or the tracing stack).
#![deny(clippy::print_stdout, clippy::print_stderr)]
pub mod admin_controls;
mod apply_patch;
pub mod auth;
pub mod bash;
@@ -37,6 +38,7 @@ mod mcp_tool_call;
mod message_history;
mod model_provider_info;
pub mod parse_command;
mod path_utils;
mod truncate;
mod unified_exec;
mod user_instructions;

View File

@@ -0,0 +1,19 @@
use std::io;
use std::path::PathBuf;
pub(crate) fn expand_tilde(raw: &str) -> io::Result<PathBuf> {
if raw.starts_with('~') {
// `shellexpand::tilde` falls back to returning the input when the home directory
// cannot be resolved; mirror the previous error semantics in that case.
let expanded = shellexpand::tilde(raw);
if expanded.starts_with('~') {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"could not resolve home directory while expanding path",
));
}
return Ok(PathBuf::from(expanded.as_ref()));
}
Ok(PathBuf::from(raw))
}

View File

@@ -10,11 +10,16 @@ mod event_processor_with_human_output;
pub mod event_processor_with_jsonl_output;
pub mod exec_events;
use anyhow::bail;
pub use cli::Cli;
use codex_core::AuthManager;
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::admin_controls::DangerAuditAction;
use codex_core::admin_controls::PendingAdminAction;
use codex_core::admin_controls::build_danger_audit_payload;
use codex_core::admin_controls::log_admin_event;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::git_info::get_git_repo_root;
@@ -193,6 +198,24 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides).await?;
if config.admin.has_pending_danger() {
if let Some(audit) = config.admin.audit.as_ref()
&& let Some(PendingAdminAction::Danger(pending)) = config
.admin
.pending
.iter()
.find(|action| matches!(action, PendingAdminAction::Danger(_)))
{
log_admin_event(
audit,
build_danger_audit_payload(pending, DangerAuditAction::Denied, None),
);
}
bail!(
"danger-full-access requires interactive justification; rerun in the interactive TUI"
);
}
let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"));
#[allow(clippy::print_stderr)]

View File

@@ -154,6 +154,8 @@ impl App {
backtrack: BacktrackState::default(),
};
app.process_pending_admin_controls();
let tui_events = tui.event_stream();
tokio::pin!(tui_events);
@@ -366,11 +368,14 @@ impl App {
}
}
}
AppEvent::UpdateAskForApprovalPolicy(policy) => {
self.chat_widget.set_approval_policy(policy);
AppEvent::ApplyApprovalPreset(preset) => {
self.handle_apply_approval_preset(preset)?;
}
AppEvent::UpdateSandboxPolicy(policy) => {
self.chat_widget.set_sandbox_policy(policy);
AppEvent::DangerJustificationSubmitted { justification } => {
self.handle_danger_justification_submission(justification)?;
}
AppEvent::DangerJustificationCancelled => {
self.handle_danger_justification_cancelled()?;
}
AppEvent::OpenReviewBranchPicker(cwd) => {
self.chat_widget.show_review_branch_picker(&cwd).await;

View File

@@ -0,0 +1,192 @@
use crate::app::App;
use codex_common::approval_presets::ApprovalPreset;
use codex_core::admin_controls::DangerAuditAction;
use codex_core::admin_controls::DangerDecision;
use codex_core::admin_controls::DangerPending;
use codex_core::admin_controls::DangerRequestSource;
use codex_core::admin_controls::PendingAdminAction;
use codex_core::admin_controls::build_danger_audit_payload;
use codex_core::admin_controls::log_admin_event;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use color_eyre::eyre::Result;
impl App {
pub(crate) fn handle_apply_approval_preset(&mut self, preset: ApprovalPreset) -> Result<()> {
self.cancel_existing_pending_requests();
let ApprovalPreset {
approval, sandbox, ..
} = preset;
match sandbox {
SandboxPolicy::DangerFullAccess => match self.config.admin.decision_for_danger() {
DangerDecision::Allowed => {
let pending = DangerPending {
source: DangerRequestSource::Approvals,
requested_sandbox: SandboxPolicy::DangerFullAccess,
requested_approval: approval,
};
self.log_danger_event(&pending, DangerAuditAction::Approved, None);
self.apply_sandbox_and_approval(approval, SandboxPolicy::DangerFullAccess);
}
DangerDecision::RequiresJustification => {
let pending = DangerPending {
source: DangerRequestSource::Approvals,
requested_sandbox: SandboxPolicy::DangerFullAccess,
requested_approval: approval,
};
self.log_danger_event(&pending, DangerAuditAction::Requested, None);
self.push_pending_danger(pending.clone());
self.chat_widget.prompt_for_danger_justification(pending);
}
DangerDecision::Denied => {
let pending = DangerPending {
source: DangerRequestSource::Approvals,
requested_sandbox: SandboxPolicy::DangerFullAccess,
requested_approval: approval,
};
self.log_danger_event(&pending, DangerAuditAction::Denied, None);
self.chat_widget.add_error_message(
"Full access is disabled by your administrator.".to_string(),
);
}
},
other => {
self.apply_sandbox_and_approval(approval, other);
}
}
Ok(())
}
pub(crate) fn handle_danger_justification_submission(
&mut self,
justification: String,
) -> Result<()> {
let justification = justification.trim();
if justification.is_empty() {
self.chat_widget.add_error_message(
"Please provide a justification before enabling full access.".to_string(),
);
return Ok(());
}
let Some(pending) = self.chat_widget.take_pending_danger() else {
return Ok(());
};
if let Some(internal) = self.drop_pending_from_configs() {
debug_assert_eq!(internal, pending);
}
self.log_danger_event(
&pending,
DangerAuditAction::Approved,
Some(justification.to_string()),
);
let DangerPending {
requested_approval,
requested_sandbox,
..
} = pending;
self.apply_sandbox_and_approval(requested_approval, requested_sandbox);
self.chat_widget.add_info_message(
"Full access enabled.".to_string(),
Some("Justification has been logged.".to_string()),
);
Ok(())
}
pub(crate) fn handle_danger_justification_cancelled(&mut self) -> Result<()> {
self.cancel_existing_pending_requests();
let approval_label = self.config.approval_policy.to_string();
let sandbox_label = self.config.sandbox_policy.to_string();
self.chat_widget.add_info_message(
format!(
"Full access remains disabled. Current approval policy `{approval_label}`, sandbox `{sandbox_label}`."
),
None,
);
Ok(())
}
pub(crate) fn process_pending_admin_controls(&mut self) {
while let Some(pending) = self.drop_pending_from_configs() {
self.chat_widget.prompt_for_danger_justification(pending);
}
}
fn apply_sandbox_and_approval(&mut self, approval: AskForApproval, sandbox: SandboxPolicy) {
self.chat_widget.submit_op(Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(approval),
sandbox_policy: Some(sandbox.clone()),
model: None,
effort: None,
summary: None,
});
self.chat_widget.set_approval_policy(approval);
self.chat_widget.set_sandbox_policy(sandbox.clone());
self.config.approval_policy = approval;
self.config.sandbox_policy = sandbox;
}
fn push_pending_danger(&mut self, pending: DangerPending) {
self.config
.admin
.pending
.push(PendingAdminAction::Danger(pending.clone()));
self.chat_widget
.config_mut()
.admin
.pending
.push(PendingAdminAction::Danger(pending));
}
fn cancel_existing_pending_requests(&mut self) {
let mut logged = false;
if let Some(previous) = self.config.admin.take_pending_danger() {
self.log_danger_event(&previous, DangerAuditAction::Cancelled, None);
logged = true;
}
if let Some(previous) = self.chat_widget.config_mut().admin.take_pending_danger()
&& !logged
{
self.log_danger_event(&previous, DangerAuditAction::Cancelled, None);
logged = true;
}
if let Some(previous) = self.chat_widget.take_pending_danger()
&& !logged
{
self.log_danger_event(&previous, DangerAuditAction::Cancelled, None);
}
}
fn drop_pending_from_configs(&mut self) -> Option<DangerPending> {
let config_pending = self.config.admin.take_pending_danger();
let widget_pending = self.chat_widget.config_mut().admin.take_pending_danger();
config_pending.or(widget_pending)
}
fn log_danger_event(
&self,
pending: &DangerPending,
action: DangerAuditAction,
justification: Option<String>,
) {
if let Some(audit) = self.config.admin.audit.as_ref() {
log_admin_event(
audit,
build_danger_audit_payload(pending, action, justification),
);
}
}
}

View File

@@ -1,5 +1,6 @@
use std::path::PathBuf;
use codex_common::approval_presets::ApprovalPreset;
use codex_common::model_presets::ModelPreset;
use codex_core::protocol::ConversationPathResponseEvent;
use codex_core::protocol::Event;
@@ -8,8 +9,6 @@ use codex_file_search::FileMatch;
use crate::bottom_pane::ApprovalRequest;
use crate::history_cell::HistoryCell;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol_config_types::ReasoningEffort;
#[allow(clippy::large_enum_variant)]
@@ -67,11 +66,16 @@ pub(crate) enum AppEvent {
presets: Vec<ModelPreset>,
},
/// Update the current approval policy in the running app and widget.
UpdateAskForApprovalPolicy(AskForApproval),
/// Apply an approval preset chosen from the popup.
ApplyApprovalPreset(ApprovalPreset),
/// Update the current sandbox policy in the running app and widget.
UpdateSandboxPolicy(SandboxPolicy),
/// Submit a justification for enabling danger-full-access.
DangerJustificationSubmitted {
justification: String,
},
/// User cancelled the danger justification prompt without submitting a reason.
DangerJustificationCancelled,
/// Forwarded conversation history snapshot from the current conversation.
ConversationHistory(ConversationPathResponseEvent),

View File

@@ -23,6 +23,8 @@ use super::textarea::TextAreaState;
/// Callback invoked when the user submits a custom prompt.
pub(crate) type PromptSubmitted = Box<dyn Fn(String) + Send + Sync>;
/// Callback invoked when the prompt input is cancelled.
pub(crate) type PromptCancelled = Box<dyn Fn() + Send + Sync>;
/// Minimal multi-line text input view to collect custom review instructions.
pub(crate) struct CustomPromptView {
@@ -30,6 +32,7 @@ pub(crate) struct CustomPromptView {
placeholder: String,
context_label: Option<String>,
on_submit: PromptSubmitted,
on_cancel: Option<PromptCancelled>,
// UI state
textarea: TextArea,
@@ -43,12 +46,14 @@ impl CustomPromptView {
placeholder: String,
context_label: Option<String>,
on_submit: PromptSubmitted,
on_cancel: Option<PromptCancelled>,
) -> Self {
Self {
title,
placeholder,
context_label,
on_submit,
on_cancel,
textarea: TextArea::new(),
textarea_state: RefCell::new(TextAreaState::default()),
complete: false,
@@ -89,6 +94,9 @@ impl BottomPaneView for CustomPromptView {
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.complete = true;
if let Some(cancel) = self.on_cancel.as_ref() {
cancel();
}
CancellationEvent::Handled
}

View File

@@ -3,6 +3,7 @@ use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
use codex_core::admin_controls::DangerPending;
use codex_core::config::Config;
use codex_core::config_types::Notifications;
use codex_core::git_info::current_branch_name;
@@ -260,6 +261,8 @@ pub(crate) struct ChatWidget {
needs_final_message_separator: bool,
last_rendered_width: std::cell::Cell<Option<usize>>,
pending_danger: Option<DangerPending>,
}
struct UserMessage {
@@ -285,6 +288,47 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
}
impl ChatWidget {
pub(crate) fn config_ref(&self) -> &Config {
&self.config
}
pub(crate) fn config_mut(&mut self) -> &mut Config {
&mut self.config
}
pub(crate) fn prompt_for_danger_justification(&mut self, pending: DangerPending) {
self.pending_danger = Some(pending);
self.add_info_message(
"Administrator justification required before enabling full access.".to_string(),
Some("Provide a short reason and press Enter to continue.".to_string()),
);
let submit_tx = self.app_event_tx.clone();
let cancel_tx = self.app_event_tx.clone();
let view = CustomPromptView::new(
"Administrator justification".to_string(),
"Type your justification and press Enter".to_string(),
Some("Your response will be logged for administrators.".to_string()),
Box::new(move |input: String| {
let trimmed = input.trim();
if trimmed.is_empty() {
return;
}
submit_tx.send(AppEvent::DangerJustificationSubmitted {
justification: trimmed.to_string(),
});
}),
Some(Box::new(move || {
cancel_tx.send(AppEvent::DangerJustificationCancelled);
})),
);
self.bottom_pane.show_view(Box::new(view));
}
pub(crate) fn take_pending_danger(&mut self) -> Option<DangerPending> {
self.pending_danger.take()
}
fn model_description_for(slug: &str) -> Option<&'static str> {
if slug.starts_with("gpt-5-codex") {
Some("Optimized for coding tasks with many tools.")
@@ -938,6 +982,7 @@ impl ChatWidget {
ghost_snapshots_disabled: true,
needs_final_message_separator: false,
last_rendered_width: std::cell::Cell::new(None),
pending_danger: None,
}
}
@@ -1001,6 +1046,7 @@ impl ChatWidget {
ghost_snapshots_disabled: true,
needs_final_message_separator: false,
last_rendered_width: std::cell::Cell::new(None),
pending_danger: None,
}
}
@@ -1776,21 +1822,11 @@ impl ChatWidget {
for preset in presets.into_iter() {
let is_current =
current_approval == preset.approval && current_sandbox == preset.sandbox;
let approval = preset.approval;
let sandbox = preset.sandbox.clone();
let name = preset.label.to_string();
let description = Some(preset.description.to_string());
let preset_for_action = preset.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(approval),
sandbox_policy: Some(sandbox.clone()),
model: None,
effort: None,
summary: None,
}));
tx.send(AppEvent::UpdateAskForApprovalPolicy(approval));
tx.send(AppEvent::UpdateSandboxPolicy(sandbox.clone()));
tx.send(AppEvent::ApplyApprovalPreset(preset_for_action.clone()));
})];
items.push(SelectionItem {
name,
@@ -2073,6 +2109,7 @@ impl ChatWidget {
},
}));
}),
None,
);
self.bottom_pane.show_view(Box::new(view));
}
@@ -2098,12 +2135,6 @@ impl ChatWidget {
self.conversation_id
}
/// Return a reference to the widget's current config (includes any
/// runtime overrides applied via TUI, e.g., model or approval policy).
pub(crate) fn config_ref(&self) -> &Config {
&self.config
}
pub(crate) fn clear_token_usage(&mut self) {
self.token_info = None;
}

View File

@@ -285,6 +285,7 @@ fn make_chatwidget_manual() -> (
ghost_snapshots_disabled: false,
needs_final_message_separator: false,
last_rendered_width: std::cell::Cell::new(None),
pending_danger: None,
};
(widget, rx, op_rx)
}

View File

@@ -30,6 +30,7 @@ use tracing_subscriber::EnvFilter;
use tracing_subscriber::prelude::*;
mod app;
mod app_admin;
mod app_backtrack;
mod app_event;
mod app_event_sender;

View File

@@ -796,3 +796,32 @@ notifications = [ "agent-turn-complete", "approval-requested" ]
| `responses_originator_header_internal_override` | string | Override `originator` header value. |
| `projects.<path>.trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). |
| `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). |
## Admin controls
The optional `[admin]` table lets administrators enforce sandbox policy and record audit events for every user on the host:
- When `admin.disallow_danger_full_access = true`, Codex refuses to enter `danger-full-access` mode.
- Setting `admin.allow_danger_with_reason = true` permits the interactive TUI to prompt for a justification. The reason is recorded by the configured audit sinks and `danger-full-access` is only granted after the user submits it.
- Headless commands (e.g., `codex exec`) cannot provide a justification and will exit with an error while these controls are active.
- Audit entries include timestamp, username, hostname, and the event payload. Configure a file sink, HTTP endpoint, or both. Use `admin.audit.log_events` to filter the JSON events you want to receive.
Example configuration:
```toml
[admin]
# Disallow --sandbox danger-full-access
disallow_danger_full_access = true
# Permit end users to proceed after providing a justification, which will be
# logged via the admin audit hook below.
allow_danger_with_reason = true
[admin.audit]
# (optional) file path to append audit entries as JSONL.
log_file = "~/Library/Logs/com.openai.codex/codex_audit.jsonl"
# (optional) HTTP webhook to receive audit events in JSON whenever Codex log event happens.
log_endpoint = "http://localhost:5000/collect"
# (optional) Determine which events to log.
log_events = ["danger", "command"]
```