Skip to content

Commit c2bdee0

Browse files
authored
proposing execpolicy amendment when prompting due to sandbox denial (#7653)
Currently, we only show the “don’t ask again for commands that start with…” option when a command is immediately flagged as needing approval. However, there is another case where we ask for approval: When a command is initially auto-approved to run within sandbox, but it fails to run inside sandbox, we would like to attempt to retry running outside of sandbox. This will require a prompt to the user. This PR addresses this latter case
1 parent cfda44b commit c2bdee0

File tree

4 files changed

+121
-35
lines changed

4 files changed

+121
-35
lines changed

codex-rs/core/src/exec_policy.rs

Lines changed: 109 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ const POLICY_DIR_NAME: &str = "policy";
3434
const POLICY_EXTENSION: &str = "codexpolicy";
3535
const DEFAULT_POLICY_FILE: &str = "default.codexpolicy";
3636

37+
fn is_policy_match(rule_match: &RuleMatch) -> bool {
38+
match rule_match {
39+
RuleMatch::PrefixRuleMatch { .. } => true,
40+
RuleMatch::HeuristicsRuleMatch { .. } => false,
41+
}
42+
}
43+
3744
#[derive(Debug, Error)]
3845
pub enum ExecPolicyError {
3946
#[error("failed to read execpolicy files from {dir}: {source}")]
@@ -147,49 +154,62 @@ pub(crate) async fn append_execpolicy_amendment_and_update(
147154
Ok(())
148155
}
149156

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:
157+
/// Derive a proposed execpolicy amendment when a command requires user approval
158+
/// - If any execpolicy rule prompts, return None, because an amendment would not skip that policy requirement.
159+
/// - Otherwise return the first heuristics Prompt.
160+
/// - Examples:
158161
/// - execpolicy: empty. Command: `["python"]`. Heuristics prompt -> `Some(vec!["python"])`.
159162
/// - execpolicy: empty. Command: `["bash", "-c", "cd /some/folder && prog1 --option1 arg1 && prog2 --option2 arg2"]`.
160163
/// Parsed commands include `cd /some/folder`, `prog1 --option1 arg1`, and `prog2 --option2 arg2`. If heuristics allow `cd` but prompt
161164
/// on `prog1`, we return `Some(vec!["prog1", "--option1", "arg1"])`.
162165
/// - execpolicy: contains a `prompt for prefix ["prog2"]` rule. For the same command as above,
163166
/// 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 {
167+
fn try_derive_execpolicy_amendment_for_prompt_rules(
168+
matched_rules: &[RuleMatch],
169+
) -> Option<ExecPolicyAmendment> {
170+
if matched_rules
171+
.iter()
172+
.any(|rule_match| is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt)
173+
{
166174
return None;
167175
}
168176

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-
}
177+
matched_rules
178+
.iter()
179+
.find_map(|rule_match| match rule_match {
180+
RuleMatch::HeuristicsRuleMatch {
181+
command,
182+
decision: Decision::Prompt,
183+
} => Some(ExecPolicyAmendment::from(command.clone())),
184+
_ => None,
185+
})
186+
}
187+
188+
/// - Note: we only use this amendment when the command fails to run in sandbox and codex prompts the user to run outside the sandbox
189+
/// - The purpose of this amendment is to bypass sandbox for similar commands in the future
190+
/// - If any execpolicy rule matches, return None, because we would already be running command outside the sandbox
191+
fn try_derive_execpolicy_amendment_for_allow_rules(
192+
matched_rules: &[RuleMatch],
193+
) -> Option<ExecPolicyAmendment> {
194+
if matched_rules.iter().any(is_policy_match) {
195+
return None;
182196
}
183197

184-
first_prompt_from_heuristics.map(ExecPolicyAmendment::from)
198+
matched_rules
199+
.iter()
200+
.find_map(|rule_match| match rule_match {
201+
RuleMatch::HeuristicsRuleMatch {
202+
command,
203+
decision: Decision::Allow,
204+
} => Some(ExecPolicyAmendment::from(command.clone())),
205+
_ => None,
206+
})
185207
}
186208

187209
/// Only return PROMPT_REASON when an execpolicy rule drove the prompt decision.
188210
fn derive_prompt_reason(evaluation: &Evaluation) -> Option<String> {
189211
evaluation.matched_rules.iter().find_map(|rule_match| {
190-
if !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. })
191-
&& rule_match.decision() == Decision::Prompt
192-
{
212+
if is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt {
193213
Some(PROMPT_REASON.to_string())
194214
} else {
195215
None
@@ -215,10 +235,6 @@ pub(crate) async fn create_exec_approval_requirement_for_command(
215235
};
216236
let policy = exec_policy.read().await;
217237
let evaluation = policy.check_multiple(commands.iter(), &heuristics_fallback);
218-
let has_policy_allow = evaluation.matched_rules.iter().any(|rule_match| {
219-
!matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. })
220-
&& rule_match.decision() == Decision::Allow
221-
});
222238

223239
match evaluation.decision {
224240
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
@@ -233,15 +249,23 @@ pub(crate) async fn create_exec_approval_requirement_for_command(
233249
ExecApprovalRequirement::NeedsApproval {
234250
reason: derive_prompt_reason(&evaluation),
235251
proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) {
236-
proposed_execpolicy_amendment(&evaluation)
252+
try_derive_execpolicy_amendment_for_prompt_rules(&evaluation.matched_rules)
237253
} else {
238254
None
239255
},
240256
}
241257
}
242258
}
243259
Decision::Allow => ExecApprovalRequirement::Skip {
244-
bypass_sandbox: has_policy_allow,
260+
// Bypass sandbox if execpolicy allows the command
261+
bypass_sandbox: evaluation.matched_rules.iter().any(|rule_match| {
262+
is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
263+
}),
264+
proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) {
265+
try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules)
266+
} else {
267+
None
268+
},
245269
},
246270
}
247271
}
@@ -730,4 +754,56 @@ prefix_rule(pattern=["rm"], decision="forbidden")
730754
}
731755
);
732756
}
757+
758+
#[tokio::test]
759+
async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() {
760+
let command = vec!["echo".to_string(), "safe".to_string()];
761+
762+
let requirement = create_exec_approval_requirement_for_command(
763+
&Arc::new(RwLock::new(Policy::empty())),
764+
&Features::with_defaults(),
765+
&command,
766+
AskForApproval::OnRequest,
767+
&SandboxPolicy::ReadOnly,
768+
SandboxPermissions::UseDefault,
769+
)
770+
.await;
771+
772+
assert_eq!(
773+
requirement,
774+
ExecApprovalRequirement::Skip {
775+
bypass_sandbox: false,
776+
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
777+
}
778+
);
779+
}
780+
781+
#[tokio::test]
782+
async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() {
783+
let policy_src = r#"prefix_rule(pattern=["echo"], decision="allow")"#;
784+
let mut parser = PolicyParser::new();
785+
parser
786+
.parse("test.codexpolicy", policy_src)
787+
.expect("parse policy");
788+
let policy = Arc::new(RwLock::new(parser.build()));
789+
let command = vec!["echo".to_string(), "safe".to_string()];
790+
791+
let requirement = create_exec_approval_requirement_for_command(
792+
&policy,
793+
&Features::with_defaults(),
794+
&command,
795+
AskForApproval::OnRequest,
796+
&SandboxPolicy::ReadOnly,
797+
SandboxPermissions::UseDefault,
798+
)
799+
.await;
800+
801+
assert_eq!(
802+
requirement,
803+
ExecApprovalRequirement::Skip {
804+
bypass_sandbox: true,
805+
proposed_execpolicy_amendment: None,
806+
}
807+
);
808+
}
733809
}

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)