Compare commits

...

2 Commits

Author SHA1 Message Date
Dylan Hurd
ff901ce04a better custom handling 2026-02-13 17:42:09 -08:00
Dylan Hurd
0ab58c871c feat(tui) /permissions custom option 2026-02-13 15:27:19 -08:00
8 changed files with 203 additions and 66 deletions

4
MODULE.bazel.lock generated

File diff suppressed because one or more lines are too long

View File

@@ -49,6 +49,7 @@ use codex_app_server_protocol::ConfigLayerSource;
use codex_backend_client::Client as BackendClient;
use codex_chatgpt::connectors;
use codex_core::config::Config;
use codex_core::config::ConfigToml;
use codex_core::config::ConstraintResult;
use codex_core::config::types::Notifications;
use codex_core::config::types::WindowsSandboxModeToml;
@@ -121,6 +122,7 @@ use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::Settings;
#[cfg(target_os = "windows")]
use codex_protocol::config_types::WindowsSandboxLevel;
@@ -5281,6 +5283,7 @@ impl ChatWidget {
let current_sandbox = self.config.permissions.sandbox_policy.get();
let mut items: Vec<SelectionItem> = Vec::new();
let presets: Vec<ApprovalPreset> = builtin_approval_presets();
let mut displayed_presets: Vec<ApprovalPreset> = Vec::new();
#[cfg(target_os = "windows")]
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
@@ -5298,6 +5301,7 @@ impl ChatWidget {
if !include_read_only && preset.id == "read-only" {
continue;
}
displayed_presets.push(preset.clone());
let is_current =
Self::preset_matches_current(current_approval, current_sandbox, &preset);
let name = if preset.id == "auto" && windows_degraded_sandbox_enabled {
@@ -5305,7 +5309,7 @@ impl ChatWidget {
} else {
preset.label.to_string()
};
let description = Some(preset.description.replace(" (Identical to Agent mode)", ""));
let description = Some(preset.description.to_string());
let disabled_reason = match self
.config
.permissions
@@ -5388,6 +5392,31 @@ impl ChatWidget {
});
}
let custom_config_permissions = Self::custom_permissions_from_user_config(&self.config);
if let Some((approval, sandbox)) = custom_config_permissions {
let matches_displayed_preset = displayed_presets
.iter()
.any(|preset| preset.approval == approval && preset.sandbox == sandbox);
if !matches_displayed_preset {
let is_current = current_approval == approval && *current_sandbox == sandbox;
let disabled_reason =
match self.config.permissions.approval_policy.can_set(&approval) {
Ok(()) => None,
Err(err) => Some(err.to_string()),
};
let description = crate::status::permissions_display_text_for(approval, &sandbox);
items.push(SelectionItem {
name: "Custom".to_string(),
description: Some(description),
is_current,
actions: Self::approval_preset_actions(approval, sandbox),
dismiss_on_select: true,
disabled_reason,
..Default::default()
});
}
}
let footer_note = show_elevate_sandbox_hint.then(|| {
vec![
"The non-admin sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the default sandbox, run ".dim(),
@@ -5456,6 +5485,48 @@ impl ChatWidget {
current_approval == preset.approval && *current_sandbox == preset.sandbox
}
fn custom_permissions_from_user_config(
config: &Config,
) -> Option<(AskForApproval, SandboxPolicy)> {
let user_layer = config.config_layer_stack.get_user_layer()?;
let config_toml: ConfigToml = user_layer.config.clone().try_into().ok()?;
let profile = config_toml
.get_config_profile(config.active_profile.clone())
.ok()?;
let approval_explicit =
profile.approval_policy.is_some() || config_toml.approval_policy.is_some();
let sandbox_mode_explicit =
profile.sandbox_mode.is_some() || config_toml.sandbox_mode.is_some();
let sandbox_workspace_write_explicit = config_toml.sandbox_workspace_write.is_some();
if !approval_explicit && !sandbox_mode_explicit && !sandbox_workspace_write_explicit {
return None;
}
let approval = profile
.approval_policy
.or(config_toml.approval_policy)
.unwrap_or_default();
let sandbox_mode = profile
.sandbox_mode
.or(config_toml.sandbox_mode)
.unwrap_or(SandboxMode::WorkspaceWrite);
let sandbox = match sandbox_mode {
SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(),
SandboxMode::WorkspaceWrite => match config_toml.sandbox_workspace_write {
Some(settings) => SandboxPolicy::WorkspaceWrite {
writable_roots: settings.writable_roots,
read_only_access: Default::default(),
network_access: settings.network_access,
exclude_tmpdir_env_var: settings.exclude_tmpdir_env_var,
exclude_slash_tmp: settings.exclude_slash_tmp,
},
None => SandboxPolicy::new_workspace_write_policy(),
},
SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess,
};
Some((approval, sandbox))
}
#[cfg(target_os = "windows")]
pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec<String>, usize, bool)> {
if self

View File

@@ -107,15 +107,24 @@ use tokio::sync::mpsc::unbounded_channel;
use toml::Value as TomlValue;
async fn test_config() -> Config {
// Use base defaults to avoid depending on host state.
let codex_home = std::env::temp_dir();
// Use an isolated codex_home to avoid depending on host state.
let codex_home = tempdir().expect("tempdir").keep();
ConfigBuilder::default()
.codex_home(codex_home.clone())
.codex_home(codex_home)
.build()
.await
.expect("config")
}
fn set_user_config_layer(chat: &mut ChatWidget, config_toml: TomlValue) {
let user_config_path = AbsolutePathBuf::try_from(std::env::temp_dir().join("config.toml"))
.expect("absolute user config path");
chat.config.config_layer_stack = chat
.config
.config_layer_stack
.with_user_config(&user_config_path, config_toml);
}
fn invalid_value(candidate: impl Into<String>, allowed: impl Into<String>) -> ConstraintError {
ConstraintError::InvalidValue {
field_name: "<unknown>",
@@ -4171,6 +4180,69 @@ async fn approvals_selection_popup_snapshot() {
assert_snapshot!("approvals_selection_popup", popup);
}
#[tokio::test]
async fn approvals_popup_shows_custom_option_for_custom_user_config() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let user_config_toml: TomlValue = toml::from_str(
r#"
approval_policy = "on-request"
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
network_access = true
"#,
)
.expect("valid user config.toml");
set_user_config_layer(&mut chat, user_config_toml);
chat.open_permissions_popup();
let popup = render_bottom_popup(&chat, 150);
assert!(
popup.contains("Custom"),
"expected popup to include a Custom option, got:\n{popup}"
);
assert!(
popup.contains("workspace-write with network access, on-request"),
"expected Custom option description to match status-card details, got:\n{popup}"
);
}
#[tokio::test]
async fn approvals_popup_hides_custom_option_for_default_or_full_access_user_config() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let user_default_toml: TomlValue = toml::from_str(
r#"
approval_policy = "on-request"
sandbox_mode = "workspace-write"
"#,
)
.expect("valid default-equivalent user config.toml");
set_user_config_layer(&mut chat, user_default_toml);
chat.open_permissions_popup();
let default_popup = render_bottom_popup(&chat, 120);
assert!(
!default_popup.contains("Custom"),
"default-equivalent config.toml should not surface Custom, got:\n{default_popup}"
);
let user_full_toml: TomlValue = toml::from_str(
r#"
approval_policy = "never"
sandbox_mode = "danger-full-access"
"#,
)
.expect("valid full-access-equivalent user config.toml");
set_user_config_layer(&mut chat, user_full_toml);
chat.open_permissions_popup();
let full_popup = render_bottom_popup(&chat, 120);
assert!(
!full_popup.contains("Custom"),
"full-access-equivalent config.toml should not surface Custom, got:\n{full_popup}"
);
}
#[cfg(target_os = "windows")]
#[tokio::test]
#[serial]

View File

@@ -8,7 +8,6 @@ use chrono::Local;
use codex_core::WireApi;
use codex_core::config::Config;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::NetworkAccess;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::TokenUsage;
use codex_core::protocol::TokenUsageInfo;
@@ -168,14 +167,6 @@ impl StatusHistoryCell {
("workdir", config.cwd.display().to_string()),
("model", model_name.to_string()),
("provider", config.model_provider_id.clone()),
(
"approval",
config.permissions.approval_policy.value().to_string(),
),
(
"sandbox",
summarize_sandbox_policy(config.permissions.sandbox_policy.get()),
),
];
if config.model_provider.wire_api == WireApi::Responses {
let effort_value = reasoning_effort_override
@@ -189,39 +180,7 @@ impl StatusHistoryCell {
));
}
let (model_name, model_details) = compose_model_display(model_name, &config_entries);
let approval = config_entries
.iter()
.find(|(k, _)| *k == "approval")
.map(|(_, v)| v.clone())
.unwrap_or_else(|| "<unknown>".to_string());
let sandbox = match config.permissions.sandbox_policy.get() {
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
SandboxPolicy::ReadOnly { .. } => "read-only".to_string(),
SandboxPolicy::WorkspaceWrite {
network_access: true,
..
} => "workspace-write with network access".to_string(),
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(),
SandboxPolicy::ExternalSandbox { network_access } => {
if matches!(network_access, NetworkAccess::Enabled) {
"external-sandbox (network access enabled)".to_string()
} else {
"external-sandbox".to_string()
}
}
};
let permissions = if config.permissions.approval_policy.value() == AskForApproval::OnRequest
&& *config.permissions.sandbox_policy.get()
== SandboxPolicy::new_workspace_write_policy()
{
"Default".to_string()
} else if config.permissions.approval_policy.value() == AskForApproval::Never
&& *config.permissions.sandbox_policy.get() == SandboxPolicy::DangerFullAccess
{
"Full Access".to_string()
} else {
format!("Custom ({sandbox}, {approval})")
};
let permissions = permissions_display_text(config);
let agents_summary = compose_agents_summary(config);
let model_provider = format_model_provider(config);
let account = compose_account_display(auth_manager, plan_type);
@@ -406,6 +365,26 @@ impl StatusHistoryCell {
}
}
pub(crate) fn permissions_display_text(config: &Config) -> String {
permissions_display_text_for(
config.permissions.approval_policy.value(),
config.permissions.sandbox_policy.get(),
)
}
pub(crate) fn permissions_display_text_for(
approval: AskForApproval,
sandbox_policy: &SandboxPolicy,
) -> String {
let approval = approval.to_string();
let sandbox = summarize_sandbox_policy(sandbox_policy, true);
match (approval.as_str(), sandbox.as_str()) {
("on-request", "workspace-write") => "Default".to_string(),
("never", "danger-full-access") => "Full Access".to_string(),
_ => format!("Custom ({sandbox}, {approval})"),
}
}
impl HistoryCell for StatusHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();

View File

@@ -15,6 +15,7 @@ mod rate_limits;
#[cfg(test)]
pub(crate) use card::new_status_output;
pub(crate) use card::new_status_output_with_rate_limits;
pub(crate) use card::permissions_display_text_for;
pub(crate) use helpers::format_directory_display;
pub(crate) use helpers::format_tokens_compact;
pub(crate) use rate_limits::RateLimitSnapshotDisplay;

View File

@@ -31,7 +31,7 @@ pub fn builtin_approval_presets() -> Vec<ApprovalPreset> {
ApprovalPreset {
id: "auto",
label: "Default",
description: "Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files. (Identical to Agent mode)",
description: "Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files.",
approval: AskForApproval::OnRequest,
sandbox: SandboxPolicy::new_workspace_write_policy(),
},

View File

@@ -15,7 +15,7 @@ pub fn create_config_summary_entries(config: &Config, model: &str) -> Vec<(&'sta
),
(
"sandbox",
summarize_sandbox_policy(config.permissions.sandbox_policy.get()),
summarize_sandbox_policy(config.permissions.sandbox_policy.get(), false),
),
];
if config.model_provider.wire_api == WireApi::Responses {

View File

@@ -1,7 +1,10 @@
use codex_core::protocol::NetworkAccess;
use codex_core::protocol::SandboxPolicy;
pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String {
pub fn summarize_sandbox_policy(
sandbox_policy: &SandboxPolicy,
ignore_writable_roots: bool,
) -> String {
match sandbox_policy {
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
SandboxPolicy::ReadOnly { .. } => "read-only".to_string(),
@@ -35,9 +38,11 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String {
.map(|p| p.to_string_lossy().to_string()),
);
summary.push_str(&format!(" [{}]", writable_entries.join(", ")));
if *network_access {
summary.push_str(" (network access enabled)");
summary.push_str(" with network access");
}
if !ignore_writable_roots {
summary.push_str(&format!(" [{}]", writable_entries.join(", ")));
}
summary
}
@@ -52,17 +57,23 @@ mod tests {
#[test]
fn summarizes_external_sandbox_without_network_access_suffix() {
let summary = summarize_sandbox_policy(&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
});
let summary = summarize_sandbox_policy(
&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
},
false,
);
assert_eq!(summary, "external-sandbox");
}
#[test]
fn summarizes_external_sandbox_with_enabled_network() {
let summary = summarize_sandbox_policy(&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
});
let summary = summarize_sandbox_policy(
&SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Enabled,
},
false,
);
assert_eq!(summary, "external-sandbox (network access enabled)");
}
@@ -70,13 +81,16 @@ mod tests {
fn workspace_write_summary_still_includes_network_access() {
let root = if cfg!(windows) { "C:\\repo" } else { "/repo" };
let writable_root = AbsolutePathBuf::try_from(root).unwrap();
let summary = summarize_sandbox_policy(&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![writable_root.clone()],
read_only_access: Default::default(),
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
});
let summary = summarize_sandbox_policy(
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![writable_root.clone()],
read_only_access: Default::default(),
network_access: true,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
},
false,
);
assert_eq!(
summary,
format!(