second pass at tui integration

This commit is contained in:
kevin zhao
2025-11-20 12:25:23 -05:00
parent 54e6e4ac32
commit 4e2112e490
26 changed files with 594 additions and 83 deletions
+1
View File
@@ -1218,6 +1218,7 @@ dependencies = [
"serde_json",
"shlex",
"starlark",
"tempfile",
"thiserror 2.0.17",
]
@@ -153,6 +153,7 @@ pub(crate) async fn apply_bespoke_event_handling(
cwd,
reason,
risk,
allow_prefix: _allow_prefix,
parsed_cmd,
}) => match api_version {
ApiVersion::V1 => {
@@ -610,6 +611,7 @@ async fn on_exec_approval_response(
.submit(Op::ExecApproval {
id: event_id,
decision: response.decision,
allow_prefix: None,
})
.await
{
@@ -783,6 +785,7 @@ async fn on_command_execution_request_approval_response(
.submit(Op::ExecApproval {
id: event_id,
decision,
allow_prefix: None,
})
.await
{
+63 -3
View File
@@ -845,6 +845,37 @@ impl Session {
.await
}
pub(crate) async fn persist_command_allow_prefix(
&self,
prefix: &[String],
) -> Result<(), crate::exec_policy::ExecPolicyUpdateError> {
let (features, codex_home) = {
let state = self.state.lock().await;
(
state.session_configuration.features.clone(),
state
.session_configuration
.original_config_do_not_use
.codex_home
.clone(),
)
};
let policy =
crate::exec_policy::append_allow_prefix_rule_and_reload(&features, &codex_home, prefix)
.await?;
let mut state = self.state.lock().await;
state.session_configuration.exec_policy = policy;
Ok(())
}
pub(crate) async fn current_exec_policy(&self) -> Arc<ExecPolicy> {
let state = self.state.lock().await;
state.session_configuration.exec_policy.clone()
}
/// Emit an exec approval request event and await the user's decision.
///
/// The request is keyed by `sub_id`/`call_id` so matching responses are delivered
@@ -858,6 +889,7 @@ impl Session {
cwd: PathBuf,
reason: Option<String>,
risk: Option<SandboxCommandAssessment>,
allow_prefix: Option<Vec<String>>,
) -> ReviewDecision {
let sub_id = turn_context.sub_id.clone();
// Add the tx_approve callback to the map before sending the request.
@@ -885,6 +917,7 @@ impl Session {
cwd,
reason,
risk,
allow_prefix,
parsed_cmd,
});
self.send_event(turn_context, event).await;
@@ -1383,8 +1416,12 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op, &mut previous_context)
.await;
}
Op::ExecApproval { id, decision } => {
handlers::exec_approval(&sess, id, decision).await;
Op::ExecApproval {
id,
decision,
allow_prefix,
} => {
handlers::exec_approval(&sess, id, decision, allow_prefix).await;
}
Op::PatchApproval { id, decision } => {
handlers::patch_approval(&sess, id, decision).await;
@@ -1453,6 +1490,7 @@ mod handlers {
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use std::sync::Arc;
@@ -1538,7 +1576,29 @@ mod handlers {
*previous_context = Some(turn_context);
}
pub async fn exec_approval(sess: &Arc<Session>, id: String, decision: ReviewDecision) {
pub async fn exec_approval(
sess: &Arc<Session>,
id: String,
decision: ReviewDecision,
allow_prefix: Option<Vec<String>>,
) {
if let Some(prefix) = allow_prefix
&& matches!(
decision,
ReviewDecision::Approved | ReviewDecision::ApprovedForSession
)
{
if let Err(err) = sess.persist_command_allow_prefix(&prefix).await {
let message = format!("Failed to update execpolicy allow list: {err}");
tracing::warn!("{message}");
let warning = EventMsg::Warning(WarningEvent { message });
sess.send_event_raw(Event {
id: id.clone(),
msg: warning,
})
.await;
}
}
match decision {
ReviewDecision::Abort => {
sess.interrupt_task().await;
+8 -1
View File
@@ -235,6 +235,7 @@ async fn handle_exec_approval(
event.cwd,
event.reason,
event.risk,
event.allow_prefix,
);
let decision = await_approval_with_cancel(
approval_fut,
@@ -244,7 +245,13 @@ async fn handle_exec_approval(
)
.await;
let _ = codex.submit(Op::ExecApproval { id, decision }).await;
let _ = codex
.submit(Op::ExecApproval {
id,
decision,
allow_prefix: None,
})
.await;
}
/// Handle an ApplyPatchApprovalRequest by consulting the parent session and replying.
+6 -2
View File
@@ -288,7 +288,7 @@ mod tests {
#[test]
fn test_get_codex_user_agent() {
let user_agent = get_codex_user_agent();
assert!(user_agent.starts_with("codex_cli_rs/"));
assert!(user_agent.starts_with(&format!("{}/", originator().value)));
}
#[tokio::test]
@@ -365,9 +365,13 @@ mod tests {
#[cfg(target_os = "macos")]
fn test_macos() {
use regex_lite::Regex;
let originator = originator().value.clone();
let user_agent = get_codex_user_agent();
let re = Regex::new(
r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$",
format!(
r"^{originator}/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$"
)
.as_str(),
)
.unwrap();
assert!(re.is_match(&user_agent));
+195 -27
View File
@@ -4,10 +4,12 @@ use std::path::PathBuf;
use std::sync::Arc;
use crate::command_safety::is_dangerous_command::requires_initial_appoval;
use codex_execpolicy::AmendError;
use codex_execpolicy::Decision;
use codex_execpolicy::Evaluation;
use codex_execpolicy::Policy;
use codex_execpolicy::PolicyParser;
use codex_execpolicy::append_allow_prefix_rule;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use thiserror::Error;
@@ -23,6 +25,13 @@ const FORBIDDEN_REASON: &str = "execpolicy forbids this command";
const PROMPT_REASON: &str = "execpolicy requires approval for this command";
const POLICY_DIR_NAME: &str = "policy";
const POLICY_EXTENSION: &str = "codexpolicy";
const DEFAULT_POLICY_FILE: &str = "default.codexpolicy";
#[derive(Debug, PartialEq)]
pub(crate) struct CommandApprovalOutcome {
pub requirement: ApprovalRequirement,
pub allow_prefix: Option<Vec<String>>,
}
#[derive(Debug, Error)]
pub enum ExecPolicyError {
@@ -45,6 +54,15 @@ pub enum ExecPolicyError {
},
}
#[derive(Debug, Error)]
pub enum ExecPolicyUpdateError {
#[error("failed to update execpolicy file {path}: {source}")]
AppendRule { path: PathBuf, source: AmendError },
#[error("failed to reload execpolicy after updating policy: {0}")]
Reload(#[from] ExecPolicyError),
}
pub(crate) async fn exec_policy_for(
features: &Features,
codex_home: &Path,
@@ -84,6 +102,105 @@ pub(crate) async fn exec_policy_for(
Ok(policy)
}
pub(crate) fn default_policy_path(codex_home: &Path) -> PathBuf {
codex_home.join(POLICY_DIR_NAME).join(DEFAULT_POLICY_FILE)
}
pub(crate) async fn append_allow_prefix_rule_and_reload(
features: &Features,
codex_home: &Path,
prefix: &[String],
) -> Result<Arc<Policy>, ExecPolicyUpdateError> {
let policy_path = default_policy_path(codex_home);
append_allow_prefix_rule(&policy_path, prefix).map_err(|source| {
ExecPolicyUpdateError::AppendRule {
path: policy_path,
source,
}
})?;
exec_policy_for(features, codex_home)
.await
.map_err(ExecPolicyUpdateError::Reload)
}
fn requirement_from_decision(
decision: Decision,
approval_policy: AskForApproval,
) -> ApprovalRequirement {
match decision {
Decision::Forbidden => ApprovalRequirement::Forbidden {
reason: FORBIDDEN_REASON.to_string(),
},
Decision::Prompt => {
let reason = PROMPT_REASON.to_string();
if matches!(approval_policy, AskForApproval::Never) {
ApprovalRequirement::Forbidden { reason }
} else {
ApprovalRequirement::NeedsApproval {
reason: Some(reason),
}
}
}
Decision::Allow => ApprovalRequirement::Skip,
}
}
fn allow_prefix_if_applicable(
commands: &[Vec<String>],
evaluation: &Evaluation,
requirement: &ApprovalRequirement,
) -> Option<Vec<String>> {
if !matches!(requirement, ApprovalRequirement::NeedsApproval { .. }) {
return None;
}
if !matches!(evaluation, Evaluation::NoMatch) {
return None;
}
let [single_command] = commands else {
return None;
};
Some(single_command.clone())
}
pub(crate) fn command_approval_outcome_for_command(
policy: &Policy,
command: &[String],
approval_policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
sandbox_permissions: SandboxPermissions,
) -> CommandApprovalOutcome {
let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]);
let evaluation = policy.check_multiple(commands.iter());
let requirement = match evaluation {
Evaluation::Match { decision, .. } => requirement_from_decision(decision, approval_policy),
Evaluation::NoMatch => {
if requires_initial_appoval(
approval_policy,
sandbox_policy,
command,
sandbox_permissions,
) {
ApprovalRequirement::NeedsApproval { reason: None }
} else {
ApprovalRequirement::Skip
}
}
};
let allow_prefix = allow_prefix_if_applicable(&commands, &evaluation, &requirement);
CommandApprovalOutcome {
requirement,
allow_prefix,
}
}
#[cfg(test)]
fn evaluate_with_policy(
policy: &Policy,
command: &[String],
@@ -93,26 +210,14 @@ fn evaluate_with_policy(
let evaluation = policy.check_multiple(commands.iter());
match evaluation {
Evaluation::Match { decision, .. } => match decision {
Decision::Forbidden => Some(ApprovalRequirement::Forbidden {
reason: FORBIDDEN_REASON.to_string(),
}),
Decision::Prompt => {
let reason = PROMPT_REASON.to_string();
if matches!(approval_policy, AskForApproval::Never) {
Some(ApprovalRequirement::Forbidden { reason })
} else {
Some(ApprovalRequirement::NeedsApproval {
reason: Some(reason),
})
}
}
Decision::Allow => Some(ApprovalRequirement::Skip),
},
Evaluation::Match { decision, .. } => {
Some(requirement_from_decision(decision, approval_policy))
}
Evaluation::NoMatch => None,
}
}
#[cfg(test)]
pub(crate) fn create_approval_requirement_for_command(
policy: &Policy,
command: &[String],
@@ -120,20 +225,14 @@ pub(crate) fn create_approval_requirement_for_command(
sandbox_policy: &SandboxPolicy,
sandbox_permissions: SandboxPermissions,
) -> ApprovalRequirement {
if let Some(requirement) = evaluate_with_policy(policy, command, approval_policy) {
return requirement;
}
if requires_initial_appoval(
command_approval_outcome_for_command(
policy,
command,
approval_policy,
sandbox_policy,
command,
sandbox_permissions,
) {
ApprovalRequirement::NeedsApproval { reason: None }
} else {
ApprovalRequirement::Skip
}
)
.requirement
}
async fn collect_policy_files(dir: &Path) -> Result<Vec<PathBuf>, ExecPolicyError> {
@@ -362,4 +461,73 @@ prefix_rule(pattern=["rm"], decision="forbidden")
ApprovalRequirement::NeedsApproval { reason: None }
);
}
#[test]
fn allow_prefix_is_present_for_single_command_without_policy_match() {
let command = vec!["python".to_string()];
let empty_policy = Policy::empty();
let outcome = command_approval_outcome_for_command(
&empty_policy,
&command,
AskForApproval::UnlessTrusted,
&SandboxPolicy::ReadOnly,
SandboxPermissions::UseDefault,
);
assert_eq!(
outcome.requirement,
ApprovalRequirement::NeedsApproval { reason: None }
);
assert_eq!(outcome.allow_prefix, Some(command));
}
#[test]
fn allow_prefix_is_omitted_when_policy_prompts() {
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
let mut parser = PolicyParser::new();
parser
.parse("test.codexpolicy", policy_src)
.expect("parse policy");
let policy = parser.build();
let command = vec!["rm".to_string()];
let outcome = command_approval_outcome_for_command(
&policy,
&command,
AskForApproval::OnRequest,
&SandboxPolicy::DangerFullAccess,
SandboxPermissions::UseDefault,
);
assert_eq!(
outcome.requirement,
ApprovalRequirement::NeedsApproval {
reason: Some(PROMPT_REASON.to_string())
}
);
assert_eq!(outcome.allow_prefix, None);
}
#[test]
fn allow_prefix_is_omitted_for_multi_command_scripts() {
let command = vec![
"bash".to_string(),
"-lc".to_string(),
"python && echo ok".to_string(),
];
let outcome = command_approval_outcome_for_command(
&Policy::empty(),
&command,
AskForApproval::UnlessTrusted,
&SandboxPolicy::ReadOnly,
SandboxPermissions::UseDefault,
);
assert_eq!(
outcome.requirement,
ApprovalRequirement::NeedsApproval { reason: None }
);
assert_eq!(outcome.allow_prefix, None);
}
}
+11 -8
View File
@@ -9,7 +9,7 @@ use crate::apply_patch::convert_apply_patch_to_protocol;
use crate::codex::TurnContext;
use crate::exec::ExecParams;
use crate::exec_env::create_env;
use crate::exec_policy::create_approval_requirement_for_command;
use crate::exec_policy::command_approval_outcome_for_command;
use crate::function_tool::FunctionCallError;
use crate::is_safe_command::is_known_safe_command;
use crate::protocol::ExecCommandSource;
@@ -297,6 +297,14 @@ impl ShellHandler {
let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None);
emitter.begin(event_ctx).await;
let exec_policy = session.current_exec_policy().await;
let approval_outcome = command_approval_outcome_for_command(
exec_policy.as_ref(),
&exec_params.command,
turn.approval_policy,
&turn.sandbox_policy,
SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)),
);
let req = ShellRequest {
command: exec_params.command.clone(),
cwd: exec_params.cwd.clone(),
@@ -304,13 +312,8 @@ impl ShellHandler {
env: exec_params.env.clone(),
with_escalated_permissions: exec_params.with_escalated_permissions,
justification: exec_params.justification.clone(),
approval_requirement: create_approval_requirement_for_command(
&turn.exec_policy,
&exec_params.command,
turn.approval_policy,
&turn.sandbox_policy,
SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)),
),
approval_requirement: approval_outcome.requirement,
approval_allow_prefix: approval_outcome.allow_prefix,
};
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = ShellRuntime::new();
@@ -127,6 +127,7 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
cwd,
Some(reason),
risk,
None,
)
.await
} else if user_explicitly_approved {
+11 -1
View File
@@ -32,6 +32,7 @@ pub struct ShellRequest {
pub with_escalated_permissions: Option<bool>,
pub justification: Option<String>,
pub approval_requirement: ApprovalRequirement,
pub approval_allow_prefix: Option<Vec<String>>,
}
impl ProvidesSandboxRetryData for ShellRequest {
@@ -100,13 +101,22 @@ impl Approvable<ShellRequest> for ShellRuntime {
.clone()
.or_else(|| req.justification.clone());
let risk = ctx.risk.clone();
let allow_prefix = req.approval_allow_prefix.clone();
let session = ctx.session;
let turn = ctx.turn;
let call_id = ctx.call_id.to_string();
Box::pin(async move {
with_cached_approval(&session.services, key, move || async move {
session
.request_command_approval(turn, call_id, command, cwd, reason, risk)
.request_command_approval(
turn,
call_id,
command,
cwd,
reason,
risk,
allow_prefix,
)
.await
})
.await
@@ -35,6 +35,7 @@ pub struct UnifiedExecRequest {
pub with_escalated_permissions: Option<bool>,
pub justification: Option<String>,
pub approval_requirement: ApprovalRequirement,
pub approval_allow_prefix: Option<Vec<String>>,
}
impl ProvidesSandboxRetryData for UnifiedExecRequest {
@@ -65,6 +66,7 @@ impl UnifiedExecRequest {
with_escalated_permissions: Option<bool>,
justification: Option<String>,
approval_requirement: ApprovalRequirement,
approval_allow_prefix: Option<Vec<String>>,
) -> Self {
Self {
command,
@@ -73,6 +75,7 @@ impl UnifiedExecRequest {
with_escalated_permissions,
justification,
approval_requirement,
approval_allow_prefix,
}
}
}
@@ -120,10 +123,19 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
.clone()
.or_else(|| req.justification.clone());
let risk = ctx.risk.clone();
let allow_prefix = req.approval_allow_prefix.clone();
Box::pin(async move {
with_cached_approval(&session.services, key, || async move {
session
.request_command_approval(turn, call_id, command, cwd, reason, risk)
.request_command_approval(
turn,
call_id,
command,
cwd,
reason,
risk,
allow_prefix,
)
.await
})
.await
@@ -11,7 +11,7 @@ use crate::codex::TurnContext;
use crate::exec::ExecToolCallOutput;
use crate::exec::StreamOutput;
use crate::exec_env::create_env;
use crate::exec_policy::create_approval_requirement_for_command;
use crate::exec_policy::command_approval_outcome_for_command;
use crate::protocol::BackgroundEventEvent;
use crate::protocol::EventMsg;
use crate::protocol::ExecCommandSource;
@@ -445,19 +445,22 @@ impl UnifiedExecSessionManager {
) -> Result<UnifiedExecSession, UnifiedExecError> {
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = UnifiedExecRuntime::new(self);
let exec_policy = context.session.current_exec_policy().await;
let approval_outcome = command_approval_outcome_for_command(
exec_policy.as_ref(),
command,
context.turn.approval_policy,
&context.turn.sandbox_policy,
SandboxPermissions::from(with_escalated_permissions.unwrap_or(false)),
);
let req = UnifiedExecToolRequest::new(
command.to_vec(),
cwd,
create_env(&context.turn.shell_environment_policy),
with_escalated_permissions,
justification,
create_approval_requirement_for_command(
&context.turn.exec_policy,
command,
context.turn.approval_policy,
&context.turn.sandbox_policy,
SandboxPermissions::from(with_escalated_permissions.unwrap_or(false)),
),
approval_outcome.requirement,
approval_outcome.allow_prefix,
);
let tool_ctx = ToolCtx {
session: context.session.as_ref(),
+1
View File
@@ -1524,6 +1524,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
.submit(Op::ExecApproval {
id: "0".into(),
decision: *decision,
allow_prefix: None,
})
.await?;
wait_for_completion(&test).await;
@@ -93,6 +93,7 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() {
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::Approved,
allow_prefix: None,
})
.await
.expect("submit exec approval");
+6
View File
@@ -843,6 +843,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::Approved,
allow_prefix: None,
})
.await
.unwrap();
@@ -901,6 +902,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::ApprovedForSession,
allow_prefix: None,
})
.await
.unwrap();
@@ -959,6 +961,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::Approved,
allow_prefix: None,
})
.await
.unwrap();
@@ -1017,6 +1020,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::Denied,
allow_prefix: None,
})
.await
.unwrap();
@@ -1075,6 +1079,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::ApprovedForSession,
allow_prefix: None,
})
.await
.unwrap();
@@ -1134,6 +1139,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() {
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::Denied,
allow_prefix: None,
})
.await
.unwrap();
+1
View File
@@ -27,3 +27,4 @@ thiserror = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
+136
View File
@@ -0,0 +1,136 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use serde_json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AmendError {
#[error("prefix rule requires at least one token")]
EmptyPrefix,
#[error("policy path has no parent: {path}")]
MissingParent { path: PathBuf },
#[error("failed to create policy directory {dir}: {source}")]
CreatePolicyDir {
dir: PathBuf,
source: std::io::Error,
},
#[error("failed to format prefix token {token}: {source}")]
SerializeToken {
token: String,
source: serde_json::Error,
},
#[error("failed to open policy file {path}: {source}")]
OpenPolicyFile {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to write to policy file {path}: {source}")]
WritePolicyFile {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to read metadata for policy file {path}: {source}")]
PolicyMetadata {
path: PathBuf,
source: std::io::Error,
},
}
pub fn append_allow_prefix_rule(policy_path: &Path, prefix: &[String]) -> Result<(), AmendError> {
if prefix.is_empty() {
return Err(AmendError::EmptyPrefix);
}
let dir = policy_path
.parent()
.ok_or_else(|| AmendError::MissingParent {
path: policy_path.to_path_buf(),
})?;
std::fs::create_dir_all(dir).map_err(|source| AmendError::CreatePolicyDir {
dir: dir.to_path_buf(),
source,
})?;
let tokens: Vec<String> = prefix
.iter()
.map(|token| {
serde_json::to_string(token).map_err(|source| AmendError::SerializeToken {
token: token.clone(),
source,
})
})
.collect::<Result<_, _>>()?;
let pattern = tokens.join(", ");
let rule = format!("prefix_rule(pattern=[{pattern}], decision=\"allow\")\n");
let needs_newline = policy_path
.metadata()
.map(|metadata| metadata.len() > 0)
.unwrap_or(false);
let final_rule = if needs_newline {
format!("\n{rule}")
} else {
rule
};
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(policy_path)
.map_err(|source| AmendError::OpenPolicyFile {
path: policy_path.to_path_buf(),
source,
})?;
file.write_all(final_rule.as_bytes())
.map_err(|source| AmendError::WritePolicyFile {
path: policy_path.to_path_buf(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn appends_rule_and_creates_directories() {
let tmp = tempdir().expect("create temp dir");
let policy_path = tmp.path().join("policy").join("default.codexpolicy");
append_allow_prefix_rule(&policy_path, &[String::from("bash"), String::from("-lc")])
.expect("append rule");
let contents =
std::fs::read_to_string(&policy_path).expect("default.codexpolicy should exist");
assert_eq!(
contents,
"prefix_rule(pattern=[\"bash\", \"-lc\"], decision=\"allow\")\n"
);
}
#[test]
fn separates_rules_with_newlines_when_appending() {
let tmp = tempdir().expect("create temp dir");
let policy_path = tmp.path().join("policy").join("default.codexpolicy");
std::fs::create_dir_all(policy_path.parent().unwrap()).expect("create policy dir");
std::fs::write(
&policy_path,
"prefix_rule(pattern=[\"ls\"], decision=\"allow\")\n",
)
.expect("write seed rule");
append_allow_prefix_rule(&policy_path, &[String::from("git")]).expect("append rule");
let contents = std::fs::read_to_string(&policy_path).expect("read policy");
assert_eq!(
contents,
"prefix_rule(pattern=[\"ls\"], decision=\"allow\")\n\nprefix_rule(pattern=[\"git\"], decision=\"allow\")\n"
);
}
}
+3
View File
@@ -1,9 +1,12 @@
pub mod amend;
pub mod decision;
pub mod error;
pub mod parser;
pub mod policy;
pub mod rule;
pub use amend::AmendError;
pub use amend::append_allow_prefix_rule;
pub use decision::Decision;
pub use error::Error;
pub use error::Result;
@@ -180,6 +180,7 @@ async fn run_codex_tool_session_inner(
call_id,
reason: _,
risk,
allow_prefix: _,
parsed_cmd,
}) => {
handle_exec_approval_request(
+1
View File
@@ -150,6 +150,7 @@ async fn on_exec_approval_response(
.submit(Op::ExecApproval {
id: event_id,
decision: response.decision,
allow_prefix: None,
})
.await
{
+3
View File
@@ -50,6 +50,9 @@ pub struct ExecApprovalRequestEvent {
/// Optional model-provided risk assessment describing the blocked command.
#[serde(skip_serializing_if = "Option::is_none")]
pub risk: Option<SandboxCommandAssessment>,
/// Prefix rule that can be added to the user's execpolicy to allow future runs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allow_prefix: Option<Vec<String>>,
pub parsed_cmd: Vec<ParsedCommand>,
}
+3
View File
@@ -143,6 +143,9 @@ pub enum Op {
id: String,
/// The user's decision in response to the request.
decision: ReviewDecision,
/// When set, persist this prefix to the execpolicy allow list.
#[serde(default, skip_serializing_if = "Option::is_none")]
allow_prefix: Option<Vec<String>>,
},
/// Approve a code patch
+106 -31
View File
@@ -41,6 +41,7 @@ pub(crate) enum ApprovalRequest {
command: Vec<String>,
reason: Option<String>,
risk: Option<SandboxCommandAssessment>,
allow_prefix: Option<Vec<String>>,
},
ApplyPatch {
id: String,
@@ -97,8 +98,8 @@ impl ApprovalOverlay {
header: Box<dyn Renderable>,
) -> (Vec<ApprovalOption>, SelectionViewParams) {
let (options, title) = match &variant {
ApprovalVariant::Exec { .. } => (
exec_options(),
ApprovalVariant::Exec { allow_prefix, .. } => (
exec_options(allow_prefix.clone()),
"Would you like to run the following command?".to_string(),
),
ApprovalVariant::ApplyPatch { .. } => (
@@ -150,8 +151,8 @@ impl ApprovalOverlay {
};
if let Some(variant) = self.current_variant.as_ref() {
match (&variant, option.decision) {
(ApprovalVariant::Exec { id, command }, decision) => {
self.handle_exec_decision(id, command, decision);
(ApprovalVariant::Exec { id, command, .. }, decision) => {
self.handle_exec_decision(id, command, decision, option.allow_prefix.clone());
}
(ApprovalVariant::ApplyPatch { id, .. }, decision) => {
self.handle_patch_decision(id, decision);
@@ -163,12 +164,19 @@ impl ApprovalOverlay {
self.advance_queue();
}
fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) {
fn handle_exec_decision(
&self,
id: &str,
command: &[String],
decision: ReviewDecision,
allow_prefix: Option<Vec<String>>,
) {
let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision);
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval {
id: id.to_string(),
decision,
allow_prefix,
}));
}
@@ -238,8 +246,8 @@ impl BottomPaneView for ApprovalOverlay {
&& let Some(variant) = self.current_variant.as_ref()
{
match &variant {
ApprovalVariant::Exec { id, command } => {
self.handle_exec_decision(id, command, ReviewDecision::Abort);
ApprovalVariant::Exec { id, command, .. } => {
self.handle_exec_decision(id, command, ReviewDecision::Abort, None);
}
ApprovalVariant::ApplyPatch { id, .. } => {
self.handle_patch_decision(id, ReviewDecision::Abort);
@@ -291,6 +299,7 @@ impl From<ApprovalRequest> for ApprovalRequestState {
command,
reason,
risk,
allow_prefix,
} => {
let reason = reason.filter(|item| !item.is_empty());
let has_reason = reason.is_some();
@@ -310,7 +319,11 @@ impl From<ApprovalRequest> for ApprovalRequestState {
}
header.extend(full_cmd_lines);
Self {
variant: ApprovalVariant::Exec { id, command },
variant: ApprovalVariant::Exec {
id,
command,
allow_prefix,
},
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
}
}
@@ -364,8 +377,14 @@ fn render_risk_lines(risk: &SandboxCommandAssessment) -> Vec<Line<'static>> {
#[derive(Clone)]
enum ApprovalVariant {
Exec { id: String, command: Vec<String> },
ApplyPatch { id: String },
Exec {
id: String,
command: Vec<String>,
allow_prefix: Option<Vec<String>>,
},
ApplyPatch {
id: String,
},
}
#[derive(Clone)]
@@ -374,6 +393,7 @@ struct ApprovalOption {
decision: ReviewDecision,
display_shortcut: Option<KeyBinding>,
additional_shortcuts: Vec<KeyBinding>,
allow_prefix: Option<Vec<String>>,
}
impl ApprovalOption {
@@ -384,27 +404,43 @@ impl ApprovalOption {
}
}
fn exec_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Yes, proceed".to_string(),
decision: ReviewDecision::Approved,
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
},
ApprovalOption {
label: "Yes, and don't ask again for this command".to_string(),
decision: ReviewDecision::ApprovedForSession,
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
},
ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ReviewDecision::Abort,
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
},
]
fn exec_options(allow_prefix: Option<Vec<String>>) -> Vec<ApprovalOption> {
let mut options = Vec::new();
options.push(ApprovalOption {
label: "Yes, proceed".to_string(),
decision: ReviewDecision::Approved,
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
allow_prefix: None,
});
let (label, allow_prefix) = match allow_prefix {
Some(prefix) => (
"Yes, and don't ask again for commands with this prefix".to_string(),
Some(prefix),
),
None => (
"Yes, and don't ask again for this command".to_string(),
None,
),
};
options.push(ApprovalOption {
label,
decision: ReviewDecision::ApprovedForSession,
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
allow_prefix,
});
options.push(ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ReviewDecision::Abort,
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
allow_prefix: None,
});
options
}
fn patch_options() -> Vec<ApprovalOption> {
@@ -414,12 +450,14 @@ fn patch_options() -> Vec<ApprovalOption> {
decision: ReviewDecision::Approved,
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
allow_prefix: None,
},
ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ReviewDecision::Abort,
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
allow_prefix: None,
},
]
}
@@ -437,6 +475,7 @@ mod tests {
command: vec!["echo".to_string(), "hi".to_string()],
reason: Some("reason".to_string()),
risk: None,
allow_prefix: None,
}
}
@@ -469,6 +508,41 @@ mod tests {
assert!(saw_op, "expected approval decision to emit an op");
}
#[test]
fn exec_prefix_option_emits_allow_prefix() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = ApprovalOverlay::new(
ApprovalRequest::Exec {
id: "test".to_string(),
command: vec!["echo".to_string()],
reason: None,
risk: None,
allow_prefix: Some(vec!["echo".to_string()]),
},
tx,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
let mut saw_op = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::CodexOp(Op::ExecApproval {
allow_prefix,
decision,
..
}) = ev
{
assert_eq!(decision, ReviewDecision::ApprovedForSession);
assert_eq!(allow_prefix, Some(vec!["echo".to_string()]));
saw_op = true;
break;
}
}
assert!(
saw_op,
"expected approval decision to emit an op with allow prefix"
);
}
#[test]
fn header_includes_command_snippet() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
@@ -479,6 +553,7 @@ mod tests {
command,
reason: None,
risk: None,
allow_prefix: None,
};
let view = ApprovalOverlay::new(exec_request, tx);
+1
View File
@@ -540,6 +540,7 @@ mod tests {
command: vec!["echo".into(), "ok".into()],
reason: None,
risk: None,
allow_prefix: None,
}
}
+1
View File
@@ -1012,6 +1012,7 @@ impl ChatWidget {
command: ev.command,
reason: ev.reason,
risk: ev.risk,
allow_prefix: ev.allow_prefix,
};
self.bottom_pane.push_approval_request(request);
self.request_redraw();
@@ -10,7 +10,7 @@ expression: terminal.backend().vt100().screen().contents()
$ echo hello world
› 1. Yes, proceed (y)
2. Yes, and don't ask again for this command (a)
2. Yes, and don't ask again for commands with this prefix (a)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+6
View File
@@ -587,6 +587,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
"this is a test reason such as one that would be produced by the model".into(),
),
risk: None,
allow_prefix: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -631,6 +632,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
"this is a test reason such as one that would be produced by the model".into(),
),
risk: None,
allow_prefix: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -681,6 +683,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
risk: None,
allow_prefix: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -1830,6 +1833,7 @@ fn approval_modal_exec_snapshot() {
"this is a test reason such as one that would be produced by the model".into(),
),
risk: None,
allow_prefix: Some(vec!["echo".into(), "hello".into(), "world".into()]),
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -1876,6 +1880,7 @@ fn approval_modal_exec_without_reason_snapshot() {
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
risk: None,
allow_prefix: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -2089,6 +2094,7 @@ fn status_widget_and_approval_modal_snapshot() {
"this is a test reason such as one that would be produced by the model".into(),
),
risk: None,
allow_prefix: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {