@@ -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,171 @@ 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 = ["npm", "publish"], 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" , "zsh" ] ) ) . unwrap ( ) ;
252+
253+ let enable_intercepted_exec_policy_shell_wrapper_parsing = false ;
254+ let evaluation = evaluate_intercepted_exec_policy (
255+ & policy,
256+ & program,
257+ & [
258+ "zsh" . to_string ( ) ,
259+ "-lc" . to_string ( ) ,
260+ "npm publish" . to_string ( ) ,
261+ ] ,
262+ AskForApproval :: OnRequest ,
263+ & SandboxPolicy :: new_read_only_policy ( ) ,
264+ SandboxPermissions :: UseDefault ,
265+ enable_intercepted_exec_policy_shell_wrapper_parsing,
266+ ) ;
267+
268+ assert ! (
269+ matches!(
270+ evaluation. matched_rules. as_slice( ) ,
271+ [ RuleMatch :: HeuristicsRuleMatch { command, decision: Decision :: Allow } ]
272+ if command == & vec![
273+ program. to_string_lossy( ) . to_string( ) ,
274+ "-lc" . to_string( ) ,
275+ "npm publish" . to_string( ) ,
276+ ]
277+ ) ,
278+ r#"This is allowed because when shell wrapper parsing is disabled,
279+ the policy evaluation does not try to parse the shell command and instead
280+ matches the whole command line with the resolved program path, which in this
281+ case is `/bin/zsh` followed by some arguments.
282+
283+ Because there is no policy rule for `/bin/zsh` or `zsh`, the decision is to
284+ allow the command and let the sandbox be responsible for enforcing any
285+ restrictions.
286+
287+ That said, if /bin/zsh is the zsh-fork, then the execve wrapper should
288+ ultimately intercept the `npm publish` command and apply the policy rules to it.
289+ "#
290+ ) ;
291+ }
292+
293+ #[ test]
294+ fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled ( ) {
295+ let policy_src = r#"prefix_rule(pattern = ["npm", "publish"], decision = "prompt")"# ;
296+ let mut parser = PolicyParser :: new ( ) ;
297+ parser. parse ( "test.rules" , policy_src) . unwrap ( ) ;
298+ let policy = parser. build ( ) ;
299+ let program = AbsolutePathBuf :: try_from ( host_absolute_path ( & [ "bin" , "bash" ] ) ) . unwrap ( ) ;
300+
301+ let enable_intercepted_exec_policy_shell_wrapper_parsing = true ;
302+ let evaluation = evaluate_intercepted_exec_policy (
303+ & policy,
304+ & program,
305+ & [
306+ "bash" . to_string ( ) ,
307+ "-lc" . to_string ( ) ,
308+ "npm publish" . to_string ( ) ,
309+ ] ,
310+ AskForApproval :: OnRequest ,
311+ & SandboxPolicy :: new_read_only_policy ( ) ,
312+ SandboxPermissions :: UseDefault ,
313+ enable_intercepted_exec_policy_shell_wrapper_parsing,
314+ ) ;
315+
316+ assert_eq ! (
317+ evaluation,
318+ Evaluation {
319+ decision: Decision :: Prompt ,
320+ matched_rules: vec![ RuleMatch :: PrefixRuleMatch {
321+ matched_prefix: vec![ "npm" . to_string( ) , "publish" . to_string( ) ] ,
322+ decision: Decision :: Prompt ,
323+ resolved_program: None ,
324+ justification: None ,
325+ } ] ,
326+ }
327+ ) ;
328+ }
329+
330+ #[ test]
331+ fn intercepted_exec_policy_uses_host_executable_mappings ( ) {
332+ let git_path = host_absolute_path ( & [ "usr" , "bin" , "git" ] ) ;
333+ let git_path_literal = starlark_string ( & git_path) ;
334+ let policy_src = format ! (
335+ r#"
336+ prefix_rule(pattern = ["git", "status"], decision = "prompt")
337+ host_executable(name = "git", paths = ["{git_path_literal}"])
338+ "#
339+ ) ;
340+ let mut parser = PolicyParser :: new ( ) ;
341+ parser. parse ( "test.rules" , & policy_src) . unwrap ( ) ;
342+ let policy = parser. build ( ) ;
343+ let program = AbsolutePathBuf :: try_from ( git_path) . unwrap ( ) ;
344+
345+ let evaluation = evaluate_intercepted_exec_policy (
346+ & policy,
347+ & program,
348+ & [ "git" . to_string ( ) , "status" . to_string ( ) ] ,
349+ AskForApproval :: OnRequest ,
350+ & SandboxPolicy :: new_read_only_policy ( ) ,
351+ SandboxPermissions :: UseDefault ,
352+ false ,
353+ ) ;
354+
355+ assert_eq ! (
356+ evaluation,
357+ Evaluation {
358+ decision: Decision :: Prompt ,
359+ matched_rules: vec![ RuleMatch :: PrefixRuleMatch {
360+ matched_prefix: vec![ "git" . to_string( ) , "status" . to_string( ) ] ,
361+ decision: Decision :: Prompt ,
362+ resolved_program: Some ( program) ,
363+ justification: None ,
364+ } ] ,
365+ }
366+ ) ;
367+ assert ! ( CoreShellActionProvider :: decision_driven_by_policy(
368+ & evaluation. matched_rules,
369+ evaluation. decision
370+ ) ) ;
371+ }
372+
373+ #[ test]
374+ fn intercepted_exec_policy_rejects_disallowed_host_executable_mapping ( ) {
375+ let allowed_git = host_absolute_path ( & [ "usr" , "bin" , "git" ] ) ;
376+ let other_git = host_absolute_path ( & [ "opt" , "homebrew" , "bin" , "git" ] ) ;
377+ let allowed_git_literal = starlark_string ( & allowed_git) ;
378+ let policy_src = format ! (
379+ r#"
380+ prefix_rule(pattern = ["git", "status"], decision = "prompt")
381+ host_executable(name = "git", paths = ["{allowed_git_literal}"])
382+ "#
383+ ) ;
384+ let mut parser = PolicyParser :: new ( ) ;
385+ parser. parse ( "test.rules" , & policy_src) . unwrap ( ) ;
386+ let policy = parser. build ( ) ;
387+ let program = AbsolutePathBuf :: try_from ( other_git. clone ( ) ) . unwrap ( ) ;
388+
389+ let evaluation = evaluate_intercepted_exec_policy (
390+ & policy,
391+ & program,
392+ & [ "git" . to_string ( ) , "status" . to_string ( ) ] ,
393+ AskForApproval :: OnRequest ,
394+ & SandboxPolicy :: new_read_only_policy ( ) ,
395+ SandboxPermissions :: UseDefault ,
396+ false ,
397+ ) ;
398+
399+ assert ! ( matches!(
400+ evaluation. matched_rules. as_slice( ) ,
401+ [ RuleMatch :: HeuristicsRuleMatch { command, .. } ]
402+ if command == & vec![ other_git, "status" . to_string( ) ]
403+ ) ) ;
404+ assert ! ( !CoreShellActionProvider :: decision_driven_by_policy(
405+ & evaluation. matched_rules,
406+ evaluation. decision
407+ ) ) ;
408+ }
409+
206410#[ cfg( target_os = "macos" ) ]
207411#[ tokio:: test]
208412async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions ( ) {
0 commit comments