Compare commits

...

25 Commits

Author SHA1 Message Date
kevin zhao
0924389ba7 fix rebase 2025-12-03 18:15:53 +00:00
kevin zhao
15eea1cbce fix oc 2025-12-03 17:59:35 +00:00
kevin zhao
7018860a0e simplifying types 2025-12-03 17:59:35 +00:00
kevin zhao
8a89f864b9 fixing execpolicy.md 2025-12-03 17:59:35 +00:00
kevin zhao
1187cac306 update cli with new behavior 2025-12-03 17:59:35 +00:00
kevin zhao
426772d49c fmt exec_policy imports 2025-12-03 17:59:35 +00:00
kevin zhao
7900e56ed4 fix rebase 2025-12-03 17:59:35 +00:00
kevin zhao
ac58739165 fix test 2025-12-03 17:59:35 +00:00
kevin zhao
a15a8ca265 remove unecessary import 2025-12-03 17:59:35 +00:00
kevin zhao
c09c192d64 simplify approval overlay logic 2025-12-03 17:59:35 +00:00
kevin zhao
919f082930 delete weird test 2025-12-03 17:59:35 +00:00
kevin zhao
d50cb24c50 inlining and cleanup tests 2025-12-03 17:59:35 +00:00
kevin zhao
97baebef2a inlining 2025-12-03 17:57:40 +00:00
kevin zhao
c26056924e update messaging 2025-12-03 17:57:40 +00:00
kevin zhao
7200d7fca3 cleanup forbidden messaging 2025-12-03 17:57:40 +00:00
kevin zhao
572848d9f0 delete useless comment 2025-12-03 17:57:40 +00:00
kevin zhao
1423d5f6bb improve messaging 2025-12-03 17:57:40 +00:00
kevin zhao
821d9148dd feat: integrating heuristics-based fallback in execpolicy 2025-12-03 17:57:40 +00:00
kevin zhao
109dc3a178 improved behavior for dont ask again for this prefix 2025-12-03 17:55:30 +00:00
kevin zhao
b492d9369e refactor: adding allow_prefix into ApprovedAllowPrefix 2025-12-03 17:48:53 +00:00
kevin zhao
6eb4568dd4 clippy 2025-12-03 17:46:54 +00:00
kevin zhao
14a4911187 using RW locks 2025-12-03 17:46:28 +00:00
kevin zhao
6e68870567 mutating in memory policy instead of reloading 2025-12-03 17:44:29 +00:00
kevin zhao
fe742b89fd Add approval allow-prefix flow in core and tui 2025-12-03 17:38:57 +00:00
kevin zhao
b6dc1be5dd 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-02 22:01:35 -05:00
35 changed files with 1048 additions and 262 deletions

View File

@@ -179,6 +179,7 @@ pub(crate) async fn apply_bespoke_event_handling(
cwd,
reason,
risk,
allow_prefix: _allow_prefix,
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

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

View File

@@ -71,6 +71,7 @@ use crate::error::CodexErr;
use crate::error::Result as CodexResult;
#[cfg(test)]
use crate::exec::StreamOutput;
use crate::exec_policy::ExecPolicyUpdateError;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::model_family::find_family_for_model;
@@ -288,7 +289,7 @@ pub(crate) struct TurnContext {
pub(crate) final_output_json_schema: Option<Value>,
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) tool_call_gate: Arc<ReadinessFlag>,
pub(crate) exec_policy: Arc<ExecPolicy>,
pub(crate) exec_policy: Arc<RwLock<ExecPolicy>>,
pub(crate) truncation_policy: TruncationPolicy,
}
@@ -346,7 +347,7 @@ pub(crate) struct SessionConfiguration {
/// Set of feature flags for this session
features: Features,
/// Execpolicy policy, applied only when enabled by feature flag.
exec_policy: Arc<ExecPolicy>,
exec_policy: Arc<RwLock<ExecPolicy>>,
// TODO(pakrym): Remove config from here
original_config_do_not_use: Arc<Config>,
@@ -861,11 +862,44 @@ impl Session {
.await
}
pub(crate) async fn persist_command_allow_prefix(
&self,
prefix: &[String],
) -> Result<(), ExecPolicyUpdateError> {
let (features, codex_home, current_policy) = {
let state = self.state.lock().await;
(
state.session_configuration.features.clone(),
state
.session_configuration
.original_config_do_not_use
.codex_home
.clone(),
state.session_configuration.exec_policy.clone(),
)
};
if !features.enabled(Feature::ExecPolicy) {
error!("attempted to append execpolicy rule while execpolicy feature is disabled");
return Err(ExecPolicyUpdateError::FeatureDisabled);
}
crate::exec_policy::append_allow_prefix_rule_and_update(
&codex_home,
&current_policy,
prefix,
)
.await?;
Ok(())
}
/// 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
/// to the correct in-flight turn. If the task is aborted, this returns the
/// default `ReviewDecision` (`Denied`).
#[allow(clippy::too_many_arguments)]
pub async fn request_command_approval(
&self,
turn_context: &TurnContext,
@@ -874,6 +908,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.
@@ -901,6 +936,7 @@ impl Session {
cwd,
reason,
risk,
allow_prefix,
parsed_cmd,
});
self.send_event(turn_context, event).await;
@@ -1075,6 +1111,15 @@ impl Session {
.enabled(feature)
}
pub(crate) async fn features(&self) -> Features {
self.state
.lock()
.await
.session_configuration
.features
.clone()
}
async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) {
for item in items {
self.send_event(
@@ -1508,6 +1553,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 codex_rmcp_client::ElicitationAction;
@@ -1622,7 +1668,21 @@ mod handlers {
}
}
/// Propagate a user's exec approval decision to the session
/// Also optionally whitelists command in execpolicy
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
{
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;
@@ -2578,7 +2638,7 @@ mod tests {
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),
features: Features::default(),
exec_policy: Arc::new(ExecPolicy::empty()),
exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())),
session_source: SessionSource::Exec,
};
@@ -2777,7 +2837,7 @@ mod tests {
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),
features: Features::default(),
exec_policy: Arc::new(ExecPolicy::empty()),
exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())),
session_source: SessionSource::Exec,
};
@@ -2855,7 +2915,7 @@ mod tests {
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),
features: Features::default(),
exec_policy: Arc::new(ExecPolicy::empty()),
exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())),
session_source: SessionSource::Exec,
};

View File

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

View File

@@ -4,14 +4,19 @@ 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::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::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use thiserror::Error;
use tokio::fs;
use tokio::sync::RwLock;
use crate::bash::parse_shell_lc_plain_commands;
use crate::features::Feature;
@@ -20,9 +25,12 @@ use crate::sandboxing::SandboxPermissions;
use crate::tools::sandboxing::ApprovalRequirement;
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";
const DEFAULT_POLICY_FILE: &str = "default.codexpolicy";
#[derive(Debug, Error)]
pub enum ExecPolicyError {
@@ -45,12 +53,27 @@ 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 update in-memory execpolicy: {source}")]
AddRule {
#[from]
source: ExecPolicyRuleError,
},
#[error("cannot append execpolicy rule because execpolicy feature is disabled")]
FeatureDisabled,
}
pub(crate) async fn exec_policy_for(
features: &Features,
codex_home: &Path,
) -> Result<Arc<Policy>, ExecPolicyError> {
) -> Result<Arc<RwLock<Policy>>, ExecPolicyError> {
if !features.enabled(Feature::ExecPolicy) {
return Ok(Arc::new(Policy::empty()));
return Ok(Arc::new(RwLock::new(Policy::empty())));
}
let policy_dir = codex_home.join(POLICY_DIR_NAME);
@@ -74,7 +97,7 @@ pub(crate) async fn exec_policy_for(
})?;
}
let policy = Arc::new(parser.build());
let policy = Arc::new(RwLock::new(parser.build()));
tracing::debug!(
"loaded execpolicy from {} files in {}",
policy_paths.len(),
@@ -84,62 +107,118 @@ pub(crate) async fn exec_policy_for(
Ok(policy)
}
fn evaluate_with_policy(
policy: &Policy,
command: &[String],
approval_policy: AskForApproval,
) -> Option<ApprovalRequirement> {
let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]);
let evaluation = policy.check_multiple(commands.iter());
pub(crate) fn default_policy_path(codex_home: &Path) -> PathBuf {
codex_home.join(POLICY_DIR_NAME).join(DEFAULT_POLICY_FILE)
}
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),
})
pub(crate) async fn append_allow_prefix_rule_and_update(
codex_home: &Path,
current_policy: &Arc<RwLock<Policy>>,
prefix: &[String],
) -> Result<(), ExecPolicyUpdateError> {
let policy_path = default_policy_path(codex_home);
blocking_append_allow_prefix_rule(&policy_path, prefix).map_err(|source| {
ExecPolicyUpdateError::AppendRule {
path: policy_path,
source,
}
})?;
current_policy
.write()
.await
.add_prefix_rule(prefix, Decision::Allow)?;
Ok(())
}
/// Return an allow-prefix option when a command needs approval and execpolicy did not drive the decision
fn allow_prefix_if_applicable(evaluation: &Evaluation, features: &Features) -> Option<Vec<String>> {
if !features.enabled(Feature::ExecPolicy) {
return None;
}
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());
}
}
Decision::Allow => Some(ApprovalRequirement::Skip {
bypass_sandbox: true,
}),
},
Evaluation::NoMatch { .. } => None,
_ if rule_match.decision() == Decision::Prompt => {
return None;
}
_ => {}
}
}
first_prompt_from_heuristics
}
pub(crate) async fn create_approval_requirement_for_command(
policy: &Policy,
exec_policy: &Arc<RwLock<Policy>>,
features: &Features,
command: &[String],
approval_policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
sandbox_permissions: SandboxPermissions,
) -> ApprovalRequirement {
if let Some(requirement) = evaluate_with_policy(policy, command, approval_policy) {
return requirement;
}
if requires_initial_appoval(
approval_policy,
sandbox_policy,
command,
sandbox_permissions,
) {
ApprovalRequirement::NeedsApproval { reason: None }
} else {
ApprovalRequirement::Skip {
bypass_sandbox: false,
let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]);
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.decision {
Decision::Forbidden => ApprovalRequirement::Forbidden {
reason: FORBIDDEN_REASON.to_string(),
},
Decision::Prompt => {
let prompt_reason = derive_prompt_reason(&evaluation);
if matches!(approval_policy, AskForApproval::Never) {
ApprovalRequirement::Forbidden {
reason: PROMPT_CONFLICT_REASON.to_string(),
}
} else {
ApprovalRequirement::NeedsApproval {
reason: prompt_reason,
allow_prefix: allow_prefix_if_applicable(&evaluation, features),
}
}
}
Decision::Allow => ApprovalRequirement::Skip {
bypass_sandbox: has_policy_allow,
},
}
}
/// 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
}
})
}
async fn collect_policy_files(dir: &Path) -> Result<Vec<PathBuf>, ExecPolicyError> {
let mut read_dir = match fs::read_dir(dir).await {
Ok(read_dir) => read_dir,
@@ -195,6 +274,7 @@ mod tests {
use codex_protocol::protocol::SandboxPolicy;
use pretty_assertions::assert_eq;
use std::fs;
use std::sync::Arc;
use tempfile::tempdir;
#[tokio::test]
@@ -208,10 +288,19 @@ mod tests {
.expect("policy result");
let commands = [vec!["rm".to_string()]];
assert!(matches!(
policy.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());
}
@@ -242,10 +331,19 @@ mod tests {
.await
.expect("policy result");
let command = [vec!["rm".to_string()]];
assert!(matches!(
policy.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]
@@ -261,14 +359,23 @@ mod tests {
.await
.expect("policy result");
let command = [vec!["ls".to_string()]];
assert!(matches!(
policy.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)
);
}
#[test]
fn evaluates_bash_lc_inner_commands() {
#[tokio::test]
async fn evaluates_bash_lc_inner_commands() {
let policy_src = r#"
prefix_rule(pattern=["rm"], decision="forbidden")
"#;
@@ -276,7 +383,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
parser
.parse("test.codexpolicy", policy_src)
.expect("parse policy");
let policy = parser.build();
let policy = Arc::new(RwLock::new(parser.build()));
let forbidden_script = vec![
"bash".to_string(),
@@ -284,9 +391,15 @@ prefix_rule(pattern=["rm"], decision="forbidden")
"rm -rf /tmp".to_string(),
];
let requirement =
evaluate_with_policy(&policy, &forbidden_script, AskForApproval::OnRequest)
.expect("expected match for forbidden command");
let requirement = create_approval_requirement_for_command(
&policy,
&Features::with_defaults(),
&forbidden_script,
AskForApproval::OnRequest,
&SandboxPolicy::DangerFullAccess,
SandboxPermissions::UseDefault,
)
.await;
assert_eq!(
requirement,
@@ -303,11 +416,12 @@ prefix_rule(pattern=["rm"], decision="forbidden")
parser
.parse("test.codexpolicy", policy_src)
.expect("parse policy");
let policy = parser.build();
let policy = Arc::new(RwLock::new(parser.build()));
let command = vec!["rm".to_string()];
let requirement = create_approval_requirement_for_command(
&policy,
&Features::with_defaults(),
&command,
AskForApproval::OnRequest,
&SandboxPolicy::DangerFullAccess,
@@ -318,7 +432,8 @@ prefix_rule(pattern=["rm"], decision="forbidden")
assert_eq!(
requirement,
ApprovalRequirement::NeedsApproval {
reason: Some(PROMPT_REASON.to_string())
reason: Some(PROMPT_REASON.to_string()),
allow_prefix: None,
}
);
}
@@ -330,11 +445,12 @@ prefix_rule(pattern=["rm"], decision="forbidden")
parser
.parse("test.codexpolicy", policy_src)
.expect("parse policy");
let policy = parser.build();
let policy = Arc::new(RwLock::new(parser.build()));
let command = vec!["rm".to_string()];
let requirement = create_approval_requirement_for_command(
&policy,
&Features::with_defaults(),
&command,
AskForApproval::Never,
&SandboxPolicy::DangerFullAccess,
@@ -345,7 +461,7 @@ prefix_rule(pattern=["rm"], decision="forbidden")
assert_eq!(
requirement,
ApprovalRequirement::Forbidden {
reason: PROMPT_REASON.to_string()
reason: PROMPT_CONFLICT_REASON.to_string()
}
);
}
@@ -354,9 +470,10 @@ prefix_rule(pattern=["rm"], decision="forbidden")
async fn approval_requirement_falls_back_to_heuristics() {
let command = vec!["python".to_string()];
let empty_policy = Policy::empty();
let empty_policy = Arc::new(RwLock::new(Policy::empty()));
let requirement = create_approval_requirement_for_command(
&empty_policy,
&Features::with_defaults(),
&command,
AskForApproval::UnlessTrusted,
&SandboxPolicy::ReadOnly,
@@ -366,7 +483,225 @@ prefix_rule(pattern=["rm"], decision="forbidden")
assert_eq!(
requirement,
ApprovalRequirement::NeedsApproval { reason: None }
ApprovalRequirement::NeedsApproval {
reason: None,
allow_prefix: Some(command)
}
);
}
#[tokio::test]
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_approval_requirement_for_command(
&policy,
&Features::with_defaults(),
&command,
AskForApproval::UnlessTrusted,
&SandboxPolicy::DangerFullAccess,
SandboxPermissions::UseDefault,
)
.await,
ApprovalRequirement::NeedsApproval {
reason: None,
allow_prefix: Some(vec!["orange".to_string()])
}
);
}
#[tokio::test]
async fn append_allow_prefix_rule_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)
.await
.expect("update policy");
let evaluation = current_policy.read().await.check(
&["echo".to_string(), "hello".to_string(), "world".to_string()],
&|_| Decision::Allow,
);
assert!(matches!(
evaluation,
Evaluation {
decision: Decision::Allow,
..
}
));
let contents = fs::read_to_string(default_policy_path(codex_home.path()))
.expect("policy file should have been created");
assert_eq!(
contents,
r#"prefix_rule(pattern=["echo", "hello"], decision="allow")
"#
);
}
#[tokio::test]
async fn append_allow_prefix_rule_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;
assert!(matches!(
result,
Err(ExecPolicyUpdateError::AppendRule {
source: AmendError::EmptyPrefix,
..
})
));
}
#[tokio::test]
async fn allow_prefix_is_present_for_single_command_without_policy_match() {
let command = vec!["python".to_string()];
let empty_policy = Arc::new(RwLock::new(Policy::empty()));
let requirement = create_approval_requirement_for_command(
&empty_policy,
&Features::with_defaults(),
&command,
AskForApproval::UnlessTrusted,
&SandboxPolicy::ReadOnly,
SandboxPermissions::UseDefault,
)
.await;
assert_eq!(
requirement,
ApprovalRequirement::NeedsApproval {
reason: None,
allow_prefix: Some(command)
}
);
}
#[tokio::test]
async fn allow_prefix_is_disabled_when_execpolicy_feature_disabled() {
let command = vec!["python".to_string()];
let mut features = Features::with_defaults();
features.disable(Feature::ExecPolicy);
let requirement = create_approval_requirement_for_command(
&Arc::new(RwLock::new(Policy::empty())),
&features,
&command,
AskForApproval::UnlessTrusted,
&SandboxPolicy::ReadOnly,
SandboxPermissions::UseDefault,
)
.await;
assert_eq!(
requirement,
ApprovalRequirement::NeedsApproval {
reason: None,
allow_prefix: None,
}
);
}
#[tokio::test]
async 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 = Arc::new(RwLock::new(parser.build()));
let command = vec!["rm".to_string()];
let requirement = create_approval_requirement_for_command(
&policy,
&Features::with_defaults(),
&command,
AskForApproval::OnRequest,
&SandboxPolicy::DangerFullAccess,
SandboxPermissions::UseDefault,
)
.await;
assert_eq!(
requirement,
ApprovalRequirement::NeedsApproval {
reason: Some(PROMPT_REASON.to_string()),
allow_prefix: None,
}
);
}
#[tokio::test]
async fn allow_prefix_is_omitted_for_multi_command_scripts() {
let command = vec![
"bash".to_string(),
"-lc".to_string(),
"python && echo ok".to_string(),
];
let requirement = create_approval_requirement_for_command(
&Arc::new(RwLock::new(Policy::empty())),
&Features::with_defaults(),
&command,
AskForApproval::UnlessTrusted,
&SandboxPolicy::ReadOnly,
SandboxPermissions::UseDefault,
)
.await;
assert_eq!(
requirement,
ApprovalRequirement::NeedsApproval {
reason: None,
allow_prefix: Some(vec!["python".to_string()]),
}
);
}
#[tokio::test]
async fn allow_prefix_uses_first_no_match_in_multi_command_scripts() {
let policy_src = r#"prefix_rule(pattern=["python"], 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(),
"python && echo ok".to_string(),
];
assert_eq!(
create_approval_requirement_for_command(
&policy,
&Features::with_defaults(),
&command,
AskForApproval::UnlessTrusted,
&SandboxPolicy::ReadOnly,
SandboxPermissions::UseDefault,
)
.await,
ApprovalRequirement::Skip {
bypass_sandbox: true
}
);
}
}

View File

@@ -231,15 +231,16 @@ impl ShellHandler {
let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None);
emitter.begin(event_ctx).await;
let features = session.features().await;
let approval_requirement = create_approval_requirement_for_command(
&turn.exec_policy,
&features,
&exec_params.command,
turn.approval_policy,
&turn.sandbox_policy,
SandboxPermissions::from(exec_params.with_escalated_permissions.unwrap_or(false)),
)
.await;
let req = ShellRequest {
command: exec_params.command.clone(),
cwd: exec_params.cwd.clone(),

View File

@@ -59,12 +59,12 @@ impl ToolOrchestrator {
});
match requirement {
ApprovalRequirement::Skip { .. } => {
otel.tool_decision(otel_tn, otel_ci, ReviewDecision::Approved, otel_cfg);
otel.tool_decision(otel_tn, otel_ci, &ReviewDecision::Approved, otel_cfg);
}
ApprovalRequirement::Forbidden { reason } => {
return Err(ToolError::Rejected(reason));
}
ApprovalRequirement::NeedsApproval { reason } => {
ApprovalRequirement::NeedsApproval { reason, .. } => {
let mut risk = None;
if let Some(metadata) = req.sandbox_retry_data() {
@@ -88,13 +88,15 @@ impl ToolOrchestrator {
};
let decision = tool.start_approval_async(req, approval_ctx).await;
otel.tool_decision(otel_tn, otel_ci, decision, otel_user.clone());
otel.tool_decision(otel_tn, otel_ci, &decision, otel_user.clone());
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
return Err(ToolError::Rejected("rejected by user".to_string()));
}
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {}
ReviewDecision::Approved
| ReviewDecision::ApprovedAllowPrefix { .. }
| ReviewDecision::ApprovedForSession => {}
}
already_approved = true;
}
@@ -169,13 +171,15 @@ impl ToolOrchestrator {
};
let decision = tool.start_approval_async(req, approval_ctx).await;
otel.tool_decision(otel_tn, otel_ci, decision, otel_user);
otel.tool_decision(otel_tn, otel_ci, &decision, otel_user);
match decision {
ReviewDecision::Denied | ReviewDecision::Abort => {
return Err(ToolError::Rejected("rejected by user".to_string()));
}
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {}
ReviewDecision::Approved
| ReviewDecision::ApprovedAllowPrefix { .. }
| ReviewDecision::ApprovedForSession => {}
}
}

View File

@@ -127,6 +127,7 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
cwd,
Some(reason),
risk,
None,
)
.await
} else if user_explicitly_approved {

View File

@@ -107,7 +107,15 @@ impl Approvable<ShellRequest> for ShellRuntime {
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,
req.approval_requirement.allow_prefix().cloned(),
)
.await
})
.await

View File

@@ -125,7 +125,15 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
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,
req.approval_requirement.allow_prefix().cloned(),
)
.await
})
.await

View File

@@ -96,11 +96,27 @@ pub(crate) enum ApprovalRequirement {
bypass_sandbox: bool,
},
/// Approval required for this tool call
NeedsApproval { reason: Option<String> },
NeedsApproval {
reason: Option<String>,
/// Prefix that can be whitelisted via execpolicy to skip future approvals for similar commands
allow_prefix: Option<Vec<String>>,
},
/// Execution forbidden for this tool call
Forbidden { reason: String },
}
impl ApprovalRequirement {
pub fn allow_prefix(&self) -> Option<&Vec<String>> {
match self {
Self::NeedsApproval {
allow_prefix: Some(prefix),
..
} => Some(prefix),
_ => None,
}
}
}
/// - Never, OnFailure: do not ask
/// - OnRequest: ask unless sandbox policy is DangerFullAccess
/// - UnlessTrusted: always ask
@@ -115,7 +131,10 @@ pub(crate) fn default_approval_requirement(
};
if needs_approval {
ApprovalRequirement::NeedsApproval { reason: None }
ApprovalRequirement::NeedsApproval {
reason: None,
allow_prefix: None,
}
} else {
ApprovalRequirement::Skip {
bypass_sandbox: false,

View File

@@ -552,10 +552,12 @@ impl UnifiedExecSessionManager {
context: &UnifiedExecContext,
) -> Result<UnifiedExecSession, UnifiedExecError> {
let env = apply_unified_exec_env(create_env(&context.turn.shell_environment_policy));
let features = context.session.features().await;
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = UnifiedExecRuntime::new(self);
let approval_requirement = create_approval_requirement_for_command(
&context.turn.exec_policy,
&features,
command,
context.turn.approval_policy,
&context.turn.sandbox_policy,

View File

@@ -1523,7 +1523,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
test.codex
.submit(Op::ExecApproval {
id: "0".into(),
decision: *decision,
decision: decision.clone(),
})
.await?;
wait_for_completion(&test).await;
@@ -1544,7 +1544,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
test.codex
.submit(Op::PatchApproval {
id: "0".into(),
decision: *decision,
decision: decision.clone(),
})
.await?;
wait_for_completion(&test).await;
@@ -1557,3 +1557,138 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn approving_allow_prefix_persists_policy_and_skips_future_prompts() -> Result<()> {
let server = start_mock_server().await;
let approval_policy = AskForApproval::UnlessTrusted;
let sandbox_policy = SandboxPolicy::DangerFullAccess;
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.approval_policy = approval_policy;
config.sandbox_policy = sandbox_policy_for_config;
});
let test = builder.build(&server).await?;
let call_id_first = "allow-prefix-first";
let (first_event, expected_command) = ActionKind::RunCommand {
command: "printf allow-prefix-ok",
}
.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 = expected_command
.split_whitespace()
.map(ToString::to_string)
.collect::<Vec<_>>();
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-allow-prefix-1"),
first_event,
ev_completed("resp-allow-prefix-1"),
]),
)
.await;
let first_results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-allow-prefix-1", "done"),
ev_completed("resp-allow-prefix-2"),
]),
)
.await;
submit_turn(
&test,
"allow-prefix-first",
approval_policy,
sandbox_policy.clone(),
)
.await?;
let approval = expect_exec_approval(&test, expected_command.as_str()).await;
assert_eq!(approval.allow_prefix, Some(expected_allow_prefix.clone()));
test.codex
.submit(Op::ExecApproval {
id: "0".into(),
decision: ReviewDecision::ApprovedAllowPrefix {
allow_prefix: expected_allow_prefix.clone(),
},
})
.await?;
wait_for_completion(&test).await;
let policy_path = test.home.path().join("policy").join("default.codexpolicy");
let policy_contents = fs::read_to_string(&policy_path)?;
assert!(
policy_contents
.contains(r#"prefix_rule(pattern=["printf", "allow-prefix-ok"], decision="allow")"#),
"unexpected policy contents: {policy_contents}"
);
let first_output = parse_result(
&first_results
.single_request()
.function_call_output(call_id_first),
);
assert_eq!(first_output.exit_code.unwrap_or(0), 0);
assert!(
first_output.stdout.contains("allow-prefix-ok"),
"unexpected stdout: {}",
first_output.stdout
);
let call_id_second = "allow-prefix-second";
let (second_event, second_command) = ActionKind::RunCommand {
command: "printf allow-prefix-ok",
}
.prepare(&test, &server, call_id_second, false)
.await?;
assert_eq!(second_command.as_deref(), Some(expected_command.as_str()));
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-allow-prefix-3"),
second_event,
ev_completed("resp-allow-prefix-3"),
]),
)
.await;
let second_results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-allow-prefix-2", "done"),
ev_completed("resp-allow-prefix-4"),
]),
)
.await;
submit_turn(
&test,
"allow-prefix-second",
approval_policy,
sandbox_policy.clone(),
)
.await?;
wait_for_completion_without_approval(&test).await;
let second_output = parse_result(
&second_results
.single_request()
.function_call_output(call_id_second),
);
assert_eq!(second_output.exit_code.unwrap_or(0), 0);
assert!(
second_output.stdout.contains("allow-prefix-ok"),
"unexpected stdout: {}",
second_output.stdout
);
Ok(())
}

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,22 +1,63 @@
use std::fs;
use std::path::PathBuf;
use anyhow::Context;
use anyhow::Result;
use clap::Parser;
use codex_execpolicy::ExecPolicyCheckCommand;
use codex_execpolicy::PolicyParser;
use codex_execpolicy::execpolicycheck::format_matches_json;
/// CLI for evaluating exec policies
#[derive(Parser)]
#[command(name = "codex-execpolicy")]
enum Cli {
/// Evaluate a command against a policy.
Check(ExecPolicyCheckCommand),
Check {
#[arg(short, long = "policy", value_name = "PATH", required = true)]
policies: Vec<PathBuf>,
/// Pretty-print the JSON output.
#[arg(long)]
pretty: bool,
/// Command tokens to check.
#[arg(
value_name = "COMMAND",
required = true,
trailing_var_arg = true,
allow_hyphen_values = true
)]
command: Vec<String>,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli {
Cli::Check(cmd) => cmd_check(cmd),
Cli::Check {
policies,
command,
pretty,
} => cmd_check(policies, command, pretty),
}
}
fn cmd_check(cmd: ExecPolicyCheckCommand) -> Result<()> {
cmd.run()
fn cmd_check(policy_paths: Vec<PathBuf>, args: Vec<String>, pretty: bool) -> Result<()> {
let policy = load_policies(&policy_paths)?;
let matched_rules = policy.matches_for_command(&args, None);
let json = format_matches_json(&matched_rules, pretty)?;
println!("{json}");
Ok(())
}
fn load_policies(policy_paths: &[PathBuf]) -> Result<codex_execpolicy::Policy> {
let mut parser = PolicyParser::new();
for policy_path in policy_paths {
let policy_file_contents = fs::read_to_string(policy_path)
.with_context(|| format!("failed to read policy at {}", policy_path.display()))?;
let policy_identifier = policy_path.to_string_lossy().to_string();
parser.parse(&policy_identifier, &policy_file_contents)?;
}
Ok(parser.build())
}

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,6 +180,7 @@ async fn run_codex_tool_session_inner(
call_id,
reason: _,
risk,
allow_prefix: _,
parsed_cmd,
}) => {
handle_exec_approval_request(

View File

@@ -352,7 +352,7 @@ impl OtelEventManager {
&self,
tool_name: &str,
call_id: &str,
decision: ReviewDecision,
decision: &ReviewDecision,
source: ToolDecisionSource,
) {
tracing::event!(
@@ -369,7 +369,7 @@ impl OtelEventManager {
slug = %self.metadata.slug,
tool_name = %tool_name,
call_id = %call_id,
decision = %decision.to_string().to_lowercase(),
decision = %decision.clone().to_string().to_lowercase(),
source = %source.to_string(),
);
}

View File

@@ -51,6 +51,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.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional, type = "Array<string>")]
pub allow_prefix: Option<Vec<String>>,
pub parsed_cmd: Vec<ParsedCommand>,
}

View File

@@ -1646,14 +1646,16 @@ pub struct SessionConfiguredEvent {
}
/// User's decision in response to an ExecApprovalRequest.
#[derive(
Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS,
)]
#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
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 automatically approve any
/// future identical instances (`command` and `cwd` match exactly) for the
/// remainder of the session.

View File

@@ -43,6 +43,7 @@ pub(crate) enum ApprovalRequest {
command: Vec<String>,
reason: Option<String>,
risk: Option<SandboxCommandAssessment>,
allow_prefix: Option<Vec<String>>,
},
ApplyPatch {
id: String,
@@ -104,8 +105,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 { .. } => (
@@ -160,12 +161,12 @@ impl ApprovalOverlay {
return;
};
if let Some(variant) = self.current_variant.as_ref() {
match (&variant, &option.decision) {
(ApprovalVariant::Exec { id, command }, ApprovalDecision::Review(decision)) => {
self.handle_exec_decision(id, command, *decision);
match (variant, &option.decision) {
(ApprovalVariant::Exec { id, command, .. }, ApprovalDecision::Review(decision)) => {
self.handle_exec_decision(id, command, decision.clone());
}
(ApprovalVariant::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => {
self.handle_patch_decision(id, *decision);
self.handle_patch_decision(id, decision.clone());
}
(
ApprovalVariant::McpElicitation {
@@ -185,7 +186,7 @@ impl ApprovalOverlay {
}
fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) {
let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision);
let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision.clone());
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval {
id: id.to_string(),
@@ -273,7 +274,7 @@ impl BottomPaneView for ApprovalOverlay {
&& let Some(variant) = self.current_variant.as_ref()
{
match &variant {
ApprovalVariant::Exec { id, command } => {
ApprovalVariant::Exec { id, command, .. } => {
self.handle_exec_decision(id, command, ReviewDecision::Abort);
}
ApprovalVariant::ApplyPatch { id, .. } => {
@@ -336,6 +337,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();
@@ -355,7 +357,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 })),
}
}
@@ -431,6 +437,7 @@ enum ApprovalVariant {
Exec {
id: String,
command: Vec<String>,
allow_prefix: Option<Vec<String>>,
},
ApplyPatch {
id: String,
@@ -463,27 +470,32 @@ impl ApprovalOption {
}
}
fn exec_options() -> Vec<ApprovalOption> {
vec![
fn exec_options(allow_prefix: Option<Vec<String>>) -> Vec<ApprovalOption> {
vec![ApprovalOption {
label: "Yes, proceed".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Approved),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
}]
.into_iter()
.chain(allow_prefix.map(|prefix| {
let rendered_prefix = strip_bash_lc_and_escape(&prefix);
ApprovalOption {
label: "Yes, proceed".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Approved),
label: format!("Yes, and don't ask again for `{rendered_prefix}` commands"),
decision: ApprovalDecision::Review(ReviewDecision::ApprovedAllowPrefix {
allow_prefix: prefix,
}),
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: ApprovalDecision::Review(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: ApprovalDecision::Review(ReviewDecision::Abort),
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
},
]
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))],
}
}))
.chain([ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Abort),
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
}])
.collect()
}
fn patch_options() -> Vec<ApprovalOption> {
@@ -539,6 +551,7 @@ mod tests {
command: vec!["echo".to_string(), "hi".to_string()],
reason: Some("reason".to_string()),
risk: None,
allow_prefix: None,
}
}
@@ -571,6 +584,40 @@ 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('p'), KeyModifiers::NONE));
let mut saw_op = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::CodexOp(Op::ExecApproval { decision, .. }) = ev {
assert_eq!(
decision,
ReviewDecision::ApprovedAllowPrefix {
allow_prefix: 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>();
@@ -581,6 +628,7 @@ mod tests {
command,
reason: None,
risk: None,
allow_prefix: None,
};
let view = ApprovalOverlay::new(exec_request, tx);
@@ -634,7 +682,6 @@ mod tests {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = ApprovalOverlay::new(make_exec_request(), tx);
view.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
@@ -649,6 +696,6 @@ mod tests {
break;
}
}
assert_eq!(decision, Some(ReviewDecision::ApprovedForSession));
assert_eq!(decision, Some(ReviewDecision::Approved));
}
}

View File

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

View File

@@ -1069,6 +1069,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();

View File

@@ -4,13 +4,12 @@ expression: terminal.backend().vt100().screen().contents()
---
Would you like to run the following command?
Reason: this is a test reason such as one that would be produced by the
model
Reason: this is a test reason such as one that would be produced by the model
$ 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 `echo hello world` commands (p)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel

View File

@@ -7,7 +7,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 `echo hello world` commands (p)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel

View File

@@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 14 },
area: Rect { x: 0, y: 0, width: 80, height: 13 },
content: [
" ",
" ",
@@ -15,8 +15,7 @@ Buffer {
" $ echo hello world ",
" ",
" 1. Yes, proceed (y) ",
" 2. Yes, and don't ask again for this command (a) ",
" 3. No, and tell Codex what to do differently (esc) ",
" 2. No, and tell Codex what to do differently (esc) ",
" ",
" Press enter to confirm or esc to cancel ",
],
@@ -31,9 +30,7 @@ Buffer {
x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 49, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 51, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}

View File

@@ -1,19 +1,17 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1548
expression: terminal.backend()
---
" "
" "
" Would you like to run the following command? "
" "
" Reason: this is a test reason such as one that would be produced by the "
" model "
" "
" $ echo 'hello world' "
" "
" 1. Yes, proceed (y) "
" 2. Yes, and don't ask again for this command (a) "
" 3. No, and tell Codex what to do differently (esc) "
" "
" Press enter to confirm or esc to cancel "
" "
" "
" Would you like to run the following command? "
" "
" Reason: this is a test reason such as one that would be produced by the model "
" "
" $ echo 'hello world' "
" "
" 1. Yes, proceed (y) "
" 2. Yes, and don't ask again for `echo 'hello world'` commands (p) "
" 3. No, and tell Codex what to do differently (esc) "
" "
" Press enter to confirm or esc to cancel "

View File

@@ -677,6 +677,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 {
@@ -721,6 +722,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 {
@@ -771,6 +773,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 {
@@ -1969,6 +1972,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 {
@@ -1977,11 +1981,12 @@ fn approval_modal_exec_snapshot() {
});
// Render to a fixed-size test terminal and snapshot.
// Call desired_height first and use that exact height for rendering.
let height = chat.desired_height(80);
let width = 100;
let height = chat.desired_height(width);
let mut terminal =
crate::custom_terminal::Terminal::with_options(VT100Backend::new(80, height))
crate::custom_terminal::Terminal::with_options(VT100Backend::new(width, height))
.expect("create terminal");
let viewport = Rect::new(0, 0, 80, height);
let viewport = Rect::new(0, 0, width, height);
terminal.set_viewport_area(viewport);
terminal
@@ -2015,6 +2020,7 @@ 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()]),
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -2022,10 +2028,11 @@ fn approval_modal_exec_without_reason_snapshot() {
msg: EventMsg::ExecApprovalRequest(ev),
});
let height = chat.desired_height(80);
let width = 100;
let height = chat.desired_height(width);
let mut terminal =
ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, 80, height));
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw approval modal (no reason)");
@@ -2228,6 +2235,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: Some(vec!["echo".into(), "hello world".into()]),
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -2236,9 +2244,11 @@ fn status_widget_and_approval_modal_snapshot() {
});
// Render at the widget's desired height and snapshot.
let height = chat.desired_height(80);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
let width: u16 = 100;
let height = chat.desired_height(width);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
.expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw status + approval modal");

View File

@@ -408,6 +408,19 @@ pub fn new_approval_decision_cell(
],
)
}
ApprovedAllowPrefix { .. } => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".green(),
vec![
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet,
" and added its prefix to your allow list".bold(),
],
)
}
ApprovedForSession => {
let snippet = Span::from(exec_snippet(&command)).dim();
(

View File

@@ -603,7 +603,7 @@ metadata above):
- `codex.tool_decision`
- `tool_name`
- `call_id`
- `decision` (`approved`, `approved_for_session`, `denied`, or `abort`)
- `decision` (`approved`, `approved_allow_prefix`, `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.
```