Skip to content

Commit 152eeb0

Browse files
committed
core: adopt host_executable() rules in zsh-fork
1 parent b148d98 commit 152eeb0

File tree

2 files changed

+184
-20
lines changed

2 files changed

+184
-20
lines changed

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

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ use crate::tools::sandboxing::SandboxablePreference;
1616
use crate::tools::sandboxing::ToolCtx;
1717
use crate::tools::sandboxing::ToolError;
1818
use codex_execpolicy::Decision;
19+
use codex_execpolicy::Evaluation;
20+
use codex_execpolicy::MatchOptions;
1921
use codex_execpolicy::Policy;
2022
use codex_execpolicy::RuleMatch;
2123
use codex_protocol::config_types::WindowsSandboxLevel;
@@ -493,29 +495,17 @@ impl EscalationPolicy for CoreShellActionProvider {
493495
.await;
494496
}
495497

496-
let command = join_program_and_argv(program, argv);
497-
let (commands, used_complex_parsing) =
498-
if let Some(commands) = parse_shell_lc_plain_commands(&command) {
499-
(commands, false)
500-
} else if let Some(single_command) = parse_shell_lc_single_command_prefix(&command) {
501-
(vec![single_command], true)
502-
} else {
503-
(vec![command.clone()], false)
504-
};
505-
506-
let fallback = |cmd: &[String]| {
507-
crate::exec_policy::render_decision_for_unmatched_command(
498+
let evaluation = {
499+
let policy = self.policy.read().await;
500+
evaluate_intercepted_exec_policy(
501+
&policy,
502+
program,
503+
argv,
508504
self.approval_policy,
509505
&self.sandbox_policy,
510-
cmd,
511506
self.sandbox_permissions,
512-
used_complex_parsing,
513507
)
514508
};
515-
let evaluation = {
516-
let policy = self.policy.read().await;
517-
policy.check_multiple(commands.iter(), &fallback)
518-
};
519509
// When true, means the Evaluation was due to *.rules, not the
520510
// fallback function.
521511
let decision_driven_by_policy =
@@ -552,6 +542,62 @@ impl EscalationPolicy for CoreShellActionProvider {
552542
}
553543
}
554544

545+
fn evaluate_intercepted_exec_policy(
546+
policy: &Policy,
547+
program: &AbsolutePathBuf,
548+
argv: &[String],
549+
approval_policy: AskForApproval,
550+
sandbox_policy: &SandboxPolicy,
551+
sandbox_permissions: SandboxPermissions,
552+
) -> Evaluation {
553+
let (commands, used_complex_parsing) = commands_for_intercepted_exec_policy(program, argv);
554+
555+
let fallback = |cmd: &[String]| {
556+
crate::exec_policy::render_decision_for_unmatched_command(
557+
approval_policy,
558+
sandbox_policy,
559+
cmd,
560+
sandbox_permissions,
561+
used_complex_parsing,
562+
)
563+
};
564+
565+
policy.check_multiple_with_options(
566+
commands.iter(),
567+
&fallback,
568+
&MatchOptions {
569+
resolve_host_executables: true,
570+
},
571+
)
572+
}
573+
574+
// Shell-wrapper parsing is weaker than direct exec interception because it can
575+
// only see the script text, not the final resolved executable path. Keep it
576+
// disabled by default so path-sensitive rules rely on the later authoritative
577+
// execve interception.
578+
const ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING: bool = false;
579+
580+
fn commands_for_intercepted_exec_policy(
581+
program: &AbsolutePathBuf,
582+
argv: &[String],
583+
) -> (Vec<Vec<String>>, bool) {
584+
if ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING && let [_, flag, script] = argv {
585+
let shell_command = [
586+
program.to_string_lossy().to_string(),
587+
flag.clone(),
588+
script.clone(),
589+
];
590+
if let Some(commands) = parse_shell_lc_plain_commands(&shell_command) {
591+
return (commands, false);
592+
}
593+
if let Some(single_command) = parse_shell_lc_single_command_prefix(&shell_command) {
594+
return (vec![single_command], true);
595+
}
596+
}
597+
598+
(vec![join_program_and_argv(program, argv)], false)
599+
}
600+
555601
struct CoreShellCommandExecutor {
556602
command: Vec<String>,
557603
cwd: PathBuf,

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

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ use super::CoreShellActionProvider;
22
#[cfg(target_os = "macos")]
33
use super::CoreShellCommandExecutor;
44
use super::ParsedShellCommand;
5+
use super::commands_for_intercepted_exec_policy;
6+
use super::evaluate_intercepted_exec_policy;
57
use super::extract_shell_script;
68
use super::join_program_and_argv;
79
use super::map_exec_result;
@@ -12,14 +14,16 @@ use crate::config::Permissions;
1214
#[cfg(target_os = "macos")]
1315
use crate::config::types::ShellEnvironmentPolicy;
1416
use crate::exec::SandboxType;
15-
#[cfg(target_os = "macos")]
1617
use crate::protocol::AskForApproval;
1718
use crate::protocol::ReadOnlyAccess;
1819
use crate::protocol::SandboxPolicy;
19-
#[cfg(target_os = "macos")]
2020
use crate::sandboxing::SandboxPermissions;
2121
#[cfg(target_os = "macos")]
2222
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
23+
use codex_execpolicy::Decision;
24+
use codex_execpolicy::Evaluation;
25+
use codex_execpolicy::PolicyParser;
26+
use codex_execpolicy::RuleMatch;
2327
#[cfg(target_os = "macos")]
2428
use codex_protocol::config_types::WindowsSandboxLevel;
2529
use codex_protocol::models::FileSystemPermissions;
@@ -36,8 +40,25 @@ use codex_utils_absolute_path::AbsolutePathBuf;
3640
use pretty_assertions::assert_eq;
3741
#[cfg(target_os = "macos")]
3842
use std::collections::HashMap;
43+
use std::path::PathBuf;
3944
use std::time::Duration;
4045

46+
fn host_absolute_path(segments: &[&str]) -> String {
47+
let mut path = if cfg!(windows) {
48+
PathBuf::from(r"C:\")
49+
} else {
50+
PathBuf::from("/")
51+
};
52+
for segment in segments {
53+
path.push(segment);
54+
}
55+
path.to_string_lossy().into_owned()
56+
}
57+
58+
fn starlark_string(value: &str) -> String {
59+
value.replace('\\', "\\\\").replace('"', "\\\"")
60+
}
61+
4162
#[test]
4263
fn extract_shell_script_preserves_login_flag() {
4364
assert_eq!(
@@ -126,6 +147,25 @@ fn join_program_and_argv_replaces_original_argv_zero() {
126147
);
127148
}
128149

150+
#[test]
151+
fn commands_for_intercepted_exec_policy_defaults_to_wrapper_command() {
152+
let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "bash"])).unwrap();
153+
assert_eq!(
154+
commands_for_intercepted_exec_policy(
155+
&program,
156+
&["not-bash".into(), "-lc".into(), "git status && pwd".into()],
157+
),
158+
(
159+
vec![vec![
160+
program.to_string_lossy().to_string(),
161+
"-lc".to_string(),
162+
"git status && pwd".to_string(),
163+
]],
164+
false
165+
)
166+
);
167+
}
168+
129169
#[test]
130170
fn map_exec_result_preserves_stdout_and_stderr() {
131171
let out = map_exec_result(
@@ -203,6 +243,84 @@ fn shell_request_escalation_execution_is_explicit() {
203243
);
204244
}
205245

246+
#[test]
247+
fn intercepted_exec_policy_uses_host_executable_mappings() {
248+
let git_path = host_absolute_path(&["usr", "bin", "git"]);
249+
let git_path_literal = starlark_string(&git_path);
250+
let policy_src = format!(
251+
r#"
252+
prefix_rule(pattern = ["git", "status"], decision = "prompt")
253+
host_executable(name = "git", paths = ["{git_path_literal}"])
254+
"#
255+
);
256+
let mut parser = PolicyParser::new();
257+
parser.parse("test.rules", &policy_src).unwrap();
258+
let policy = parser.build();
259+
let program = AbsolutePathBuf::try_from(git_path).unwrap();
260+
261+
let evaluation = evaluate_intercepted_exec_policy(
262+
&policy,
263+
&program,
264+
&["git".to_string(), "status".to_string()],
265+
AskForApproval::OnRequest,
266+
&SandboxPolicy::new_read_only_policy(),
267+
SandboxPermissions::UseDefault,
268+
);
269+
270+
assert_eq!(
271+
evaluation,
272+
Evaluation {
273+
decision: Decision::Prompt,
274+
matched_rules: vec![RuleMatch::PrefixRuleMatch {
275+
matched_prefix: vec!["git".to_string(), "status".to_string()],
276+
decision: Decision::Prompt,
277+
resolved_program: Some(program),
278+
justification: None,
279+
}],
280+
}
281+
);
282+
assert!(CoreShellActionProvider::decision_driven_by_policy(
283+
&evaluation.matched_rules,
284+
evaluation.decision
285+
));
286+
}
287+
288+
#[test]
289+
fn intercepted_exec_policy_rejects_disallowed_host_executable_mapping() {
290+
let allowed_git = host_absolute_path(&["usr", "bin", "git"]);
291+
let other_git = host_absolute_path(&["opt", "homebrew", "bin", "git"]);
292+
let allowed_git_literal = starlark_string(&allowed_git);
293+
let policy_src = format!(
294+
r#"
295+
prefix_rule(pattern = ["git", "status"], decision = "prompt")
296+
host_executable(name = "git", paths = ["{allowed_git_literal}"])
297+
"#
298+
);
299+
let mut parser = PolicyParser::new();
300+
parser.parse("test.rules", &policy_src).unwrap();
301+
let policy = parser.build();
302+
let program = AbsolutePathBuf::try_from(other_git.clone()).unwrap();
303+
304+
let evaluation = evaluate_intercepted_exec_policy(
305+
&policy,
306+
&program,
307+
&["git".to_string(), "status".to_string()],
308+
AskForApproval::OnRequest,
309+
&SandboxPolicy::new_read_only_policy(),
310+
SandboxPermissions::UseDefault,
311+
);
312+
313+
assert!(matches!(
314+
evaluation.matched_rules.as_slice(),
315+
[RuleMatch::HeuristicsRuleMatch { command, .. }]
316+
if command == &vec![other_git, "status".to_string()]
317+
));
318+
assert!(!CoreShellActionProvider::decision_driven_by_policy(
319+
&evaluation.matched_rules,
320+
evaluation.decision
321+
));
322+
}
323+
206324
#[cfg(target_os = "macos")]
207325
#[tokio::test]
208326
async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions() {

0 commit comments

Comments
 (0)