Skip to content

Commit 8da75ba

Browse files
committed
core: resolve host_executable() rules during preflight
1 parent 1a8d930 commit 8da75ba

File tree

3 files changed

+175
-7
lines changed

3 files changed

+175
-7
lines changed

codex-rs/core/src/exec_policy.rs

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use codex_execpolicy::AmendError;
1313
use codex_execpolicy::Decision;
1414
use codex_execpolicy::Error as ExecPolicyRuleError;
1515
use codex_execpolicy::Evaluation;
16+
use codex_execpolicy::MatchOptions;
1617
use codex_execpolicy::NetworkRuleProtocol;
1718
use codex_execpolicy::Policy;
1819
use codex_execpolicy::PolicyParser;
@@ -221,14 +222,22 @@ impl ExecPolicyManager {
221222
used_complex_parsing,
222223
)
223224
};
224-
let evaluation = exec_policy.check_multiple(commands.iter(), &exec_policy_fallback);
225+
let match_options = MatchOptions {
226+
resolve_host_executables: true,
227+
};
228+
let evaluation = exec_policy.check_multiple_with_options(
229+
commands.iter(),
230+
&exec_policy_fallback,
231+
&match_options,
232+
);
225233

226234
let requested_amendment = derive_requested_execpolicy_amendment_from_prefix_rule(
227235
prefix_rule.as_ref(),
228236
&evaluation.matched_rules,
229237
exec_policy.as_ref(),
230238
&commands,
231239
&exec_policy_fallback,
240+
&match_options,
232241
);
233242

234243
match evaluation.decision {
@@ -630,6 +639,7 @@ fn derive_requested_execpolicy_amendment_from_prefix_rule(
630639
exec_policy: &Policy,
631640
commands: &[Vec<String>],
632641
exec_policy_fallback: &impl Fn(&[String]) -> Decision,
642+
match_options: &MatchOptions,
633643
) -> Option<ExecPolicyAmendment> {
634644
let prefix_rule = prefix_rule?;
635645
if prefix_rule.is_empty() {
@@ -656,6 +666,7 @@ fn derive_requested_execpolicy_amendment_from_prefix_rule(
656666
&amendment.command,
657667
commands,
658668
exec_policy_fallback,
669+
match_options,
659670
) {
660671
Some(amendment)
661672
} else {
@@ -668,6 +679,7 @@ fn prefix_rule_would_approve_all_commands(
668679
prefix_rule: &[String],
669680
commands: &[Vec<String>],
670681
exec_policy_fallback: &impl Fn(&[String]) -> Decision,
682+
match_options: &MatchOptions,
671683
) -> bool {
672684
let mut policy_with_prefix_rule = exec_policy.clone();
673685
if policy_with_prefix_rule
@@ -679,7 +691,7 @@ fn prefix_rule_would_approve_all_commands(
679691

680692
commands.iter().all(|command| {
681693
policy_with_prefix_rule
682-
.check(command, exec_policy_fallback)
694+
.check_with_options(command, exec_policy_fallback, match_options)
683695
.decision
684696
== Decision::Allow
685697
})
@@ -849,6 +861,15 @@ mod tests {
849861
path.to_string_lossy().into_owned()
850862
}
851863

864+
fn host_program_path(name: &str) -> String {
865+
let executable_name = if cfg!(windows) {
866+
format!("{name}.exe")
867+
} else {
868+
name.to_string()
869+
};
870+
host_absolute_path(&["usr", "bin", &executable_name])
871+
}
872+
852873
fn starlark_string(value: &str) -> String {
853874
value.replace('\\', "\\\\").replace('"', "\\\"")
854875
}
@@ -1398,6 +1419,115 @@ prefix_rule(
13981419
);
13991420
}
14001421

1422+
#[tokio::test]
1423+
async fn absolute_path_exec_approval_requirement_matches_host_executable_rules() {
1424+
let git_path = host_program_path("git");
1425+
let git_path_literal = starlark_string(&git_path);
1426+
let policy_src = format!(
1427+
r#"
1428+
host_executable(name = "git", paths = ["{git_path_literal}"])
1429+
prefix_rule(pattern=["git"], decision="allow")
1430+
"#
1431+
);
1432+
let mut parser = PolicyParser::new();
1433+
parser
1434+
.parse("test.rules", &policy_src)
1435+
.expect("parse policy");
1436+
let manager = ExecPolicyManager::new(Arc::new(parser.build()));
1437+
let command = vec![git_path, "status".to_string()];
1438+
1439+
let requirement = manager
1440+
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
1441+
command: &command,
1442+
approval_policy: AskForApproval::UnlessTrusted,
1443+
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
1444+
sandbox_permissions: SandboxPermissions::UseDefault,
1445+
prefix_rule: None,
1446+
})
1447+
.await;
1448+
1449+
assert_eq!(
1450+
requirement,
1451+
ExecApprovalRequirement::Skip {
1452+
bypass_sandbox: true,
1453+
proposed_execpolicy_amendment: None,
1454+
}
1455+
);
1456+
}
1457+
1458+
#[tokio::test]
1459+
async fn absolute_path_exec_approval_requirement_ignores_disallowed_host_executable_paths() {
1460+
let allowed_git_path = host_program_path("git");
1461+
let disallowed_git_path = host_absolute_path(&[
1462+
"opt",
1463+
"homebrew",
1464+
"bin",
1465+
if cfg!(windows) { "git.exe" } else { "git" },
1466+
]);
1467+
let allowed_git_path_literal = starlark_string(&allowed_git_path);
1468+
let policy_src = format!(
1469+
r#"
1470+
host_executable(name = "git", paths = ["{allowed_git_path_literal}"])
1471+
prefix_rule(pattern=["git"], decision="prompt")
1472+
"#
1473+
);
1474+
let mut parser = PolicyParser::new();
1475+
parser
1476+
.parse("test.rules", &policy_src)
1477+
.expect("parse policy");
1478+
let manager = ExecPolicyManager::new(Arc::new(parser.build()));
1479+
let command = vec![disallowed_git_path, "status".to_string()];
1480+
1481+
let requirement = manager
1482+
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
1483+
command: &command,
1484+
approval_policy: AskForApproval::UnlessTrusted,
1485+
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
1486+
sandbox_permissions: SandboxPermissions::UseDefault,
1487+
prefix_rule: None,
1488+
})
1489+
.await;
1490+
1491+
assert_eq!(
1492+
requirement,
1493+
ExecApprovalRequirement::Skip {
1494+
bypass_sandbox: false,
1495+
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)),
1496+
}
1497+
);
1498+
}
1499+
1500+
#[tokio::test]
1501+
async fn requested_prefix_rule_can_approve_absolute_path_commands() {
1502+
let command = vec![
1503+
host_program_path("cargo"),
1504+
"install".to_string(),
1505+
"cargo-insta".to_string(),
1506+
];
1507+
let manager = ExecPolicyManager::default();
1508+
1509+
let requirement = manager
1510+
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
1511+
command: &command,
1512+
approval_policy: AskForApproval::UnlessTrusted,
1513+
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
1514+
sandbox_permissions: SandboxPermissions::UseDefault,
1515+
prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]),
1516+
})
1517+
.await;
1518+
1519+
assert_eq!(
1520+
requirement,
1521+
ExecApprovalRequirement::NeedsApproval {
1522+
reason: None,
1523+
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![
1524+
"cargo".to_string(),
1525+
"install".to_string(),
1526+
])),
1527+
}
1528+
);
1529+
}
1530+
14011531
#[tokio::test]
14021532
async fn exec_approval_requirement_respects_approval_policy() {
14031533
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
@@ -1952,6 +2082,7 @@ prefix_rule(
19522082
&Policy::empty(),
19532083
&commands,
19542084
&|_: &[String]| Decision::Allow,
2085+
&MatchOptions::default(),
19552086
)
19562087
}
19572088

codex-rs/shell-command/src/command_safety/is_dangerous_command.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::bash::parse_shell_lc_plain_commands;
2+
use std::path::Path;
23
#[cfg(windows)]
34
#[path = "windows_dangerous_commands.rs"]
45
mod windows_dangerous_commands;
@@ -52,6 +53,32 @@ fn is_git_global_option_with_inline_value(arg: &str) -> bool {
5253
) || ((arg.starts_with("-C") || arg.starts_with("-c")) && arg.len() > 2)
5354
}
5455

56+
pub(crate) fn executable_name_lookup_key(raw: &str) -> Option<String> {
57+
#[cfg(windows)]
58+
{
59+
Path::new(raw)
60+
.file_name()
61+
.and_then(|name| name.to_str())
62+
.map(|name| {
63+
let name = name.to_ascii_lowercase();
64+
for suffix in [".exe", ".cmd", ".bat", ".com"] {
65+
if let Some(stripped) = name.strip_suffix(suffix) {
66+
return stripped.to_string();
67+
}
68+
}
69+
name
70+
})
71+
}
72+
73+
#[cfg(not(windows))]
74+
{
75+
Path::new(raw)
76+
.file_name()
77+
.and_then(|name| name.to_str())
78+
.map(std::borrow::ToOwned::to_owned)
79+
}
80+
}
81+
5582
/// Find the first matching git subcommand, skipping known global options that
5683
/// may appear before it (e.g., `-C`, `-c`, `--git-dir`).
5784
///
@@ -61,7 +88,7 @@ pub(crate) fn find_git_subcommand<'a>(
6188
subcommands: &[&str],
6289
) -> Option<(usize, &'a str)> {
6390
let cmd0 = command.first().map(String::as_str)?;
64-
if !cmd0.ends_with("git") {
91+
if executable_name_lookup_key(cmd0).as_deref() != Some("git") {
6592
return None;
6693
}
6794

codex-rs/shell-command/src/command_safety/is_safe_command.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::bash::parse_shell_lc_plain_commands;
2+
use crate::command_safety::is_dangerous_command::executable_name_lookup_key;
23
// Find the first matching git subcommand, skipping known global options that
34
// may appear before it (e.g., `-C`, `-c`, `--git-dir`).
45
// Implemented in `is_dangerous_command` and shared here.
@@ -47,10 +48,7 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool {
4748
return false;
4849
};
4950

50-
match std::path::Path::new(&cmd0)
51-
.file_name()
52-
.and_then(|osstr| osstr.to_str())
53-
{
51+
match executable_name_lookup_key(cmd0).as_deref() {
5452
Some(cmd) if cfg!(target_os = "linux") && matches!(cmd, "numfmt" | "tac") => true,
5553

5654
#[rustfmt::skip]
@@ -495,6 +493,18 @@ mod tests {
495493
])));
496494
}
497495

496+
#[test]
497+
fn windows_git_full_path_is_safe() {
498+
if !cfg!(windows) {
499+
return;
500+
}
501+
502+
assert!(is_known_safe_command(&vec_str(&[
503+
r"C:\Program Files\Git\cmd\git.exe",
504+
"status",
505+
])));
506+
}
507+
498508
#[test]
499509
fn bash_lc_safe_examples() {
500510
assert!(is_known_safe_command(&vec_str(&["bash", "-lc", "ls"])));

0 commit comments

Comments
 (0)