feat(approval): add semantic prefix matching

This commit is contained in:
James Peter
2025-09-19 14:03:22 +10:00
parent e5a01aeb56
commit 57ecce7cca
8 changed files with 741 additions and 156 deletions
+148 -6
View File
@@ -38,10 +38,12 @@ use tracing::warn;
use uuid::Uuid;
use crate::CodexAuth;
use crate::agent_tool::AgentStatusUpdatePayload;
use crate::protocol::ApprovedCommandMatchKind;
use crate::protocol::WebSearchBeginEvent;
use crate::protocol::WebSearchCompleteEvent;
use codex_protocol::models::WebSearchAction;
use codex_protocol::protocol::RolloutItem;
use shlex::split as shlex_split;
pub mod compact;
use self::compact::build_compacted_history;
@@ -627,10 +629,89 @@ impl Codex {
}
}
/// Mutable state of the agent
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ApprovedCommandPattern {
argv: Vec<String>,
kind: ApprovedCommandMatchKind,
semantic_prefix: Option<Vec<String>>,
}
impl ApprovedCommandPattern {
pub(crate) fn new(
argv: Vec<String>,
kind: ApprovedCommandMatchKind,
semantic_prefix: Option<Vec<String>>,
) -> Self {
let semantic_prefix = if matches!(kind, ApprovedCommandMatchKind::Prefix) {
semantic_prefix.or_else(|| Some(argv.clone()))
} else {
None
};
Self {
argv,
kind,
semantic_prefix,
}
}
pub(crate) fn matches(&self, command: &[String]) -> bool {
match self.kind {
ApprovedCommandMatchKind::Exact => command == self.argv.as_slice(),
ApprovedCommandMatchKind::Prefix => {
if command.starts_with(&self.argv) {
return true;
}
if let (Some(pattern), Some(candidate)) = (
self.semantic_prefix.as_ref(),
semantic_tokens(command),
) {
return candidate.starts_with(pattern);
}
false
}
}
}
pub fn argv(&self) -> &[String] { &self.argv }
pub fn kind(&self) -> ApprovedCommandMatchKind { self.kind }
}
fn semantic_tokens(command: &[String]) -> Option<Vec<String>> {
if command.is_empty() {
return None;
}
if let Some(tokens) = shell_script_tokens(command) {
return Some(tokens);
}
Some(command.to_vec())
}
fn shell_script_tokens(command: &[String]) -> Option<Vec<String>> {
if command.len() == 3 && is_shell_wrapper(&command[0], &command[1]) {
if let Some(tokens) = shlex_split(&command[2]) {
return Some(tokens);
}
return Some(vec![command[2].clone()]);
}
None
}
fn is_shell_wrapper(shell: &str, flag: &str) -> bool {
let file_name = Path::new(shell)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(shell)
.to_ascii_lowercase();
matches!(
file_name.as_str(),
"bash" | "sh" | "zsh" | "ksh" | "fish" | "dash"
) && matches!(flag, "-lc" | "-c")
}
#[derive(Default)]
struct State {
approved_commands: HashSet<Vec<String>>,
approved_commands: HashSet<ApprovedCommandPattern>,
current_task: Option<AgentTask>,
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
pending_input: Vec<ResponseInputItem>,
@@ -1070,9 +1151,9 @@ impl Session {
}
}
pub fn add_approved_command(&self, cmd: Vec<String>) {
pub fn add_approved_command(&self, pattern: ApprovedCommandPattern) {
let mut state = self.state.lock().unwrap();
state.approved_commands.insert(cmd);
state.approved_commands.insert(pattern);
}
/// Records items to both the rollout and the chat completions/ZDR
@@ -2087,6 +2168,14 @@ async fn submission_loop(
last_screenshot_info: Mutex::new(None),
confirm_guard: ConfirmGuardRuntime::from_config(&config.confirm_guard),
}));
if let Some(sess_arc) = &sess {
if !config.always_allow_commands.is_empty() {
let mut st = sess_arc.state.lock().unwrap();
for pattern in &config.always_allow_commands {
st.approved_commands.insert(pattern.clone());
}
}
}
let mut replay_history_items: Option<Vec<ResponseItem>> = None;
@@ -2229,6 +2318,24 @@ async fn submission_loop(
other => sess.notify_approval(&id, other),
}
}
Op::RegisterApprovedCommand {
command,
match_kind,
semantic_prefix,
} => {
if command.is_empty() {
continue;
}
if let Some(sess) = sess.as_ref() {
sess.add_approved_command(ApprovedCommandPattern::new(
command,
match_kind,
semantic_prefix,
));
} else {
send_no_session_event(sub.id).await;
}
}
Op::PatchApproval { id, decision } => {
let sess = match sess.as_ref() {
Some(sess) => sess,
@@ -5372,6 +5479,31 @@ async fn handle_container_exec_with_params(
None
}
fn strip_leading_confirm_prefix(argv: &mut Vec<String>) -> bool {
if argv.is_empty() {
return false;
}
let first = argv[0].trim().to_string();
for prefix in ["confirm:", "CONFIRM:"] {
if first == prefix {
argv.remove(0);
return true;
}
if let Some(rest) = first.strip_prefix(prefix) {
let trimmed = rest.trim_start();
if trimmed.is_empty() {
argv.remove(0);
} else {
argv[0] = trimmed.to_string();
}
return true;
}
}
false
}
fn guidance_for_sensitive_git(kind: SensitiveGitKind, original_label: &str, original_value: &str, suggested: &str) -> String {
match kind {
SensitiveGitKind::BranchChange => format!(
@@ -5661,6 +5793,8 @@ async fn handle_container_exec_with_params(
}
}
strip_leading_confirm_prefix(&mut params.command);
// If no shell wrapper, perform a lightweight argv inspection for sensitive git commands.
if extract_shell_script_from_wrapper(&params.command).is_none() {
let joined = params.command.join(" ");
@@ -5903,7 +6037,11 @@ async fn handle_container_exec_with_params(
match rx_approve.await.unwrap_or_default() {
ReviewDecision::Approved => (),
ReviewDecision::ApprovedForSession => {
sess.add_approved_command(params.command.clone());
sess.add_approved_command(ApprovedCommandPattern::new(
params.command.clone(),
ApprovedCommandMatchKind::Exact,
None,
));
}
ReviewDecision::Denied | ReviewDecision::Abort => {
return ResponseInputItem::FunctionCallOutput {
@@ -6082,7 +6220,11 @@ async fn handle_sandbox_error(
// remainder of the session so future
// executions skip the sandbox directly.
// TODO(ragona): Isn't this a bug? It always saves the command in an | fork?
sess.add_approved_command(params.command.clone());
sess.add_approved_command(ApprovedCommandPattern::new(
params.command.clone(),
ApprovedCommandMatchKind::Exact,
None,
));
// Inform UI we are retrying without sandbox.
sess.notify_background_event(&sub_id, "retrying command without sandbox")
.await;
+134
View File
@@ -1,5 +1,9 @@
use crate::codex::ApprovedCommandPattern;
use crate::protocol::ApprovedCommandMatchKind;
use crate::config_profile::ConfigProfile;
use crate::config_types::AgentConfig;
use crate::config_types::AllowedCommand;
use crate::config_types::AllowedCommandMatchKind;
use crate::config_types::BrowserConfig;
use crate::config_types::History;
use crate::config_types::GithubConfig;
@@ -38,6 +42,7 @@ use std::sync::OnceLock;
use tempfile::NamedTempFile;
use toml::Value as TomlValue;
use toml_edit::Array as TomlArray;
use toml_edit::ArrayOfTables as TomlArrayOfTables;
use toml_edit::DocumentMut;
use toml_edit::Item as TomlItem;
use toml_edit::Table as TomlTable;
@@ -89,6 +94,9 @@ pub struct Config {
pub sandbox_policy: SandboxPolicy,
/// Commands the user has permanently approved for this project/session.
pub always_allow_commands: Vec<ApprovedCommandPattern>,
pub shell_environment_policy: ShellEnvironmentPolicy,
/// Patterns requiring an explicit confirm prefix before running.
pub confirm_guard: ConfirmGuardConfig,
@@ -884,6 +892,101 @@ pub fn set_project_access_mode(
Ok(())
}
/// Append a command pattern to `[projects."<path>"].always_allow_commands`.
pub fn add_project_allowed_command(
codex_home: &Path,
project_path: &Path,
command: &[String],
match_kind: ApprovedCommandMatchKind,
) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
let mut doc = match std::fs::read_to_string(&read_path) {
Ok(s) => s.parse::<DocumentMut>()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => return Err(e.into()),
};
let project_key = project_path.to_string_lossy().to_string();
if doc
.as_table()
.get("projects")
.and_then(|i| i.as_table())
.is_none()
{
doc["projects"] = TomlItem::Table(TomlTable::new());
}
let Some(projects_tbl) = doc["projects"].as_table_mut() else {
return Err(anyhow::anyhow!("failed to prepare projects table"));
};
if projects_tbl
.get(project_key.as_str())
.and_then(|i| i.as_table())
.is_none()
{
projects_tbl.insert(project_key.as_str(), TomlItem::Table(TomlTable::new()));
}
let project_tbl = projects_tbl
.get_mut(project_key.as_str())
.and_then(|i| i.as_table_mut())
.ok_or_else(|| anyhow::anyhow!(format!("failed to create projects.{} table", project_key)))?;
let mut argv_array = TomlArray::new();
for arg in command {
argv_array.push(arg.clone());
}
let mut table = TomlTable::new();
table.insert("argv", TomlItem::Value(toml_edit::Value::Array(argv_array)));
let match_str = match match_kind {
ApprovedCommandMatchKind::Exact => "exact",
ApprovedCommandMatchKind::Prefix => "prefix",
};
table.insert(
"match_kind",
TomlItem::Value(toml_edit::Value::from(match_str)),
);
if let Some(existing) = project_tbl
.get_mut("always_allow_commands")
.and_then(|item| item.as_array_of_tables_mut())
{
let exists = existing.iter().any(|tbl| {
let argv_match = tbl
.get("argv")
.and_then(|item| item.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(ToString::to_string))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let match_kind = tbl
.get("match_kind")
.and_then(|item| item.as_str())
.unwrap_or("exact");
argv_match == command && match_kind.eq_ignore_ascii_case(match_str)
});
if !exists {
existing.push(table);
}
} else {
let mut arr = TomlArrayOfTables::new();
arr.push(table);
project_tbl.insert("always_allow_commands", TomlItem::ArrayOfTables(arr));
}
std::fs::create_dir_all(codex_home)?;
let tmp = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp.path(), doc.to_string())?;
tmp.persist(config_path)?;
Ok(())
}
/// List MCP servers from `CODEX_HOME/config.toml`.
/// Returns `(enabled, disabled)` lists of `(name, McpServerConfig)`.
pub fn list_mcp_servers(codex_home: &Path) -> anyhow::Result<(
@@ -1254,6 +1357,8 @@ pub struct ProjectConfig {
pub trust_level: Option<String>,
pub approval_policy: Option<AskForApproval>,
pub sandbox_mode: Option<SandboxMode>,
#[serde(default)]
pub always_allow_commands: Option<Vec<AllowedCommand>>,
}
#[derive(Deserialize, Debug, Clone, Default)]
@@ -1513,6 +1618,31 @@ impl Config {
let history = cfg.history.unwrap_or_default();
let mut always_allow_commands: Vec<ApprovedCommandPattern> = Vec::new();
if let Some(project_cfg) = project_override {
if let Some(commands) = &project_cfg.always_allow_commands {
for cmd in commands {
if cmd.argv.is_empty() {
continue;
}
let kind = match cmd.match_kind {
AllowedCommandMatchKind::Exact => ApprovedCommandMatchKind::Exact,
AllowedCommandMatchKind::Prefix => ApprovedCommandMatchKind::Prefix,
};
let semantic = if matches!(kind, ApprovedCommandMatchKind::Prefix) {
Some(cmd.argv.clone())
} else {
None
};
always_allow_commands.push(ApprovedCommandPattern::new(
cmd.argv.clone(),
kind,
semantic,
));
}
}
}
let tools_web_search_request = override_tools_web_search_request
.or(cfg.tools.as_ref().and_then(|t| t.web_search))
.unwrap_or(false);
@@ -1612,6 +1742,7 @@ impl Config {
cwd: resolved_cwd,
approval_policy: effective_approval,
sandbox_policy,
always_allow_commands,
shell_environment_policy,
confirm_guard,
disable_response_storage: config_profile
@@ -2302,6 +2433,7 @@ model_verbosity = "high"
model_provider: fixture.openai_provider.clone(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
always_allow_commands: Vec::new(),
shell_environment_policy: ShellEnvironmentPolicy::default(),
disable_response_storage: false,
user_instructions: None,
@@ -2366,6 +2498,7 @@ model_verbosity = "high"
active_profile: Some("gpt3".to_string()),
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
always_allow_commands: Vec::new(),
shell_environment_policy: ShellEnvironmentPolicy::default(),
disable_response_storage: false,
user_instructions: None,
@@ -2445,6 +2578,7 @@ model_verbosity = "high"
active_profile: Some("zdr".to_string()),
approval_policy: AskForApproval::OnFailure,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
always_allow_commands: Vec::new(),
shell_environment_policy: ShellEnvironmentPolicy::default(),
disable_response_storage: true,
user_instructions: None,
+16
View File
@@ -120,6 +120,15 @@ pub enum Op {
decision: ReviewDecision,
},
/// Register a command pattern as approved for the remainder of the session.
RegisterApprovedCommand {
command: Vec<String>,
match_kind: ApprovedCommandMatchKind,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
semantic_prefix: Option<Vec<String>>,
},
/// Approve a code patch
PatchApproval {
/// The id of the submission we are approving
@@ -176,6 +185,13 @@ pub enum AskForApproval {
Never,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "kebab-case")]
pub enum ApprovedCommandMatchKind {
Exact,
Prefix,
}
/// Determines execution restrictions for model shell commands.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display)]
#[strum(serialize_all = "kebab-case")]
+16
View File
@@ -139,6 +139,15 @@ pub enum Op {
decision: ReviewDecision,
},
/// Register a command pattern as approved for the remainder of the session.
RegisterApprovedCommand {
command: Vec<String>,
match_kind: ApprovedCommandMatchKind,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
semantic_prefix: Option<Vec<String>>,
},
/// Approve a code patch
PatchApproval {
/// The id of the submission we are approving
@@ -210,6 +219,13 @@ pub enum AskForApproval {
Never,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, TS)]
#[serde(rename_all = "kebab-case")]
pub enum ApprovedCommandMatchKind {
Exact,
Prefix,
}
/// Determines execution restrictions for model shell commands.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, TS)]
#[strum(serialize_all = "kebab-case")]
+33
View File
@@ -12,8 +12,10 @@ use crate::transcript_app::TranscriptApp;
use crate::tui;
use crate::tui::TerminalInfo;
use crate::history_cell;
use crate::exec_command::strip_bash_lc_and_escape;
use codex_core::ConversationManager;
use codex_login::{AuthManager, AuthMode};
use codex_core::config::add_project_allowed_command;
use codex_core::config::Config;
use codex_core::protocol::Event;
use codex_core::protocol::Op;
@@ -911,6 +913,37 @@ impl App<'_> {
widget.cancel_running_task_from_approval();
}
}
AppEvent::RegisterApprovedCommand { command, match_kind, persist, semantic_prefix } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.register_approved_command(
command.clone(),
match_kind.clone(),
semantic_prefix.clone(),
);
if persist {
if let Err(err) = add_project_allowed_command(
&self.config.codex_home,
&self.config.cwd,
&command,
match_kind.clone(),
) {
widget.history_push(history_cell::new_error_event(format!(
"Failed to persist always-allow command: {err:#}",
)));
} else {
let display = strip_bash_lc_and_escape(&command);
widget.history_push(history_cell::new_background_event(format!(
"Always allowing `{display}` for this project.",
)));
}
}
}
}
AppEvent::MarkTaskIdle => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.mark_task_idle_after_denied();
}
}
AppEvent::OpenTerminal(launch) => {
let mut spawn = None;
if let AppState::Chat { widget } = &mut self.app_state {
+11
View File
@@ -2,6 +2,7 @@ use codex_core::config_types::ReasoningEffort;
use codex_core::config_types::TextVerbosity;
use codex_core::config_types::ThemeName;
use codex_core::protocol::Event;
use codex_core::protocol::ApprovedCommandMatchKind;
use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use crossterm::event::MouseEvent;
@@ -206,6 +207,16 @@ pub(crate) enum AppEvent {
/// (clear spinner/status, finalize running exec/tool cells) while the core
/// continues its own abort/cleanup in parallel.
CancelRunningTask,
/// Register a command pattern as approved, optionally persisting to config.
RegisterApprovedCommand {
command: Vec<String>,
match_kind: ApprovedCommandMatchKind,
persist: bool,
semantic_prefix: Option<Vec<String>>,
},
/// Indicate that an approval was denied so the UI can clear transient
/// spinner/status state without interrupting the core task.
MarkTaskIdle,
OpenTerminal(TerminalLaunch),
TerminalChunk { id: u64, chunk: Vec<u8>, is_stderr: bool },
TerminalExit { id: u64, exit_code: Option<i32>, duration: Duration },
+39 -30
View File
@@ -41,6 +41,7 @@ mod terminal_handlers;
mod tools;
use codex_core::parse_command::ParsedCommand;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::ApprovedCommandMatchKind;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningDeltaEvent;
use codex_core::protocol::AgentReasoningEvent;
@@ -6186,20 +6187,6 @@ impl ChatWidget<'_> {
let label = match (&self.config.sandbox_policy, self.config.approval_policy) {
(SandboxPolicy::ReadOnly, _) => Some("Read Only".to_string()),
(
SandboxPolicy::WorkspaceWrite {
network_access: false,
..
},
AskForApproval::OnRequest,
)
| (
SandboxPolicy::WorkspaceWrite {
network_access: false,
..
},
AskForApproval::OnFailure,
)
| (
SandboxPolicy::WorkspaceWrite {
network_access: false,
..
@@ -6221,20 +6208,6 @@ impl ChatWidget<'_> {
let idx = match (&self.config.sandbox_policy, self.config.approval_policy) {
(SandboxPolicy::ReadOnly, _) => 0,
(
SandboxPolicy::WorkspaceWrite {
network_access: false,
..
},
AskForApproval::OnRequest,
)
| (
SandboxPolicy::WorkspaceWrite {
network_access: false,
..
},
AskForApproval::OnFailure,
)
| (
SandboxPolicy::WorkspaceWrite {
network_access: false,
..
@@ -6242,7 +6215,7 @@ impl ChatWidget<'_> {
AskForApproval::UnlessTrusted,
) => 1,
(SandboxPolicy::DangerFullAccess, AskForApproval::Never) => 2,
_ => 1,
_ => 0,
};
let next = (idx + 1) % 3;
@@ -6255,7 +6228,7 @@ impl ChatWidget<'_> {
),
1 => (
"Write with Approval",
AskForApproval::OnRequest,
AskForApproval::UnlessTrusted,
SandboxPolicy::new_workspace_write_policy(),
),
_ => (
@@ -6602,6 +6575,42 @@ impl ChatWidget<'_> {
self.interrupt_running_task();
}
pub(crate) fn register_approved_command(
&self,
command: Vec<String>,
match_kind: ApprovedCommandMatchKind,
semantic_prefix: Option<Vec<String>>,
) {
if command.is_empty() {
return;
}
let op = Op::RegisterApprovedCommand {
command,
match_kind,
semantic_prefix,
};
self.submit_op(op);
}
/// Clear transient spinner/status after a denial without interrupting core
/// execution. Only hide the spinner when there is no remaining activity so
/// we avoid masking in-flight work (e.g. follow-up reasoning).
pub(crate) fn mark_task_idle_after_denied(&mut self) {
let any_tools_running = !self.exec.running_commands.is_empty()
|| !self.tools_state.running_custom_tools.is_empty()
|| !self.tools_state.running_web_search.is_empty();
let any_streaming = self.stream.is_write_cycle_active();
let any_agents_active = self.agents_are_actively_running();
let any_tasks_active = !self.active_task_ids.is_empty();
if !(any_tools_running || any_streaming || any_agents_active || any_tasks_active) {
self.bottom_pane.set_task_running(false);
self.bottom_pane.update_status_text(String::new());
self.bottom_pane.clear_ctrl_c_quit_hint();
self.mark_needs_redraw();
}
}
pub(crate) fn insert_history_lines(&mut self, lines: Vec<ratatui::text::Line<'static>>) {
let kind = self.stream_state.current_kind.unwrap_or(StreamKind::Answer);
self.insert_history_lines_with_kind(kind, None, lines);
+344 -120
View File
@@ -6,9 +6,8 @@
//! UI to Rust using [`ratatui`]. The goal is feature‑parity for the keyboard
//! driven workflow – a fully‑fledged visual match is not required.
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use crossterm::event::KeyCode;
@@ -17,18 +16,19 @@ use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::*;
use ratatui::text::Line;
use ratatui::text::{Line, Span};
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use shlex::split as shlex_split;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::exec_command::strip_bash_lc_and_escape;
use codex_core::protocol::ApprovedCommandMatchKind;
/// Request coming from the agent that needs user approval.
pub(crate) enum ApprovalRequest {
@@ -44,62 +44,32 @@ pub(crate) enum ApprovalRequest {
},
}
/// Options displayed in the *select* mode.
///
/// The `key` is matched case-insensitively.
#[derive(Clone)]
struct SelectOption {
label: Line<'static>,
description: &'static str,
key: KeyCode,
decision: ReviewDecision,
label: String,
description: String,
hotkey: KeyCode,
action: SelectAction,
}
static COMMAND_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
vec![
SelectOption {
label: Line::from(vec!["Y".underlined(), "es".into()]),
description: "Approve and run the command",
key: KeyCode::Char('y'),
decision: ReviewDecision::Approved,
},
SelectOption {
label: Line::from(vec!["A".underlined(), "lways".into()]),
description: "Approve the command for the remainder of this session",
key: KeyCode::Char('a'),
decision: ReviewDecision::ApprovedForSession,
},
SelectOption {
label: Line::from(vec!["N".underlined(), "o, provide feedback".into()]),
description: "Do not run the command; provide feedback",
key: KeyCode::Char('n'),
decision: ReviewDecision::Abort,
},
]
});
static PATCH_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
vec![
SelectOption {
label: Line::from(vec!["Y".underlined(), "es".into()]),
description: "Approve and apply the changes",
key: KeyCode::Char('y'),
decision: ReviewDecision::Approved,
},
SelectOption {
label: Line::from(vec!["N".underlined(), "o, provide feedback".into()]),
description: "Do not apply the changes; provide feedback",
key: KeyCode::Char('n'),
decision: ReviewDecision::Abort,
},
]
});
#[derive(Clone)]
enum SelectAction {
ApproveOnce,
ApproveForSession {
command: Vec<String>,
match_kind: ApprovedCommandMatchKind,
persist: bool,
semantic_prefix: Option<Vec<String>>,
},
Abort,
}
/// A modal prompting the user to approve or deny the pending request.
pub(crate) struct UserApprovalWidget<'a> {
approval_request: ApprovalRequest,
app_event_tx: AppEventSender,
confirmation_prompt: Paragraph<'a>,
select_options: &'a Vec<SelectOption>,
select_options: Vec<SelectOption>,
/// Currently selected index in *select* mode.
selected_option: usize,
@@ -156,14 +126,16 @@ impl UserApprovalWidget<'_> {
}
};
let select_options = match &approval_request {
ApprovalRequest::Exec { command, .. } => build_exec_select_options(command),
ApprovalRequest::ApplyPatch { .. } => build_patch_select_options(),
};
Self {
select_options: match &approval_request {
ApprovalRequest::Exec { .. } => &COMMAND_SELECT_OPTIONS,
ApprovalRequest::ApplyPatch { .. } => &PATCH_SELECT_OPTIONS,
},
approval_request,
app_event_tx,
confirmation_prompt,
select_options,
selected_option: 0,
done: false,
}
@@ -210,29 +182,39 @@ impl UserApprovalWidget<'_> {
}
fn handle_select_key(&mut self, key_event: KeyEvent) {
let len = self.select_options.len();
if len == 0 {
return;
}
match key_event.code {
KeyCode::Left => {
self.selected_option = (self.selected_option + self.select_options.len() - 1)
% self.select_options.len();
KeyCode::Up | KeyCode::Left => {
self.selected_option = if self.selected_option == 0 {
len - 1
} else {
self.selected_option - 1
};
}
KeyCode::Right => {
self.selected_option = (self.selected_option + 1) % self.select_options.len();
KeyCode::Down | KeyCode::Right => {
self.selected_option = (self.selected_option + 1) % len;
}
KeyCode::Enter => {
let opt = &self.select_options[self.selected_option];
self.send_decision(opt.decision);
if let Some(option) = self.select_options.get(self.selected_option).cloned() {
self.perform_action(option.action);
}
}
KeyCode::Esc => {
self.send_decision(ReviewDecision::Abort);
self.perform_action(SelectAction::Abort);
}
other => {
let normalized = Self::normalize_keycode(other);
if let Some(opt) = self
if let Some((idx, option)) = self
.select_options
.iter()
.find(|opt| Self::normalize_keycode(opt.key) == normalized)
.enumerate()
.find(|(_, opt)| Self::normalize_keycode(opt.hotkey) == normalized)
{
self.send_decision(opt.decision);
self.selected_option = idx;
self.perform_action(option.action.clone());
}
}
}
@@ -271,8 +253,20 @@ impl UserApprovalWidget<'_> {
// If the user aborted an exec approval, immediately cancel any running task
// so the UI reflects their intent (clear spinner/status) without waiting
// for backend cleanup. Core still receives the Abort below.
if matches!((&self.approval_request, decision), (ApprovalRequest::Exec { .. }, ReviewDecision::Abort)) {
self.app_event_tx.send(AppEvent::CancelRunningTask);
match (&self.approval_request, decision) {
(ApprovalRequest::Exec { .. }, ReviewDecision::Abort) => {
self.app_event_tx.send(AppEvent::CancelRunningTask);
}
(ApprovalRequest::Exec { .. }, ReviewDecision::Denied) => {
self.app_event_tx.send(AppEvent::MarkTaskIdle);
}
(ApprovalRequest::ApplyPatch { .. }, ReviewDecision::Abort) => {
self.app_event_tx.send(AppEvent::CancelRunningTask);
}
(ApprovalRequest::ApplyPatch { .. }, ReviewDecision::Denied) => {
self.app_event_tx.send(AppEvent::MarkTaskIdle);
}
_ => {}
}
let op = match &self.approval_request {
@@ -290,6 +284,31 @@ impl UserApprovalWidget<'_> {
self.done = true;
}
fn perform_action(&mut self, action: SelectAction) {
match action {
SelectAction::ApproveOnce => {
self.send_decision(ReviewDecision::Approved);
}
SelectAction::ApproveForSession {
command,
match_kind,
persist,
semantic_prefix,
} => {
self.app_event_tx.send(AppEvent::RegisterApprovedCommand {
command: command.clone(),
match_kind: match_kind.clone(),
persist,
semantic_prefix: semantic_prefix.clone(),
});
self.send_decision(ReviewDecision::ApprovedForSession);
}
SelectAction::Abort => {
self.send_decision(ReviewDecision::Abort);
}
}
}
/// Returns `true` once the user has made a decision and the widget no
/// longer needs to be displayed.
pub(crate) fn is_complete(&self) -> bool {
@@ -297,75 +316,193 @@ impl UserApprovalWidget<'_> {
}
pub(crate) fn desired_height(&self, width: u16) -> u16 {
// Reserve space for:
// - 1 title line ("Allow command?" or "Apply changes?")
// - 1 buttons line (options rendered horizontally on a single row)
// - 1 description line (context for the currently selected option)
self.get_confirmation_prompt_height(width) + 3
let prompt = self.get_confirmation_prompt_height(width);
let option_lines = (self.select_options.len() as u16).saturating_mul(2);
prompt + option_lines + 2
}
}
impl WidgetRef for &UserApprovalWidget<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let prompt_height = self.get_confirmation_prompt_height(area.width);
let [prompt_chunk, response_chunk] = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(prompt_height), Constraint::Min(0)])
.areas(area);
let lines: Vec<Line> = self
.select_options
.iter()
.enumerate()
.map(|(idx, opt)| {
let style = if idx == self.selected_option {
Style::new()
.bg(crate::colors::light_blue())
.fg(crate::colors::background())
} else {
Style::new().add_modifier(Modifier::DIM)
};
opt.label.clone().alignment(Alignment::Center).style(style)
})
.collect();
let [title_area, button_area, description_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
let [prompt_chunk, options_chunk] = Layout::vertical([
Constraint::Length(prompt_height),
Constraint::Min(0),
])
.areas(response_chunk.inner(Margin::new(1, 0)));
let title = match &self.approval_request {
ApprovalRequest::Exec { .. } => "Allow command?",
ApprovalRequest::ApplyPatch { .. } => "Apply changes?",
};
Line::from(title).render(title_area, buf);
.areas(area);
self.confirmation_prompt.clone().render(prompt_chunk, buf);
let areas = Layout::horizontal(
lines
.iter()
.map(|l| Constraint::Length(l.width() as u16 + 2)),
)
.spacing(1)
.split(button_area);
for (idx, area) in areas.iter().enumerate() {
let line = &lines[idx];
line.render(*area, buf);
let mut lines: Vec<Line> = Vec::new();
for (idx, option) in self.select_options.iter().enumerate() {
let selected = idx == self.selected_option;
let indicator = if selected { "› " } else { " " };
let line_style = if selected {
Style::default()
.fg(crate::colors::primary())
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let label = format!("{}{}{}", indicator, option.label, hotkey_suffix(option.hotkey));
lines.push(Line::from(Span::styled(label, line_style)));
let desc_style = Style::default()
.fg(crate::colors::text_dim())
.add_modifier(Modifier::ITALIC);
lines.push(Line::from(Span::styled(
format!(" {}", option.description),
desc_style,
)));
lines.push(Line::from(""));
}
if !lines.is_empty() {
lines.pop();
}
Line::from(self.select_options[self.selected_option].description)
.style(Style::new().italic().add_modifier(Modifier::DIM))
.render(description_area.inner(Margin::new(1, 0)), buf);
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(options_chunk.inner(Margin::new(1, 0)), buf);
Block::bordered()
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().fg(crate::colors::light_blue()))
.borders(Borders::LEFT)
.render_ref(
Rect::new(0, response_chunk.y, 1, response_chunk.height),
buf,
);
.render_ref(Rect::new(0, options_chunk.y, 1, options_chunk.height), buf);
}
}
fn build_exec_select_options(command: &[String]) -> Vec<SelectOption> {
let mut options = Vec::new();
options.push(SelectOption {
label: "Yes".to_string(),
description: "Approve and run the command".to_string(),
hotkey: KeyCode::Char('y'),
action: SelectAction::ApproveOnce,
});
let full_display = strip_bash_lc_and_escape(command);
options.push(SelectOption {
label: format!("Always allow '{full_display}' for this project"),
description: "Approve this exact command automatically next time".to_string(),
hotkey: KeyCode::Char('a'),
action: SelectAction::ApproveForSession {
command: command.to_vec(),
match_kind: ApprovedCommandMatchKind::Exact,
persist: true,
semantic_prefix: None,
},
});
let normalized_tokens = normalized_command_tokens(command);
if let Some(tokens) = normalized_tokens.as_ref() {
if let Some(prefix) = prefix_candidate(tokens) {
let prefix_display = strip_bash_lc_and_escape(&prefix);
let prefix_with_wildcard = format!("{prefix_display} *");
options.push(SelectOption {
label: format!("Always allow '{prefix_with_wildcard}' for this project"),
description: "Approve any command starting with this prefix".to_string(),
hotkey: KeyCode::Char('p'),
action: SelectAction::ApproveForSession {
command: prefix.clone(),
match_kind: ApprovedCommandMatchKind::Prefix,
persist: true,
semantic_prefix: Some(prefix),
},
});
}
}
options.push(SelectOption {
label: "No, provide feedback".to_string(),
description: "Do not run the command; provide feedback".to_string(),
hotkey: KeyCode::Char('n'),
action: SelectAction::Abort,
});
options
}
fn build_patch_select_options() -> Vec<SelectOption> {
vec![
SelectOption {
label: "Yes".to_string(),
description: "Approve and apply the changes".to_string(),
hotkey: KeyCode::Char('y'),
action: SelectAction::ApproveOnce,
},
SelectOption {
label: "No, provide feedback".to_string(),
description: "Do not apply the changes; provide feedback".to_string(),
hotkey: KeyCode::Char('n'),
action: SelectAction::Abort,
},
]
}
fn normalized_command_tokens(command: &[String]) -> Option<Vec<String>> {
if command.is_empty() {
return None;
}
if command.len() == 3 && is_shell_wrapper(&command[0], &command[1]) {
if let Some(script_tokens) = shlex_split(&command[2]) {
return Some(script_tokens);
}
return Some(vec![command[2].clone()]);
}
Some(command.to_vec())
}
fn prefix_candidate(tokens: &[String]) -> Option<Vec<String>> {
if tokens.len() < 2 {
return None;
}
let mut prefix: Vec<String> = Vec::with_capacity(tokens.len());
for (idx, token) in tokens.iter().enumerate() {
if idx == 0 {
prefix.push(token.clone());
continue;
}
if token.starts_with('-')
|| token.contains('/')
|| token.contains('.')
|| token.contains('\\')
{
break;
}
prefix.push(token.clone());
if prefix.len() == 3 {
break;
}
}
if prefix.len() >= 2 && prefix.len() < tokens.len() {
Some(prefix)
} else {
None
}
}
fn is_shell_wrapper(shell: &str, flag: &str) -> bool {
let file_name = Path::new(shell)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(shell)
.to_ascii_lowercase();
matches!(file_name.as_str(), "bash" | "sh" | "zsh") && matches!(flag, "-lc" | "-c")
}
fn hotkey_suffix(key: KeyCode) -> String {
match key {
KeyCode::Char(c) => format!(" ({})", c.to_ascii_lowercase()),
_ => String::new(),
}
}
@@ -376,6 +513,7 @@ mod tests {
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use std::sync::mpsc::channel;
use codex_core::protocol::ApprovedCommandMatchKind;
#[test]
fn lowercase_shortcut_is_accepted() {
@@ -399,6 +537,92 @@ mod tests {
)));
}
#[test]
fn always_option_registers_command() {
let (tx_raw, rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let req = ApprovalRequest::Exec {
id: "1".to_string(),
command: vec!["git".into(), "status".into()],
reason: None,
};
let mut widget = UserApprovalWidget::new(req, tx);
widget.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
let events: Vec<AppEvent> = rx.try_iter().collect();
assert!(events.iter().any(|event| matches!(
event,
AppEvent::RegisterApprovedCommand {
command,
match_kind: ApprovedCommandMatchKind::Exact,
persist: true,
semantic_prefix: None,
} if command == &vec!["git".to_string(), "status".to_string()]
)));
}
#[test]
fn prefix_option_registers_prefix_command() {
let (tx_raw, rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let req = ApprovalRequest::Exec {
id: "3".to_string(),
command: vec![
"git".to_string(),
"checkout".to_string(),
"--".to_string(),
"README.md".to_string(),
],
reason: None,
};
let mut widget = UserApprovalWidget::new(req, tx);
widget.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
let events: Vec<AppEvent> = rx.try_iter().collect();
assert!(events.iter().any(|event| matches!(
event,
AppEvent::RegisterApprovedCommand {
command,
match_kind: ApprovedCommandMatchKind::Prefix,
persist: true,
semantic_prefix: Some(prefix)
} if command == &vec!["git".to_string(), "checkout".to_string()]
&& prefix == vec!["git".to_string(), "checkout".to_string()]
)));
}
#[test]
fn prefix_candidate_skips_flags_and_paths() {
assert_eq!(prefix_candidate(&["git".into(), "status".into()]), None);
assert_eq!(
prefix_candidate(&["git".into(), "checkout".into(), "--".into(), "file".into()]),
Some(vec!["git".into(), "checkout".into()])
);
assert_eq!(
prefix_candidate(&["aws".into(), "s3".into(), "cp".into(), "foo".into(), "bar".into()]),
Some(vec!["aws".into(), "s3".into(), "cp".into()])
);
assert_eq!(
prefix_candidate(&["docker".into(), "build".into(), "-t".into(), "image".into(), ".".into()]),
Some(vec!["docker".into(), "build".into()])
);
assert_eq!(
prefix_candidate(&["echo".into(), "hello".into(), "world".into()]),
None
);
// Shell-wrapped script
let normalized = normalized_command_tokens(&[
"bash".into(),
"-lc".into(),
"git checkout -- README.md".into(),
]);
assert_eq!(
normalized.as_ref().and_then(|tokens| prefix_candidate(tokens)),
Some(vec!["git".into(), "checkout".into()])
);
}
#[test]
fn uppercase_shortcut_is_accepted() {
let (tx_raw, rx) = channel::<AppEvent>();