@@ -2,6 +2,8 @@ use super::CoreShellActionProvider;
22#[ cfg( target_os = "macos" ) ]
33use super :: CoreShellCommandExecutor ;
44use super :: ParsedShellCommand ;
5+ use super :: commands_for_intercepted_exec_policy;
6+ use super :: evaluate_intercepted_exec_policy;
57use super :: extract_shell_script;
68use super :: join_program_and_argv;
79use super :: map_exec_result;
@@ -12,14 +14,16 @@ use crate::config::Permissions;
1214#[ cfg( target_os = "macos" ) ]
1315use crate :: config:: types:: ShellEnvironmentPolicy ;
1416use crate :: exec:: SandboxType ;
15- #[ cfg( target_os = "macos" ) ]
1617use crate :: protocol:: AskForApproval ;
1718use crate :: protocol:: ReadOnlyAccess ;
1819use crate :: protocol:: SandboxPolicy ;
19- #[ cfg( target_os = "macos" ) ]
2020use crate :: sandboxing:: SandboxPermissions ;
2121#[ cfg( target_os = "macos" ) ]
2222use 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" ) ]
2428use codex_protocol:: config_types:: WindowsSandboxLevel ;
2529use codex_protocol:: models:: FileSystemPermissions ;
@@ -36,8 +40,25 @@ use codex_utils_absolute_path::AbsolutePathBuf;
3640use pretty_assertions:: assert_eq;
3741#[ cfg( target_os = "macos" ) ]
3842use std:: collections:: HashMap ;
43+ use std:: path:: PathBuf ;
3944use 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]
4263fn extract_shell_script_preserves_login_flag ( ) {
4364 assert_eq ! (
@@ -126,6 +147,24 @@ fn join_program_and_argv_replaces_original_argv_zero() {
126147 ) ;
127148}
128149
150+ #[ test]
151+ fn commands_for_intercepted_exec_policy_parses_plain_shell_wrappers ( ) {
152+ let program = AbsolutePathBuf :: try_from ( host_absolute_path ( & [ "bin" , "bash" ] ) ) . unwrap ( ) ;
153+ let candidate_commands = commands_for_intercepted_exec_policy (
154+ & program,
155+ & [ "not-bash" . into ( ) , "-lc" . into ( ) , "git status && pwd" . into ( ) ] ,
156+ ) ;
157+
158+ assert_eq ! (
159+ candidate_commands. commands,
160+ vec![
161+ vec![ "git" . to_string( ) , "status" . to_string( ) ] ,
162+ vec![ "pwd" . to_string( ) ] ,
163+ ]
164+ ) ;
165+ assert ! ( !candidate_commands. used_complex_parsing) ;
166+ }
167+
129168#[ test]
130169fn map_exec_result_preserves_stdout_and_stderr ( ) {
131170 let out = map_exec_result (
@@ -203,6 +242,155 @@ fn shell_request_escalation_execution_is_explicit() {
203242 ) ;
204243}
205244
245+ #[ test]
246+ fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_parsing_disabled ( ) {
247+ let policy_src = r#"prefix_rule(pattern = ["git", "status"], decision = "prompt")"# ;
248+ let mut parser = PolicyParser :: new ( ) ;
249+ parser. parse ( "test.rules" , policy_src) . unwrap ( ) ;
250+ let policy = parser. build ( ) ;
251+ let program = AbsolutePathBuf :: try_from ( host_absolute_path ( & [ "bin" , "bash" ] ) ) . unwrap ( ) ;
252+
253+ let evaluation = evaluate_intercepted_exec_policy (
254+ & policy,
255+ & program,
256+ & [
257+ "bash" . to_string ( ) ,
258+ "-lc" . to_string ( ) ,
259+ "git status" . to_string ( ) ,
260+ ] ,
261+ AskForApproval :: OnRequest ,
262+ & SandboxPolicy :: new_read_only_policy ( ) ,
263+ SandboxPermissions :: UseDefault ,
264+ false ,
265+ ) ;
266+
267+ assert ! ( matches!(
268+ evaluation. matched_rules. as_slice( ) ,
269+ [ RuleMatch :: HeuristicsRuleMatch { command, decision: Decision :: Allow } ]
270+ if command == & vec![
271+ program. to_string_lossy( ) . to_string( ) ,
272+ "-lc" . to_string( ) ,
273+ "git status" . to_string( ) ,
274+ ]
275+ ) ) ;
276+ }
277+
278+ #[ test]
279+ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled ( ) {
280+ let policy_src = r#"prefix_rule(pattern = ["git", "status"], decision = "prompt")"# ;
281+ let mut parser = PolicyParser :: new ( ) ;
282+ parser. parse ( "test.rules" , policy_src) . unwrap ( ) ;
283+ let policy = parser. build ( ) ;
284+ let program = AbsolutePathBuf :: try_from ( host_absolute_path ( & [ "bin" , "bash" ] ) ) . unwrap ( ) ;
285+
286+ let evaluation = evaluate_intercepted_exec_policy (
287+ & policy,
288+ & program,
289+ & [
290+ "bash" . to_string ( ) ,
291+ "-lc" . to_string ( ) ,
292+ "git status" . to_string ( ) ,
293+ ] ,
294+ AskForApproval :: OnRequest ,
295+ & SandboxPolicy :: new_read_only_policy ( ) ,
296+ SandboxPermissions :: UseDefault ,
297+ true ,
298+ ) ;
299+
300+ assert_eq ! (
301+ evaluation,
302+ Evaluation {
303+ decision: Decision :: Prompt ,
304+ matched_rules: vec![ RuleMatch :: PrefixRuleMatch {
305+ matched_prefix: vec![ "git" . to_string( ) , "status" . to_string( ) ] ,
306+ decision: Decision :: Prompt ,
307+ resolved_program: None ,
308+ justification: None ,
309+ } ] ,
310+ }
311+ ) ;
312+ }
313+
314+ #[ test]
315+ fn intercepted_exec_policy_uses_host_executable_mappings ( ) {
316+ let git_path = host_absolute_path ( & [ "usr" , "bin" , "git" ] ) ;
317+ let git_path_literal = starlark_string ( & git_path) ;
318+ let policy_src = format ! (
319+ r#"
320+ prefix_rule(pattern = ["git", "status"], decision = "prompt")
321+ host_executable(name = "git", paths = ["{git_path_literal}"])
322+ "#
323+ ) ;
324+ let mut parser = PolicyParser :: new ( ) ;
325+ parser. parse ( "test.rules" , & policy_src) . unwrap ( ) ;
326+ let policy = parser. build ( ) ;
327+ let program = AbsolutePathBuf :: try_from ( git_path) . unwrap ( ) ;
328+
329+ let evaluation = evaluate_intercepted_exec_policy (
330+ & policy,
331+ & program,
332+ & [ "git" . to_string ( ) , "status" . to_string ( ) ] ,
333+ AskForApproval :: OnRequest ,
334+ & SandboxPolicy :: new_read_only_policy ( ) ,
335+ SandboxPermissions :: UseDefault ,
336+ false ,
337+ ) ;
338+
339+ assert_eq ! (
340+ evaluation,
341+ Evaluation {
342+ decision: Decision :: Prompt ,
343+ matched_rules: vec![ RuleMatch :: PrefixRuleMatch {
344+ matched_prefix: vec![ "git" . to_string( ) , "status" . to_string( ) ] ,
345+ decision: Decision :: Prompt ,
346+ resolved_program: Some ( program) ,
347+ justification: None ,
348+ } ] ,
349+ }
350+ ) ;
351+ assert ! ( CoreShellActionProvider :: decision_driven_by_policy(
352+ & evaluation. matched_rules,
353+ evaluation. decision
354+ ) ) ;
355+ }
356+
357+ #[ test]
358+ fn intercepted_exec_policy_rejects_disallowed_host_executable_mapping ( ) {
359+ let allowed_git = host_absolute_path ( & [ "usr" , "bin" , "git" ] ) ;
360+ let other_git = host_absolute_path ( & [ "opt" , "homebrew" , "bin" , "git" ] ) ;
361+ let allowed_git_literal = starlark_string ( & allowed_git) ;
362+ let policy_src = format ! (
363+ r#"
364+ prefix_rule(pattern = ["git", "status"], decision = "prompt")
365+ host_executable(name = "git", paths = ["{allowed_git_literal}"])
366+ "#
367+ ) ;
368+ let mut parser = PolicyParser :: new ( ) ;
369+ parser. parse ( "test.rules" , & policy_src) . unwrap ( ) ;
370+ let policy = parser. build ( ) ;
371+ let program = AbsolutePathBuf :: try_from ( other_git. clone ( ) ) . unwrap ( ) ;
372+
373+ let evaluation = evaluate_intercepted_exec_policy (
374+ & policy,
375+ & program,
376+ & [ "git" . to_string ( ) , "status" . to_string ( ) ] ,
377+ AskForApproval :: OnRequest ,
378+ & SandboxPolicy :: new_read_only_policy ( ) ,
379+ SandboxPermissions :: UseDefault ,
380+ false ,
381+ ) ;
382+
383+ assert ! ( matches!(
384+ evaluation. matched_rules. as_slice( ) ,
385+ [ RuleMatch :: HeuristicsRuleMatch { command, .. } ]
386+ if command == & vec![ other_git, "status" . to_string( ) ]
387+ ) ) ;
388+ assert ! ( !CoreShellActionProvider :: decision_driven_by_policy(
389+ & evaluation. matched_rules,
390+ evaluation. decision
391+ ) ) ;
392+ }
393+
206394#[ cfg( target_os = "macos" ) ]
207395#[ tokio:: test]
208396async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions ( ) {
0 commit comments