Compare commits

...

10 Commits

Author SHA1 Message Date
kevin zhao
4488061a9d execpolicy struct 2025-12-04 07:23:05 +00:00
kevin zhao
249bd10726 . 2025-12-04 07:23:05 +00:00
kevin zhao
1635285cf8 rwlock 2025-12-04 07:23:05 +00:00
kevin zhao
4f2da75601 . 2025-12-04 07:23:05 +00:00
kevin zhao
47b3bf73f1 . 2025-12-04 07:23:05 +00:00
kevin zhao
74c9ed0b05 Add PR description file for execpolicy refactor 2025-12-04 07:23:05 +00:00
kevin zhao
4e0de6bc1f Add PR description 2025-12-04 07:23:05 +00:00
kevin zhao
2526ae1ed7 Refactor execpolicy fallback evaluation 2025-12-04 07:23:05 +00:00
kevin zhao
2f75048697 . 2025-12-04 07:23:05 +00:00
kevin zhao
195bf29185 Add approval allow-prefix flow in core and tui
Add explicit prefix-approval decision and wire it through execpolicy/UI snapshots

update doc

mutating in memory policy instead of reloading

using RW locks

clippy

refactor: adding allow_prefix into ApprovedAllowPrefix

fmt

do not send allow_prefix if execpolicy is disabled

moving args around

cleanup exec_policy getters

undo diff

fixing rw lock bug causing tui to hang

updating phrasing

integration test

.

fix compile

fix flaky test

fix compile error

running test with single thread

fixup allow_prefix_if_applicable

fix formatting

fix approvals test

only cloning when needed

docs

add docstring

fix rebase bug

fixing rebase issues

Revert "fixing rebase issues"

This reverts commit 79ce7e1f2fc0378c2c0b362408e2e544566540fd.

fix rebase errors
2025-12-04 07:23:05 +00:00
27 changed files with 538 additions and 257 deletions

View File

@@ -179,7 +179,7 @@ pub(crate) async fn apply_bespoke_event_handling(
cwd,
reason,
risk,
allow_prefix: _allow_prefix,
proposed_execpolicy_amendment: _,
parsed_cmd,
}) => match api_version {
ApiVersion::V1 => {

View File

@@ -40,17 +40,15 @@ prefix_rule(
assert_eq!(
result,
json!({
"match": {
"decision": "forbidden",
"matchedRules": [
{
"prefixRuleMatch": {
"matchedPrefix": ["git", "push"],
"decision": "forbidden"
}
"decision": "forbidden",
"matchedRules": [
{
"prefixRuleMatch": {
"matchedPrefix": ["git", "push"],
"decision": "forbidden"
}
]
}
}
]
})
);

View File

@@ -71,7 +71,7 @@ pub(crate) async fn apply_patch(
.await;
match rx_approve.await.unwrap_or_default() {
ReviewDecision::Approved
| ReviewDecision::ApprovedAllowPrefix { .. }
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {
InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec {
action,

View File

@@ -25,6 +25,7 @@ use crate::util::error_or_panic;
use async_channel::Receiver;
use async_channel::Sender;
use codex_protocol::ConversationId;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::items::TurnItem;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::HasLegacyEvent;
@@ -871,13 +872,11 @@ impl Session {
.await
}
/// Adds a prefix rule to the exec policy
///
/// This mutates the in-memory execpolicy so the current conversation can use the new
/// prefix and persists the change in default.execpolicy so new conversations will also allow the new prefix.
pub(crate) async fn persist_command_allow_prefix(
/// Adds an execpolicy amendment to both the in-memory and on-disk policies so future
/// commands can use the newly approved prefix.
pub(crate) async fn persist_execpolicy_amendment(
&self,
prefix: &[String],
amendment: &ExecPolicyAmendment,
) -> Result<(), ExecPolicyUpdateError> {
let features = self.features.clone();
let (codex_home, current_policy) = {
@@ -897,10 +896,10 @@ impl Session {
return Err(ExecPolicyUpdateError::FeatureDisabled);
}
crate::exec_policy::append_allow_prefix_rule_and_update(
crate::exec_policy::append_execpolicy_amendment_and_update(
&codex_home,
&current_policy,
prefix,
&amendment.command,
)
.await?;
@@ -921,7 +920,7 @@ impl Session {
cwd: PathBuf,
reason: Option<String>,
risk: Option<SandboxCommandAssessment>,
allow_prefix: Option<Vec<String>>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
) -> ReviewDecision {
let sub_id = turn_context.sub_id.clone();
// Add the tx_approve callback to the map before sending the request.
@@ -949,7 +948,7 @@ impl Session {
cwd,
reason,
risk,
allow_prefix,
proposed_execpolicy_amendment,
parsed_cmd,
});
self.send_event(turn_context, event).await;
@@ -1672,13 +1671,17 @@ mod handlers {
}
}
/// Propagate a user's exec approval decision to the session
/// Also optionally whitelists command in execpolicy
/// Propagate a user's exec approval decision to the session.
/// Also optionally applies an execpolicy amendment.
pub async fn exec_approval(sess: &Arc<Session>, id: String, decision: ReviewDecision) {
if let ReviewDecision::ApprovedAllowPrefix { allow_prefix } = &decision
&& let Err(err) = sess.persist_command_allow_prefix(allow_prefix).await
if let ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment,
} = &decision
&& let Err(err) = sess
.persist_execpolicy_amendment(proposed_execpolicy_amendment)
.await
{
let message = format!("Failed to update execpolicy allow list: {err}");
let message = format!("Failed to apply execpolicy amendment: {err}");
tracing::warn!("{message}");
let warning = EventMsg::Warning(WarningEvent { message });
sess.send_event_raw(Event {

View File

@@ -281,7 +281,7 @@ async fn handle_exec_approval(
event.cwd,
event.reason,
event.risk,
event.allow_prefix,
event.proposed_execpolicy_amendment,
);
let decision = await_approval_with_cancel(
approval_fut,

View File

@@ -10,7 +10,9 @@ use codex_execpolicy::Error as ExecPolicyRuleError;
use codex_execpolicy::Evaluation;
use codex_execpolicy::Policy;
use codex_execpolicy::PolicyParser;
use codex_execpolicy::RuleMatch;
use codex_execpolicy::blocking_append_allow_prefix_rule;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use thiserror::Error;
@@ -25,6 +27,8 @@ use crate::sandboxing::SandboxPermissions;
use crate::tools::sandboxing::ExecApprovalRequirement;
const FORBIDDEN_REASON: &str = "execpolicy forbids this command";
const PROMPT_CONFLICT_REASON: &str =
"execpolicy requires approval for this command, but AskForApproval is set to Never";
const PROMPT_REASON: &str = "execpolicy requires approval for this command";
const POLICY_DIR_NAME: &str = "policy";
const POLICY_EXTENSION: &str = "codexpolicy";
@@ -112,7 +116,7 @@ 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_update(
pub(crate) async fn append_execpolicy_amendment_and_update(
codex_home: &Path,
current_policy: &Arc<RwLock<Policy>>,
prefix: &[String],
@@ -139,45 +143,54 @@ pub(crate) async fn append_allow_prefix_rule_and_update(
Ok(())
}
fn requirement_from_decision(
decision: Decision,
approval_policy: AskForApproval,
) -> ExecApprovalRequirement {
match decision {
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
reason: FORBIDDEN_REASON.to_string(),
},
Decision::Prompt => {
let reason = PROMPT_REASON.to_string();
if matches!(approval_policy, AskForApproval::Never) {
ExecApprovalRequirement::Forbidden { reason }
} else {
ExecApprovalRequirement::NeedsApproval {
reason: Some(reason),
allow_prefix: None,
/// Returns a proposed execpolicy amendment only when heuristics caused
/// the prompt decision, so we can offer to apply that amendment for future runs.
///
/// The amendment uses the first command heuristics marked as `Prompt`. If any explicit
/// execpolicy rule also prompts, we return `None` because applying the amendment would not
/// skip that policy requirement.
///
/// Examples:
/// - execpolicy: empty. Command: `["python"]`. Heuristics prompt -> `Some(vec!["python"])`.
/// - execpolicy: empty. Command: `["bash", "-c", "cd /some/folder && prog1 --option1 arg1 && prog2 --option2 arg2"]`.
/// Parsed commands include `cd /some/folder`, `prog1 --option1 arg1`, and `prog2 --option2 arg2`. If heuristics allow `cd` but prompt
/// on `prog1`, we return `Some(vec!["prog1", "--option1", "arg1"])`.
/// - execpolicy: contains a `prompt for prefix ["prog2"]` rule. For the same command as above,
/// we return `None` because an execpolicy prompt still applies even if we amend execpolicy to allow ["prog1", "--option1", "arg1"].
fn proposed_execpolicy_amendment(evaluation: &Evaluation) -> Option<ExecPolicyAmendment> {
if evaluation.decision != Decision::Prompt {
return None;
}
let mut first_prompt_from_heuristics: Option<Vec<String>> = None;
for rule_match in &evaluation.matched_rules {
match rule_match {
RuleMatch::HeuristicsRuleMatch { command, decision } => {
if *decision == Decision::Prompt && first_prompt_from_heuristics.is_none() {
first_prompt_from_heuristics = Some(command.clone());
}
}
_ if rule_match.decision() == Decision::Prompt => {
return None;
}
_ => {}
}
Decision::Allow => ExecApprovalRequirement::Skip {
bypass_sandbox: true,
},
}
first_prompt_from_heuristics.map(ExecPolicyAmendment::from)
}
/// Return an allow-prefix option when a single plain command needs approval without
/// any matching policy rule. We only surface the prefix opt-in when execpolicy did
/// not already drive the decision (NoMatch) and when the command is a single
/// unrolled command (multi-part scripts shouldnt be whitelisted via prefix) and
/// when execpolicy feature is enabled.
fn allow_prefix_if_applicable(
commands: &[Vec<String>],
features: &Features,
) -> Option<Vec<String>> {
if features.enabled(Feature::ExecPolicy) && commands.len() == 1 {
Some(commands[0].clone())
} else {
None
}
/// Only return PROMPT_REASON when an execpolicy rule drove the prompt decision.
fn derive_prompt_reason(evaluation: &Evaluation) -> Option<String> {
evaluation.matched_rules.iter().find_map(|rule_match| {
if !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. })
&& rule_match.decision() == Decision::Prompt
{
Some(PROMPT_REASON.to_string())
} else {
None
}
})
}
pub(crate) async fn create_exec_approval_requirement_for_command(
@@ -189,27 +202,43 @@ pub(crate) async fn create_exec_approval_requirement_for_command(
sandbox_permissions: SandboxPermissions,
) -> ExecApprovalRequirement {
let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]);
let evaluation = exec_policy.read().await.check_multiple(commands.iter());
let heuristics_fallback = |cmd: &[String]| {
if requires_initial_appoval(approval_policy, sandbox_policy, cmd, sandbox_permissions) {
Decision::Prompt
} else {
Decision::Allow
}
};
let policy = exec_policy.read().await;
let evaluation = policy.check_multiple(commands.iter(), &heuristics_fallback);
let has_policy_allow = evaluation.matched_rules.iter().any(|rule_match| {
!matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. })
&& rule_match.decision() == Decision::Allow
});
match evaluation {
Evaluation::Match { decision, .. } => requirement_from_decision(decision, approval_policy),
Evaluation::NoMatch { .. } => {
if requires_initial_appoval(
approval_policy,
sandbox_policy,
command,
sandbox_permissions,
) {
ExecApprovalRequirement::NeedsApproval {
reason: None,
allow_prefix: allow_prefix_if_applicable(&commands, features),
match evaluation.decision {
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
reason: FORBIDDEN_REASON.to_string(),
},
Decision::Prompt => {
if matches!(approval_policy, AskForApproval::Never) {
ExecApprovalRequirement::Forbidden {
reason: PROMPT_CONFLICT_REASON.to_string(),
}
} else {
ExecApprovalRequirement::Skip {
bypass_sandbox: false,
ExecApprovalRequirement::NeedsApproval {
reason: derive_prompt_reason(&evaluation),
proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) {
proposed_execpolicy_amendment(&evaluation)
} else {
None
},
}
}
}
Decision::Allow => ExecApprovalRequirement::Skip {
bypass_sandbox: has_policy_allow,
},
}
}
@@ -282,10 +311,19 @@ mod tests {
.expect("policy result");
let commands = [vec!["rm".to_string()]];
assert!(matches!(
policy.read().await.check_multiple(commands.iter()),
Evaluation::NoMatch { .. }
));
assert_eq!(
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
command: vec!["rm".to_string()],
decision: Decision::Allow
}],
},
policy
.read()
.await
.check_multiple(commands.iter(), &|_| Decision::Allow)
);
assert!(!temp_dir.path().join(POLICY_DIR_NAME).exists());
}
@@ -316,10 +354,19 @@ mod tests {
.await
.expect("policy result");
let command = [vec!["rm".to_string()]];
assert!(matches!(
policy.read().await.check_multiple(command.iter()),
Evaluation::Match { .. }
));
assert_eq!(
Evaluation {
decision: Decision::Forbidden,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["rm".to_string()],
decision: Decision::Forbidden
}],
},
policy
.read()
.await
.check_multiple(command.iter(), &|_| Decision::Allow)
);
}
#[tokio::test]
@@ -335,10 +382,19 @@ mod tests {
.await
.expect("policy result");
let command = [vec!["ls".to_string()]];
assert!(matches!(
policy.read().await.check_multiple(command.iter()),
Evaluation::NoMatch { .. }
));
assert_eq!(
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
command: vec!["ls".to_string()],
decision: Decision::Allow
}],
},
policy
.read()
.await
.check_multiple(command.iter(), &|_| Decision::Allow)
);
}
#[tokio::test]
@@ -400,7 +456,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
requirement,
ExecApprovalRequirement::NeedsApproval {
reason: Some(PROMPT_REASON.to_string()),
allow_prefix: None,
proposed_execpolicy_amendment: None,
}
);
}
@@ -428,7 +484,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
assert_eq!(
requirement,
ExecApprovalRequirement::Forbidden {
reason: PROMPT_REASON.to_string()
reason: PROMPT_CONFLICT_REASON.to_string()
}
);
}
@@ -452,29 +508,61 @@ prefix_rule(pattern=["rm"], decision="forbidden")
requirement,
ExecApprovalRequirement::NeedsApproval {
reason: None,
allow_prefix: Some(command)
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command))
}
);
}
#[tokio::test]
async fn append_allow_prefix_rule_updates_policy_and_file() {
async fn heuristics_apply_when_other_commands_match_policy() {
let policy_src = r#"prefix_rule(pattern=["apple"], decision="allow")"#;
let mut parser = PolicyParser::new();
parser
.parse("test.codexpolicy", policy_src)
.expect("parse policy");
let policy = Arc::new(RwLock::new(parser.build()));
let command = vec![
"bash".to_string(),
"-lc".to_string(),
"apple | orange".to_string(),
];
assert_eq!(
create_exec_approval_requirement_for_command(
&policy,
&Features::with_defaults(),
&command,
AskForApproval::UnlessTrusted,
&SandboxPolicy::DangerFullAccess,
SandboxPermissions::UseDefault,
)
.await,
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"orange".to_string()
]))
}
);
}
#[tokio::test]
async fn append_execpolicy_amendment_updates_policy_and_file() {
let codex_home = tempdir().expect("create temp dir");
let current_policy = Arc::new(RwLock::new(Policy::empty()));
let prefix = vec!["echo".to_string(), "hello".to_string()];
append_allow_prefix_rule_and_update(codex_home.path(), &current_policy, &prefix)
append_execpolicy_amendment_and_update(codex_home.path(), &current_policy, &prefix)
.await
.expect("update policy");
let evaluation = current_policy.read().await.check(&[
"echo".to_string(),
"hello".to_string(),
"world".to_string(),
]);
let evaluation = current_policy.read().await.check(
&["echo".to_string(), "hello".to_string(), "world".to_string()],
&|_| Decision::Allow,
);
assert!(matches!(
evaluation,
Evaluation::Match {
Evaluation {
decision: Decision::Allow,
..
}
@@ -490,12 +578,12 @@ prefix_rule(pattern=["rm"], decision="forbidden")
}
#[tokio::test]
async fn append_allow_prefix_rule_rejects_empty_prefix() {
async fn append_execpolicy_amendment_rejects_empty_prefix() {
let codex_home = tempdir().expect("create temp dir");
let current_policy = Arc::new(RwLock::new(Policy::empty()));
let result =
append_allow_prefix_rule_and_update(codex_home.path(), &current_policy, &[]).await;
append_execpolicy_amendment_and_update(codex_home.path(), &current_policy, &[]).await;
assert!(matches!(
result,
@@ -507,7 +595,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
}
#[tokio::test]
async fn allow_prefix_is_present_for_single_command_without_policy_match() {
async fn proposed_execpolicy_amendment_is_present_for_single_command_without_policy_match() {
let command = vec!["cargo".to_string(), "build".to_string()];
let empty_policy = Arc::new(RwLock::new(Policy::empty()));
@@ -525,13 +613,13 @@ prefix_rule(pattern=["rm"], decision="forbidden")
requirement,
ExecApprovalRequirement::NeedsApproval {
reason: None,
allow_prefix: Some(command)
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command))
}
);
}
#[tokio::test]
async fn allow_prefix_is_disabled_when_execpolicy_feature_disabled() {
async fn proposed_execpolicy_amendment_is_disabled_when_execpolicy_feature_disabled() {
let command = vec!["cargo".to_string(), "build".to_string()];
let mut features = Features::with_defaults();
@@ -551,13 +639,13 @@ prefix_rule(pattern=["rm"], decision="forbidden")
requirement,
ExecApprovalRequirement::NeedsApproval {
reason: None,
allow_prefix: None,
proposed_execpolicy_amendment: None,
}
);
}
#[tokio::test]
async fn allow_prefix_is_omitted_when_policy_prompts() {
async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() {
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
let mut parser = PolicyParser::new();
parser
@@ -580,13 +668,13 @@ prefix_rule(pattern=["rm"], decision="forbidden")
requirement,
ExecApprovalRequirement::NeedsApproval {
reason: Some(PROMPT_REASON.to_string()),
allow_prefix: None,
proposed_execpolicy_amendment: None,
}
);
}
#[tokio::test]
async fn allow_prefix_is_omitted_for_multi_command_scripts() {
async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() {
let command = vec![
"bash".to_string(),
"-lc".to_string(),
@@ -606,7 +694,44 @@ prefix_rule(pattern=["rm"], decision="forbidden")
requirement,
ExecApprovalRequirement::NeedsApproval {
reason: None,
allow_prefix: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"cargo".to_string(),
"build".to_string()
])),
}
);
}
#[tokio::test]
async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scripts() {
let policy_src = r#"prefix_rule(pattern=["cat"], decision="allow")"#;
let mut parser = PolicyParser::new();
parser
.parse("test.codexpolicy", policy_src)
.expect("parse policy");
let policy = Arc::new(RwLock::new(parser.build()));
let command = vec![
"bash".to_string(),
"-lc".to_string(),
"cat && apple".to_string(),
];
assert_eq!(
create_exec_approval_requirement_for_command(
&policy,
&Features::with_defaults(),
&command,
AskForApproval::UnlessTrusted,
&SandboxPolicy::ReadOnly,
SandboxPermissions::UseDefault,
)
.await,
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"apple".to_string()
])),
}
);
}

View File

@@ -95,7 +95,7 @@ impl ToolOrchestrator {
return Err(ToolError::Rejected("rejected by user".to_string()));
}
ReviewDecision::Approved
| ReviewDecision::ApprovedAllowPrefix { .. }
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {}
}
already_approved = true;
@@ -178,7 +178,7 @@ impl ToolOrchestrator {
return Err(ToolError::Rejected("rejected by user".to_string()));
}
ReviewDecision::Approved
| ReviewDecision::ApprovedAllowPrefix { .. }
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {}
}
}

View File

@@ -114,7 +114,9 @@ impl Approvable<ShellRequest> for ShellRuntime {
cwd,
reason,
risk,
req.exec_approval_requirement.allow_prefix().cloned(),
req.exec_approval_requirement
.proposed_execpolicy_amendment()
.cloned(),
)
.await
})

View File

@@ -132,7 +132,9 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
cwd,
reason,
risk,
req.exec_approval_requirement.allow_prefix().cloned(),
req.exec_approval_requirement
.proposed_execpolicy_amendment()
.cloned(),
)
.await
})

View File

@@ -13,6 +13,7 @@ use crate::sandboxing::CommandSpec;
use crate::sandboxing::SandboxManager;
use crate::sandboxing::SandboxTransformError;
use crate::state::SessionServices;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use std::collections::HashMap;
@@ -98,18 +99,19 @@ pub(crate) enum ExecApprovalRequirement {
/// Approval required for this tool call.
NeedsApproval {
reason: Option<String>,
/// Prefix that can be whitelisted via execpolicy to skip future approvals for similar commands
allow_prefix: Option<Vec<String>>,
/// Proposed execpolicy amendment to skip future approvals for similar commands
/// See core/src/exec_policy.rs for more details on how proposed_execpolicy_amendment is determined.
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
},
/// Execution forbidden for this tool call.
Forbidden { reason: String },
}
impl ExecApprovalRequirement {
pub fn allow_prefix(&self) -> Option<&Vec<String>> {
pub fn proposed_execpolicy_amendment(&self) -> Option<&ExecPolicyAmendment> {
match self {
Self::NeedsApproval {
allow_prefix: Some(prefix),
proposed_execpolicy_amendment: Some(prefix),
..
} => Some(prefix),
_ => None,
@@ -133,7 +135,7 @@ pub(crate) fn default_exec_approval_requirement(
if needs_approval {
ExecApprovalRequirement::NeedsApproval {
reason: None,
allow_prefix: None,
proposed_execpolicy_amendment: None,
}
} else {
ExecApprovalRequirement::Skip {

View File

@@ -6,6 +6,7 @@ use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecPolicyAmendment;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::config_types::ReasoningSummary;
@@ -1560,7 +1561,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
#[tokio::test(flavor = "current_thread")]
#[cfg(unix)]
async fn approving_allow_prefix_persists_policy_and_skips_future_prompts() -> Result<()> {
async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts() -> Result<()> {
let server = start_mock_server().await;
let approval_policy = AskForApproval::UnlessTrusted;
let sandbox_policy = SandboxPolicy::ReadOnly;
@@ -1580,8 +1581,9 @@ async fn approving_allow_prefix_persists_policy_and_skips_future_prompts() -> Re
.prepare(&test, &server, call_id_first, false)
.await?;
let expected_command =
expected_command.expect("allow prefix scenario should produce a shell command");
let expected_allow_prefix = vec!["touch".to_string(), "allow-prefix.txt".to_string()];
expected_command.expect("execpolicy amendment scenario should produce a shell command");
let expected_execpolicy_amendment =
ExecPolicyAmendment::new(vec!["touch".to_string(), "allow-prefix.txt".to_string()]);
let _ = mount_sse_once(
&server,
@@ -1610,13 +1612,16 @@ async fn approving_allow_prefix_persists_policy_and_skips_future_prompts() -> Re
.await?;
let approval = expect_exec_approval(&test, expected_command.as_str()).await;
assert_eq!(approval.allow_prefix, Some(expected_allow_prefix.clone()));
assert_eq!(
approval.proposed_execpolicy_amendment,
Some(expected_execpolicy_amendment.clone())
);
test.codex
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::ApprovedAllowPrefix {
allow_prefix: expected_allow_prefix.clone(),
decision: ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: expected_execpolicy_amendment.clone(),
},
})
.await?;

View File

@@ -30,32 +30,24 @@ codex execpolicy check --policy path/to/policy.codexpolicy git status
cargo run -p codex-execpolicy -- check --policy path/to/policy.codexpolicy git status
```
- Example outcomes:
- Match: `{"match": { ... "decision": "allow" ... }}`
- No match: `{"noMatch": {}}`
- Match: `{"matchedRules":[{...}],"decision":"allow"}`
- No match: `{"matchedRules":[]}`
## Response shapes
- Match:
## Response shape
```json
{
"match": {
"decision": "allow|prompt|forbidden",
"matchedRules": [
{
"prefixRuleMatch": {
"matchedPrefix": ["<token>", "..."],
"decision": "allow|prompt|forbidden"
}
"matchedRules": [
{
"prefixRuleMatch": {
"matchedPrefix": ["<token>", "..."],
"decision": "allow|prompt|forbidden"
}
]
}
}
],
"decision": "allow|prompt|forbidden"
}
```
- No match:
```json
{"noMatch": {}}
```
- When no rules match, `matchedRules` is an empty array and `decision` is omitted.
- `matchedRules` lists every rule whose prefix matched the command; `matchedPrefix` is the exact prefix that matched.
- The effective `decision` is the strictest severity across all matches (`forbidden` > `prompt` > `allow`).

View File

@@ -4,10 +4,12 @@ use std::path::PathBuf;
use anyhow::Context;
use anyhow::Result;
use clap::Parser;
use serde::Serialize;
use crate::Evaluation;
use crate::Decision;
use crate::Policy;
use crate::PolicyParser;
use crate::RuleMatch;
/// Arguments for evaluating a command against one or more execpolicy files.
#[derive(Debug, Parser, Clone)]
@@ -34,20 +36,25 @@ impl ExecPolicyCheckCommand {
/// Load the policies for this command, evaluate the command, and render JSON output.
pub fn run(&self) -> Result<()> {
let policy = load_policies(&self.policies)?;
let evaluation = policy.check(&self.command);
let matched_rules = policy.matches_for_command(&self.command, None);
let json = format_evaluation_json(&evaluation, self.pretty)?;
let json = format_matches_json(&matched_rules, self.pretty)?;
println!("{json}");
Ok(())
}
}
pub fn format_evaluation_json(evaluation: &Evaluation, pretty: bool) -> Result<String> {
pub fn format_matches_json(matched_rules: &[RuleMatch], pretty: bool) -> Result<String> {
let output = ExecPolicyCheckOutput {
matched_rules,
decision: matched_rules.iter().map(RuleMatch::decision).max(),
};
if pretty {
serde_json::to_string_pretty(evaluation).map_err(Into::into)
serde_json::to_string_pretty(&output).map_err(Into::into)
} else {
serde_json::to_string(evaluation).map_err(Into::into)
serde_json::to_string(&output).map_err(Into::into)
}
}
@@ -65,3 +72,12 @@ pub fn load_policies(policy_paths: &[PathBuf]) -> Result<Policy> {
Ok(parser.build())
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ExecPolicyCheckOutput<'a> {
#[serde(rename = "matchedRules")]
matched_rules: &'a [RuleMatch],
#[serde(skip_serializing_if = "Option::is_none")]
decision: Option<Decision>,
}

View File

@@ -1,6 +1,6 @@
use anyhow::Result;
use clap::Parser;
use codex_execpolicy::ExecPolicyCheckCommand;
use codex_execpolicy::execpolicycheck::ExecPolicyCheckCommand;
/// CLI for evaluating exec policies
#[derive(Parser)]
@@ -13,10 +13,6 @@ enum Cli {
fn main() -> Result<()> {
let cli = Cli::parse();
match cli {
Cli::Check(cmd) => cmd_check(cmd),
Cli::Check(cmd) => cmd.run(),
}
}
fn cmd_check(cmd: ExecPolicyCheckCommand) -> Result<()> {
cmd.run()
}

View File

@@ -11,6 +11,8 @@ use serde::Deserialize;
use serde::Serialize;
use std::sync::Arc;
type HeuristicsFallback<'a> = Option<&'a dyn Fn(&[String]) -> Decision>;
#[derive(Clone, Debug)]
pub struct Policy {
rules_by_program: MultiMap<String, RuleRef>,
@@ -50,62 +52,84 @@ impl Policy {
Ok(())
}
pub fn check(&self, cmd: &[String]) -> Evaluation {
let rules = match cmd.first() {
Some(first) => match self.rules_by_program.get_vec(first) {
Some(rules) => rules,
None => return Evaluation::NoMatch {},
},
None => return Evaluation::NoMatch {},
};
let matched_rules: Vec<RuleMatch> =
rules.iter().filter_map(|rule| rule.matches(cmd)).collect();
match matched_rules.iter().map(RuleMatch::decision).max() {
Some(decision) => Evaluation::Match {
decision,
matched_rules,
},
None => Evaluation::NoMatch {},
}
pub fn check<F>(&self, cmd: &[String], heuristics_fallback: &F) -> Evaluation
where
F: Fn(&[String]) -> Decision,
{
let matched_rules = self.matches_for_command(cmd, Some(heuristics_fallback));
Evaluation::from_matches(matched_rules)
}
pub fn check_multiple<Commands>(&self, commands: Commands) -> Evaluation
pub fn check_multiple<Commands, F>(
&self,
commands: Commands,
heuristics_fallback: &F,
) -> Evaluation
where
Commands: IntoIterator,
Commands::Item: AsRef<[String]>,
F: Fn(&[String]) -> Decision,
{
let matched_rules: Vec<RuleMatch> = commands
.into_iter()
.flat_map(|command| match self.check(command.as_ref()) {
Evaluation::Match { matched_rules, .. } => matched_rules,
Evaluation::NoMatch { .. } => Vec::new(),
.flat_map(|command| {
self.matches_for_command(command.as_ref(), Some(heuristics_fallback))
})
.collect();
match matched_rules.iter().map(RuleMatch::decision).max() {
Some(decision) => Evaluation::Match {
decision,
matched_rules,
},
None => Evaluation::NoMatch {},
Evaluation::from_matches(matched_rules)
}
pub fn matches_for_command(
&self,
cmd: &[String],
heuristics_fallback: HeuristicsFallback<'_>,
) -> Vec<RuleMatch> {
let mut matched_rules: Vec<RuleMatch> = match cmd.first() {
Some(first) => self
.rules_by_program
.get_vec(first)
.map(|rules| rules.iter().filter_map(|rule| rule.matches(cmd)).collect())
.unwrap_or_default(),
None => Vec::new(),
};
if let (true, Some(heuristics_fallback)) = (matched_rules.is_empty(), heuristics_fallback) {
matched_rules.push(RuleMatch::HeuristicsRuleMatch {
command: cmd.to_vec(),
decision: heuristics_fallback(cmd),
});
}
matched_rules
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Evaluation {
NoMatch {},
Match {
decision: Decision,
#[serde(rename = "matchedRules")]
matched_rules: Vec<RuleMatch>,
},
pub struct Evaluation {
pub decision: Decision,
#[serde(rename = "matchedRules")]
pub matched_rules: Vec<RuleMatch>,
}
impl Evaluation {
pub fn is_match(&self) -> bool {
matches!(self, Self::Match { .. })
self.matched_rules
.iter()
.any(|rule_match| !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }))
}
fn from_matches(matched_rules: Vec<RuleMatch>) -> Self {
let decision = matched_rules
.iter()
.map(RuleMatch::decision)
.max()
.unwrap_or(Decision::Allow);
Self {
decision,
matched_rules,
}
}
}

View File

@@ -64,12 +64,17 @@ pub enum RuleMatch {
matched_prefix: Vec<String>,
decision: Decision,
},
HeuristicsRuleMatch {
command: Vec<String>,
decision: Decision,
},
}
impl RuleMatch {
pub fn decision(&self) -> Decision {
match self {
Self::PrefixRuleMatch { decision, .. } => *decision,
Self::HeuristicsRuleMatch { decision, .. } => *decision,
}
}
}

View File

@@ -19,6 +19,14 @@ fn tokens(cmd: &[&str]) -> Vec<String> {
cmd.iter().map(std::string::ToString::to_string).collect()
}
fn allow_all(_: &[String]) -> Decision {
Decision::Allow
}
fn prompt_all(_: &[String]) -> Decision {
Decision::Prompt
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum RuleSnapshot {
Prefix(PrefixRule),
@@ -49,9 +57,9 @@ prefix_rule(
parser.parse("test.codexpolicy", policy_src)?;
let policy = parser.build();
let cmd = tokens(&["git", "status"]);
let evaluation = policy.check(&cmd);
let evaluation = policy.check(&cmd, &allow_all);
assert_eq!(
Evaluation::Match {
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "status"]),
@@ -80,9 +88,9 @@ fn add_prefix_rule_extends_policy() -> Result<()> {
rules
);
let evaluation = policy.check(&tokens(&["ls", "-l", "/tmp"]));
let evaluation = policy.check(&tokens(&["ls", "-l", "/tmp"]), &allow_all);
assert_eq!(
Evaluation::Match {
Evaluation {
decision: Decision::Prompt,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["ls", "-l"]),
@@ -146,9 +154,9 @@ prefix_rule(
git_rules
);
let status_eval = policy.check(&tokens(&["git", "status"]));
let status_eval = policy.check(&tokens(&["git", "status"]), &allow_all);
assert_eq!(
Evaluation::Match {
Evaluation {
decision: Decision::Prompt,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
@@ -158,9 +166,9 @@ prefix_rule(
status_eval
);
let commit_eval = policy.check(&tokens(&["git", "commit", "-m", "hi"]));
let commit_eval = policy.check(&tokens(&["git", "commit", "-m", "hi"]), &allow_all);
assert_eq!(
Evaluation::Match {
Evaluation {
decision: Decision::Forbidden,
matched_rules: vec![
RuleMatch::PrefixRuleMatch {
@@ -217,9 +225,9 @@ prefix_rule(
sh_rules
);
let bash_eval = policy.check(&tokens(&["bash", "-c", "echo", "hi"]));
let bash_eval = policy.check(&tokens(&["bash", "-c", "echo", "hi"]), &allow_all);
assert_eq!(
Evaluation::Match {
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["bash", "-c"]),
@@ -229,9 +237,9 @@ prefix_rule(
bash_eval
);
let sh_eval = policy.check(&tokens(&["sh", "-l", "echo", "hi"]));
let sh_eval = policy.check(&tokens(&["sh", "-l", "echo", "hi"]), &allow_all);
assert_eq!(
Evaluation::Match {
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["sh", "-l"]),
@@ -273,9 +281,9 @@ prefix_rule(
rules
);
let npm_i = policy.check(&tokens(&["npm", "i", "--legacy-peer-deps"]));
let npm_i = policy.check(&tokens(&["npm", "i", "--legacy-peer-deps"]), &allow_all);
assert_eq!(
Evaluation::Match {
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["npm", "i", "--legacy-peer-deps"]),
@@ -285,9 +293,12 @@ prefix_rule(
npm_i
);
let npm_install = policy.check(&tokens(&["npm", "install", "--no-save", "leftpad"]));
let npm_install = policy.check(
&tokens(&["npm", "install", "--no-save", "leftpad"]),
&allow_all,
);
assert_eq!(
Evaluation::Match {
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["npm", "install", "--no-save"]),
@@ -314,9 +325,9 @@ prefix_rule(
let mut parser = PolicyParser::new();
parser.parse("test.codexpolicy", policy_src)?;
let policy = parser.build();
let match_eval = policy.check(&tokens(&["git", "status"]));
let match_eval = policy.check(&tokens(&["git", "status"]), &allow_all);
assert_eq!(
Evaluation::Match {
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "status"]),
@@ -326,13 +337,20 @@ prefix_rule(
match_eval
);
let no_match_eval = policy.check(&tokens(&[
"git",
"--config",
"color.status=always",
"status",
]));
assert_eq!(Evaluation::NoMatch {}, no_match_eval);
let no_match_eval = policy.check(
&tokens(&["git", "--config", "color.status=always", "status"]),
&allow_all,
);
assert_eq!(
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
command: tokens(&["git", "--config", "color.status=always", "status",]),
decision: Decision::Allow,
}],
},
no_match_eval
);
Ok(())
}
@@ -352,9 +370,9 @@ prefix_rule(
parser.parse("test.codexpolicy", policy_src)?;
let policy = parser.build();
let commit = policy.check(&tokens(&["git", "commit", "-m", "hi"]));
let commit = policy.check(&tokens(&["git", "commit", "-m", "hi"]), &allow_all);
assert_eq!(
Evaluation::Match {
Evaluation {
decision: Decision::Forbidden,
matched_rules: vec![
RuleMatch::PrefixRuleMatch {
@@ -393,9 +411,9 @@ prefix_rule(
tokens(&["git", "commit", "-m", "hi"]),
];
let evaluation = policy.check_multiple(&commands);
let evaluation = policy.check_multiple(&commands, &allow_all);
assert_eq!(
Evaluation::Match {
Evaluation {
decision: Decision::Forbidden,
matched_rules: vec![
RuleMatch::PrefixRuleMatch {
@@ -416,3 +434,21 @@ prefix_rule(
);
Ok(())
}
#[test]
fn heuristics_match_is_returned_when_no_policy_matches() {
let policy = Policy::empty();
let command = tokens(&["python"]);
let evaluation = policy.check(&command, &prompt_all);
assert_eq!(
Evaluation {
decision: Decision::Prompt,
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
command,
decision: Decision::Prompt,
}],
},
evaluation
);
}

View File

@@ -180,7 +180,7 @@ async fn run_codex_tool_session_inner(
call_id,
reason: _,
risk,
allow_prefix: _,
proposed_execpolicy_amendment: _,
parsed_cmd,
}) => {
handle_exec_approval_request(

View File

@@ -17,6 +17,34 @@ pub enum SandboxRiskLevel {
High,
}
/// Proposed execpolicy change to allow commands starting with this prefix.
///
/// The `command` tokens form the prefix that would be added as an execpolicy
/// `prefix_rule(..., decision="allow")`, letting the agent bypass approval for
/// commands that start with this token sequence.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(transparent)]
#[ts(type = "Array<string>")]
pub struct ExecPolicyAmendment {
pub command: Vec<String>,
}
impl ExecPolicyAmendment {
pub fn new(command: Vec<String>) -> Self {
Self { command }
}
pub fn command(&self) -> &[String] {
&self.command
}
}
impl From<Vec<String>> for ExecPolicyAmendment {
fn from(command: Vec<String>) -> Self {
Self { command }
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct SandboxCommandAssessment {
pub description: String,
@@ -51,10 +79,10 @@ 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.
/// Proposed execpolicy amendment that can be applied to allow future runs.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional, type = "Array<string>")]
pub allow_prefix: Option<Vec<String>>,
#[ts(optional)]
pub proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
pub parsed_cmd: Vec<ParsedCommand>,
}

View File

@@ -39,6 +39,7 @@ use ts_rs::TS;
pub use crate::approvals::ApplyPatchApprovalRequestEvent;
pub use crate::approvals::ElicitationAction;
pub use crate::approvals::ExecApprovalRequestEvent;
pub use crate::approvals::ExecPolicyAmendment;
pub use crate::approvals::SandboxCommandAssessment;
pub use crate::approvals::SandboxRiskLevel;
@@ -1655,9 +1656,11 @@ pub enum ReviewDecision {
/// User has approved this command and the agent should execute it.
Approved,
/// User has approved this command and wants to add the command prefix to
/// the execpolicy allow list so future matching commands are permitted.
ApprovedAllowPrefix { allow_prefix: Vec<String> },
/// User has approved this command and wants to apply the proposed execpolicy
/// amendment so future matching commands are permitted.
ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: ExecPolicyAmendment,
},
/// User has approved this command and wants to automatically approve any
/// future identical instances (`command` and `cwd` match exactly) for the

View File

@@ -17,6 +17,7 @@ use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use codex_core::protocol::ElicitationAction;
use codex_core::protocol::ExecPolicyAmendment;
use codex_core::protocol::FileChange;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
@@ -43,7 +44,7 @@ pub(crate) enum ApprovalRequest {
command: Vec<String>,
reason: Option<String>,
risk: Option<SandboxCommandAssessment>,
allow_prefix: Option<Vec<String>>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
},
ApplyPatch {
id: String,
@@ -105,8 +106,11 @@ impl ApprovalOverlay {
header: Box<dyn Renderable>,
) -> (Vec<ApprovalOption>, SelectionViewParams) {
let (options, title) = match &variant {
ApprovalVariant::Exec { allow_prefix, .. } => (
exec_options(allow_prefix.clone()),
ApprovalVariant::Exec {
proposed_execpolicy_amendment,
..
} => (
exec_options(proposed_execpolicy_amendment.clone()),
"Would you like to run the following command?".to_string(),
),
ApprovalVariant::ApplyPatch { .. } => (
@@ -337,7 +341,7 @@ impl From<ApprovalRequest> for ApprovalRequestState {
command,
reason,
risk,
allow_prefix,
proposed_execpolicy_amendment,
} => {
let reason = reason.filter(|item| !item.is_empty());
let has_reason = reason.is_some();
@@ -360,7 +364,7 @@ impl From<ApprovalRequest> for ApprovalRequestState {
variant: ApprovalVariant::Exec {
id,
command,
allow_prefix,
proposed_execpolicy_amendment,
},
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
}
@@ -437,7 +441,7 @@ enum ApprovalVariant {
Exec {
id: String,
command: Vec<String>,
allow_prefix: Option<Vec<String>>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
},
ApplyPatch {
id: String,
@@ -470,7 +474,7 @@ impl ApprovalOption {
}
}
fn exec_options(allow_prefix: Option<Vec<String>>) -> Vec<ApprovalOption> {
fn exec_options(proposed_execpolicy_amendment: Option<ExecPolicyAmendment>) -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Yes, proceed".to_string(),
@@ -486,10 +490,10 @@ fn exec_options(allow_prefix: Option<Vec<String>>) -> Vec<ApprovalOption> {
},
]
.into_iter()
.chain(allow_prefix.map(|prefix| ApprovalOption {
.chain(proposed_execpolicy_amendment.map(|prefix| ApprovalOption {
label: "Yes, and don't ask again for commands with this prefix".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::ApprovedAllowPrefix {
allow_prefix: prefix,
decision: ApprovalDecision::Review(ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: prefix,
}),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))],
@@ -556,7 +560,7 @@ mod tests {
command: vec!["echo".to_string(), "hi".to_string()],
reason: Some("reason".to_string()),
risk: None,
allow_prefix: None,
proposed_execpolicy_amendment: None,
}
}
@@ -590,7 +594,7 @@ mod tests {
}
#[test]
fn exec_prefix_option_emits_allow_prefix() {
fn exec_prefix_option_emits_execpolicy_amendment() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = ApprovalOverlay::new(
@@ -599,7 +603,9 @@ mod tests {
command: vec!["echo".to_string()],
reason: None,
risk: None,
allow_prefix: Some(vec!["echo".to_string()]),
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"echo".to_string(),
])),
},
tx,
);
@@ -609,8 +615,10 @@ mod tests {
if let AppEvent::CodexOp(Op::ExecApproval { decision, .. }) = ev {
assert_eq!(
decision,
ReviewDecision::ApprovedAllowPrefix {
allow_prefix: vec!["echo".to_string()]
ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![
"echo".to_string()
])
}
);
saw_op = true;
@@ -619,7 +627,7 @@ mod tests {
}
assert!(
saw_op,
"expected approval decision to emit an op with allow prefix"
"expected approval decision to emit an op with command prefix"
);
}
@@ -633,7 +641,7 @@ mod tests {
command,
reason: None,
risk: None,
allow_prefix: None,
proposed_execpolicy_amendment: None,
};
let view = ApprovalOverlay::new(exec_request, tx);

View File

@@ -570,7 +570,7 @@ mod tests {
command: vec!["echo".into(), "ok".into()],
reason: None,
risk: None,
allow_prefix: None,
proposed_execpolicy_amendment: None,
}
}

View File

@@ -1074,7 +1074,7 @@ impl ChatWidget {
command: ev.command,
reason: ev.reason,
risk: ev.risk,
allow_prefix: ev.allow_prefix,
proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment,
};
self.bottom_pane.push_approval_request(request);
self.request_redraw();

View File

@@ -23,6 +23,7 @@ use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::ExecCommandSource;
use codex_core::protocol::ExecPolicyAmendment;
use codex_core::protocol::ExitedReviewModeEvent;
use codex_core::protocol::FileChange;
use codex_core::protocol::Op;
@@ -688,7 +689,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,
proposed_execpolicy_amendment: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -733,7 +734,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,
proposed_execpolicy_amendment: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -784,7 +785,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,
proposed_execpolicy_amendment: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -1993,7 +1994,11 @@ 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()]),
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"echo".into(),
"hello".into(),
"world".into(),
])),
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -2040,7 +2045,11 @@ fn approval_modal_exec_without_reason_snapshot() {
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
risk: None,
allow_prefix: Some(vec!["echo".into(), "hello".into(), "world".into()]),
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"echo".into(),
"hello".into(),
"world".into(),
])),
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -2254,7 +2263,10 @@ 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: Some(vec!["echo".into(), "hello world".into()]),
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
"echo".into(),
"hello world".into(),
])),
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {

View File

@@ -409,7 +409,7 @@ pub fn new_approval_decision_cell(
],
)
}
ApprovedAllowPrefix { .. } => {
ApprovedExecpolicyAmendment { .. } => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".green(),
@@ -418,7 +418,7 @@ pub fn new_approval_decision_cell(
"approved".bold(),
" codex to run ".into(),
snippet,
" and added its prefix to your allow list".bold(),
" and applied the execpolicy amendment".bold(),
],
)
}

View File

@@ -603,7 +603,7 @@ metadata above):
- `codex.tool_decision`
- `tool_name`
- `call_id`
- `decision` (`approved`, `approved_allow_prefix`, `approved_for_session`, `denied`, or `abort`)
- `decision` (`approved`, `approved_execpolicy_amendment`, `approved_for_session`, `denied`, or `abort`)
- `source` (`config` or `user`)
- `codex.tool_result`
- `tool_name`

View File

@@ -33,6 +33,30 @@ codex execpolicy check --policy ~/.codex/policy/default.codexpolicy git push ori
Pass multiple `--policy` flags to test how several files combine, and use `--pretty` for formatted JSON output. See the [`codex-rs/execpolicy` README](../codex-rs/execpolicy/README.md) for a more detailed walkthrough of the available syntax.
Example output when a rule matches:
```json
{
"matchedRules": [
{
"prefixRuleMatch": {
"matchedPrefix": ["git", "push"],
"decision": "prompt"
}
}
],
"decision": "prompt"
}
```
When no rules match, `matchedRules` is an empty array and `decision` is omitted.
```json
{
"matchedRules": []
}
```
## Status
`execpolicy` commands are still in preview. The API may have breaking changes in the future.