Skip to content

Commit 5a520f0

Browse files
committed
proposing execpolicy amendment when prompting due to sandbox denial
1 parent a8cbbdb commit 5a520f0

File tree

4 files changed

+113
-33
lines changed

4 files changed

+113
-33
lines changed

codex-rs/core/src/exec_policy.rs

Lines changed: 101 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -147,41 +147,54 @@ pub(crate) async fn append_execpolicy_amendment_and_update(
147147
Ok(())
148148
}
149149

150-
/// Returns a proposed execpolicy amendment only when heuristics caused
151-
/// the prompt decision, so we can offer to apply that amendment for future runs.
152-
///
153-
/// The amendment uses the first command heuristics marked as `Prompt`. If any explicit
154-
/// execpolicy rule also prompts, we return `None` because applying the amendment would not
155-
/// skip that policy requirement.
156-
///
157-
/// Examples:
158-
/// - execpolicy: empty. Command: `["python"]`. Heuristics prompt -> `Some(vec!["python"])`.
159-
/// - execpolicy: empty. Command: `["bash", "-c", "cd /some/folder && prog1 --option1 arg1 && prog2 --option2 arg2"]`.
160-
/// Parsed commands include `cd /some/folder`, `prog1 --option1 arg1`, and `prog2 --option2 arg2`. If heuristics allow `cd` but prompt
161-
/// on `prog1`, we return `Some(vec!["prog1", "--option1", "arg1"])`.
162-
/// - execpolicy: contains a `prompt for prefix ["prog2"]` rule. For the same command as above,
163-
/// we return `None` because an execpolicy prompt still applies even if we amend execpolicy to allow ["prog1", "--option1", "arg1"].
164-
fn proposed_execpolicy_amendment(evaluation: &Evaluation) -> Option<ExecPolicyAmendment> {
165-
if evaluation.decision != Decision::Prompt {
150+
// - If any execpolicy rule prompts, return None, because an amendment would not skip that policy requirement.
151+
// - Otherwise return the first heuristics Prompt.
152+
// Examples:
153+
// - execpolicy: empty. Command: `["python"]`. Heuristics prompt -> `Some(vec!["python"])`.
154+
// - execpolicy: empty. Command: `["bash", "-c", "cd /some/folder && prog1 --option1 arg1 && prog2 --option2 arg2"]`.
155+
// Parsed commands include `cd /some/folder`, `prog1 --option1 arg1`, and `prog2 --option2 arg2`. If heuristics allow `cd` but prompt
156+
// on `prog1`, we return `Some(vec!["prog1", "--option1", "arg1"])`.
157+
// - execpolicy: contains a `prompt for prefix ["prog2"]` rule. For the same command as above,
158+
// we return `None` because an execpolicy prompt still applies even if we amend execpolicy to allow ["prog1", "--option1", "arg1"].
159+
fn prompt_execpolicy_amendment(matched_rules: &[RuleMatch]) -> Option<ExecPolicyAmendment> {
160+
if matched_rules.iter().any(|rule_match| {
161+
!matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. })
162+
&& rule_match.decision() == Decision::Prompt
163+
}) {
166164
return None;
167165
}
168166

169-
let mut first_prompt_from_heuristics: Option<Vec<String>> = None;
170-
for rule_match in &evaluation.matched_rules {
171-
match rule_match {
172-
RuleMatch::HeuristicsRuleMatch { command, decision } => {
173-
if *decision == Decision::Prompt && first_prompt_from_heuristics.is_none() {
174-
first_prompt_from_heuristics = Some(command.clone());
175-
}
176-
}
177-
_ if rule_match.decision() == Decision::Prompt => {
178-
return None;
179-
}
180-
_ => {}
181-
}
167+
matched_rules
168+
.iter()
169+
.find_map(|rule_match| match rule_match {
170+
RuleMatch::HeuristicsRuleMatch {
171+
command,
172+
decision: Decision::Prompt,
173+
} => Some(ExecPolicyAmendment::from(command.clone())),
174+
_ => None,
175+
})
176+
}
177+
178+
// - Note: we only use this amendment when the command fails to run in sandbox and codex prompts the user to run outside the sandbox
179+
// - The purpose of this amendment is to bypass sandbox for similar commands in the future
180+
// - If any execpolicy rule matches, return None, because we would already be running command outside the sandbox
181+
fn allow_execpolicy_amendment(matched_rules: &[RuleMatch]) -> Option<ExecPolicyAmendment> {
182+
if matched_rules
183+
.iter()
184+
.any(|rule_match| !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }))
185+
{
186+
return None;
182187
}
183188

184-
first_prompt_from_heuristics.map(ExecPolicyAmendment::from)
189+
matched_rules
190+
.iter()
191+
.find_map(|rule_match| match rule_match {
192+
RuleMatch::HeuristicsRuleMatch {
193+
command,
194+
decision: Decision::Allow,
195+
} => Some(ExecPolicyAmendment::from(command.clone())),
196+
_ => None,
197+
})
185198
}
186199

187200
/// Only return PROMPT_REASON when an execpolicy rule drove the prompt decision.
@@ -233,7 +246,7 @@ pub(crate) async fn create_exec_approval_requirement_for_command(
233246
ExecApprovalRequirement::NeedsApproval {
234247
reason: derive_prompt_reason(&evaluation),
235248
proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) {
236-
proposed_execpolicy_amendment(&evaluation)
249+
prompt_execpolicy_amendment(&evaluation.matched_rules)
237250
} else {
238251
None
239252
},
@@ -242,6 +255,11 @@ pub(crate) async fn create_exec_approval_requirement_for_command(
242255
}
243256
Decision::Allow => ExecApprovalRequirement::Skip {
244257
bypass_sandbox: has_policy_allow,
258+
proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) {
259+
allow_execpolicy_amendment(&evaluation.matched_rules)
260+
} else {
261+
None
262+
},
245263
},
246264
}
247265
}
@@ -730,4 +748,56 @@ prefix_rule(pattern=["rm"], decision="forbidden")
730748
}
731749
);
732750
}
751+
752+
#[tokio::test]
753+
async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() {
754+
let command = vec!["echo".to_string(), "safe".to_string()];
755+
756+
let requirement = create_exec_approval_requirement_for_command(
757+
&Arc::new(RwLock::new(Policy::empty())),
758+
&Features::with_defaults(),
759+
&command,
760+
AskForApproval::OnRequest,
761+
&SandboxPolicy::ReadOnly,
762+
SandboxPermissions::UseDefault,
763+
)
764+
.await;
765+
766+
assert_eq!(
767+
requirement,
768+
ExecApprovalRequirement::Skip {
769+
bypass_sandbox: false,
770+
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
771+
}
772+
);
773+
}
774+
775+
#[tokio::test]
776+
async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() {
777+
let policy_src = r#"prefix_rule(pattern=["echo"], decision="allow")"#;
778+
let mut parser = PolicyParser::new();
779+
parser
780+
.parse("test.codexpolicy", policy_src)
781+
.expect("parse policy");
782+
let policy = Arc::new(RwLock::new(parser.build()));
783+
let command = vec!["echo".to_string(), "safe".to_string()];
784+
785+
let requirement = create_exec_approval_requirement_for_command(
786+
&policy,
787+
&Features::with_defaults(),
788+
&command,
789+
AskForApproval::OnRequest,
790+
&SandboxPolicy::ReadOnly,
791+
SandboxPermissions::UseDefault,
792+
)
793+
.await;
794+
795+
assert_eq!(
796+
requirement,
797+
ExecApprovalRequirement::Skip {
798+
bypass_sandbox: true,
799+
proposed_execpolicy_amendment: None,
800+
}
801+
);
802+
}
733803
}

codex-rs/core/src/tools/runtimes/shell.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@ impl Approvable<ShellRequest> for ShellRuntime {
133133
|| matches!(
134134
req.exec_approval_requirement,
135135
ExecApprovalRequirement::Skip {
136-
bypass_sandbox: true
136+
bypass_sandbox: true,
137+
..
137138
}
138139
)
139140
{

codex-rs/core/src/tools/runtimes/unified_exec.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,8 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
154154
|| matches!(
155155
req.exec_approval_requirement,
156156
ExecApprovalRequirement::Skip {
157-
bypass_sandbox: true
157+
bypass_sandbox: true,
158+
..
158159
}
159160
)
160161
{

codex-rs/core/src/tools/sandboxing.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ pub(crate) enum ExecApprovalRequirement {
9595
/// The first attempt should skip sandboxing (e.g., when explicitly
9696
/// greenlit by policy).
9797
bypass_sandbox: bool,
98+
/// Proposed execpolicy amendment to skip future approvals for similar commands
99+
/// Only applies if the command fails to run in sandbox and codex prompts the user to run outside the sandbox.
100+
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
98101
},
99102
/// Approval required for this tool call.
100103
NeedsApproval {
@@ -114,6 +117,10 @@ impl ExecApprovalRequirement {
114117
proposed_execpolicy_amendment: Some(prefix),
115118
..
116119
} => Some(prefix),
120+
Self::Skip {
121+
proposed_execpolicy_amendment: Some(prefix),
122+
..
123+
} => Some(prefix),
117124
_ => None,
118125
}
119126
}
@@ -140,6 +147,7 @@ pub(crate) fn default_exec_approval_requirement(
140147
} else {
141148
ExecApprovalRequirement::Skip {
142149
bypass_sandbox: false,
150+
proposed_execpolicy_amendment: None,
143151
}
144152
}
145153
}

0 commit comments

Comments
 (0)