mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Compare commits
10 Commits
dh--permis
...
add-admin-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f08cb68a62 | ||
|
|
357612da38 | ||
|
|
07442e4533 | ||
|
|
9f850f8bb6 | ||
|
|
662bc2c9ab | ||
|
|
81ec812bcc | ||
|
|
deebfb9d37 | ||
|
|
7d3cf212e1 | ||
|
|
ec9bf6f53e | ||
|
|
2c668fa4a1 |
12
codex-rs/Cargo.lock
generated
12
codex-rs/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
448
codex-rs/core/src/admin_controls.rs
Normal file
448
codex-rs/core/src/admin_controls.rs
Normal 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(
|
||||
¶ms,
|
||||
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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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(
|
||||
¶ms,
|
||||
sandbox,
|
||||
&config.sandbox_policy,
|
||||
&config.sandbox_cwd,
|
||||
);
|
||||
log_admin_event(admin_audit, payload);
|
||||
}
|
||||
process_exec_tool_call(
|
||||
params,
|
||||
sandbox,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
19
codex-rs/core/src/path_utils.rs
Normal file
19
codex-rs/core/src/path_utils.rs
Normal 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))
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
192
codex-rs/tui/src/app_admin.rs
Normal file
192
codex-rs/tui/src/app_admin.rs
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user