From 688425072b91da7e3ac61f8ea442ce53730de4e8 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 8 Jun 2026 07:34:20 -0700 Subject: [PATCH] fix: honor parent approvals for intercepted execs --- .../tools/runtimes/shell/unix_escalation.rs | 91 +++- .../runtimes/shell/unix_escalation_tests.rs | 497 +++++++++++++++--- 2 files changed, 499 insertions(+), 89 deletions(-) diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 71fa89f98030..fb82ad23da84 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -13,10 +13,13 @@ use crate::hook_runtime::run_permission_request_hooks; use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecRequest; use crate::sandboxing::SandboxPermissions; +use crate::session::session::Session; +use crate::session::turn_context::TurnContext; use crate::shell::ShellType; use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::exec_env_for_sandbox_permissions; use crate::tools::runtimes::prepend_zsh_fork_bin_to_path; +use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::PermissionRequestPayload; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::ToolCtx; @@ -99,6 +102,19 @@ fn approval_sandbox_permissions( } } +fn parent_approved_sandbox_override( + sandbox_permissions: SandboxPermissions, + additional_permissions_preapproved: bool, + exec_approval_requirement: &ExecApprovalRequirement, +) -> bool { + sandbox_permissions.requests_sandbox_override() + && (additional_permissions_preapproved + || matches!( + exec_approval_requirement, + ExecApprovalRequirement::NeedsApproval { .. } + )) +} + pub(super) async fn try_run_zsh_fork( req: &ShellRequest, attempt: &SandboxAttempt<'_>, @@ -226,6 +242,11 @@ pub(super) async fn try_run_zsh_fork( sandbox_permissions: req.sandbox_permissions, approval_sandbox_permissions, prompt_permissions: req.additional_permissions.clone(), + parent_sandbox_override_approved: parent_approved_sandbox_override( + req.sandbox_permissions, + req.additional_permissions_preapproved, + &req.exec_approval_requirement, + ), stopwatch: stopwatch.clone(), }; @@ -301,6 +322,11 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( req.additional_permissions_preapproved, ), prompt_permissions: req.additional_permissions.clone(), + parent_sandbox_override_approved: parent_approved_sandbox_override( + req.sandbox_permissions, + req.additional_permissions_preapproved, + &req.exec_approval_requirement, + ), stopwatch: Stopwatch::unlimited(), }; @@ -332,6 +358,7 @@ struct CoreShellActionProvider { sandbox_permissions: SandboxPermissions, approval_sandbox_permissions: SandboxPermissions, prompt_permissions: Option, + parent_sandbox_override_approved: bool, stopwatch: Stopwatch, } @@ -368,6 +395,23 @@ fn execve_prompt_is_rejected_by_policy( } } +async fn execve_prompt_routes_to_guardian(session: &Arc, turn: &Arc) -> bool { + routes_approval_to_guardian(turn) || session.strict_auto_review_enabled_for_turn().await +} + +async fn permission_request_hook_decision( + session: &Arc, + turn: &Arc, + run_id_suffix: &str, + command: &[String], +) -> Option { + let permission_request = PermissionRequestPayload::bash( + codex_shell_command::parse_command::shlex_join(command), + /*description*/ None, + ); + run_permission_request_hooks(session, turn, run_id_suffix, permission_request).await +} + impl CoreShellActionProvider { fn decision_driven_by_policy(matched_rules: &[RuleMatch], decision: Decision) -> bool { matched_rules.iter().any(|rule_match| { @@ -422,20 +466,18 @@ impl CoreShellActionProvider { let call_id = self.call_id.clone(); let approval_id = Some(Uuid::new_v4().to_string()); let source = self.tool_name; - let guardian_review_id = routes_approval_to_guardian(&turn).then(new_guardian_review_id); + let guardian_review_id = execve_prompt_routes_to_guardian(&session, &turn) + .await + .then(new_guardian_review_id); Ok(stopwatch .pause_for(async move { // 1) Run PermissionRequest hooks - let permission_request = PermissionRequestPayload::bash( - codex_shell_command::parse_command::shlex_join(&command), - /*description*/ None, - ); let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone()); - match run_permission_request_hooks( + match permission_request_hook_decision( &session, &turn, &effective_approval_id, - permission_request, + &command, ) .await { @@ -635,6 +677,39 @@ impl EscalationPolicy for CoreShellActionProvider { SandboxPermissions::RequireEscalated => unsandboxed_allowed, SandboxPermissions::WithAdditionalPermissions => true, }; + // The parent shell/unified-exec approval already covered unmatched + // prompts caused by its sandbox override. Explicit exec-policy rules + // still keep their own decisions. Guardian-routed turns keep the + // prompt so Guardian can review each intercepted execve independently. + // PermissionRequest hooks also keep top priority: a hook denial must not + // be bypassed just because the parent process approval was granted. + let routes_execve_prompt_to_guardian = + execve_prompt_routes_to_guardian(&self.session, &self.turn).await; + let decision = if self.parent_sandbox_override_approved + && !routes_execve_prompt_to_guardian + && evaluation.decision == Decision::Prompt + && !decision_driven_by_policy + { + let command = join_program_and_argv(program, argv); + match self + .stopwatch + .pause_for(permission_request_hook_decision( + &self.session, + &self.turn, + &self.call_id, + &command, + )) + .await + { + Some(PermissionRequestDecision::Allow) | None => {} + Some(PermissionRequestDecision::Deny { message }) => { + return Ok(EscalationDecision::deny(Some(message))); + } + } + Decision::Allow + } else { + evaluation.decision + }; let decision_source = if decision_driven_by_policy { DecisionSource::PrefixRule @@ -652,7 +727,7 @@ impl EscalationPolicy for CoreShellActionProvider { ), }; self.process_decision( - evaluation.decision, + decision, needs_escalation, program, argv, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index ee5b9e7db32a..cc99dd9a9580 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -9,8 +9,12 @@ use super::join_program_and_argv; use super::map_exec_result; use crate::config::Constrained; use crate::sandboxing::SandboxPermissions; +use crate::session::session::Session; use crate::session::tests::make_session_and_context; +use crate::session::turn_context::TurnContext; +use crate::state::ActiveTurn; use anyhow::Context; +use codex_config::types::ApprovalsReviewer; use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; use codex_execpolicy::PolicyParser; @@ -40,6 +44,7 @@ use codex_shell_escalation::ExecResult; use codex_shell_escalation::ResolvedPermissionProfile; use codex_shell_escalation::ShellCommandExecutor; use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::hooks::trusted_config_layer_stack; use pretty_assertions::assert_eq; use serde_json::Value; use std::collections::HashMap; @@ -94,6 +99,95 @@ fn test_sandbox_cwd() -> AbsolutePathBuf { AbsolutePathBuf::try_from(host_absolute_path(&["workspace"])).unwrap() } +async fn enable_strict_auto_review_for_session(session: &Session) { + let active_turn = ActiveTurn::default(); + let turn_state = Arc::clone(&active_turn.turn_state); + *session.active_turn.lock().await = Some(active_turn); + turn_state.lock().await.enable_strict_auto_review(); +} + +fn install_execve_permission_request_hook( + session: &Session, + turn_context: &TurnContext, + hook_output: &Value, +) -> anyhow::Result { + std::fs::create_dir_all(&turn_context.config.codex_home) + .context("recreate codex home for hook fixtures")?; + let script_path = turn_context + .config + .codex_home + .join("permission_request_hook.sh"); + let log_path = turn_context + .config + .codex_home + .join("permission_request_hook_log.jsonl"); + let hook_output = hook_output.to_string(); + std::fs::write( + &script_path, + format!( + "#!/bin/sh\ncat > {log_path}\nprintf '%s\\n' {hook_output}\n", + log_path = shlex::try_quote(log_path.to_string_lossy().as_ref())?, + hook_output = shlex::try_quote(&hook_output)?, + ), + ) + .with_context(|| format!("write hook script to {}", script_path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = std::fs::metadata(&script_path) + .with_context(|| format!("read hook script metadata from {}", script_path.display()))? + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&script_path, permissions) + .with_context(|| format!("set hook script permissions on {}", script_path.display()))?; + } + std::fs::write( + turn_context.config.codex_home.join("hooks.json"), + serde_json::json!({ + "hooks": { + "PermissionRequest": [{ + "hooks": [{ + "type": "command", + "command": script_path.display().to_string(), + }] + }] + } + }) + .to_string(), + ) + .context("write hooks.json")?; + let hook_list = codex_hooks::list_hooks(HooksConfig { + feature_enabled: true, + config_layer_stack: Some(turn_context.config.config_layer_stack.clone()), + ..HooksConfig::default() + }); + assert_eq!(hook_list.hooks.len(), 1); + let trusted_config_layer_stack = trusted_config_layer_stack( + &turn_context.config.config_layer_stack, + &turn_context.config.codex_home, + hook_list.hooks, + ); + + let mut hook_shell_argv = session + .user_shell() + .derive_exec_args("", /*use_login_shell*/ false); + let hook_shell_program = hook_shell_argv.remove(0); + let _ = hook_shell_argv.pop(); + session + .services + .hooks + .store(Arc::new(Hooks::new(HooksConfig { + feature_enabled: true, + config_layer_stack: Some(trusted_config_layer_stack), + shell_program: Some(hook_shell_program), + shell_args: hook_shell_argv, + ..HooksConfig::default() + }))); + + Ok(log_path.to_path_buf()) +} + #[test] fn execve_prompt_rejection_keeps_prefix_rules_on_rules_flag() { assert_eq!( @@ -153,6 +247,42 @@ fn approval_sandbox_permissions_only_downgrades_preapproved_additional_permissio ); } +#[test] +fn parent_approved_sandbox_override_tracks_parent_approval_sources() { + assert!(super::parent_approved_sandbox_override( + SandboxPermissions::RequireEscalated, + /*additional_permissions_preapproved*/ false, + &crate::tools::sandboxing::ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + }, + )); + assert!(super::parent_approved_sandbox_override( + SandboxPermissions::WithAdditionalPermissions, + /*additional_permissions_preapproved*/ true, + &crate::tools::sandboxing::ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + }, + )); + assert!(!super::parent_approved_sandbox_override( + SandboxPermissions::UseDefault, + /*additional_permissions_preapproved*/ false, + &crate::tools::sandboxing::ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + }, + )); + assert!(!super::parent_approved_sandbox_override( + SandboxPermissions::RequireEscalated, + /*additional_permissions_preapproved*/ false, + &crate::tools::sandboxing::ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + }, + )); +} + #[test] fn extract_shell_script_preserves_login_flag() { assert_eq!( @@ -433,6 +563,7 @@ async fn preapproved_additional_permissions_escalate_intercepted_exec() -> anyho sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, approval_sandbox_permissions: SandboxPermissions::UseDefault, prompt_permissions: Some(requested_permissions), + parent_sandbox_override_approved: true, stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), }; @@ -457,93 +588,215 @@ async fn preapproved_additional_permissions_escalate_intercepted_exec() -> anyho Ok(()) } -#[tokio::test(flavor = "current_thread")] -async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Result<()> { - let (session, mut turn_context) = make_session_and_context().await; - std::fs::create_dir_all(&turn_context.config.codex_home) - .context("recreate codex home for hook fixtures")?; - let script_path = turn_context - .config - .codex_home - .join("permission_request_hook.py"); - let log_path = turn_context - .config - .codex_home - .join("permission_request_hook_log.jsonl"); - std::fs::write( - &script_path, - format!( - "#!/bin/sh\ncat > {log_path}\nprintf '%s\\n' '{response}'\n", - log_path = shlex::try_quote(log_path.to_string_lossy().as_ref())?, - response = "{\"hookSpecificOutput\":{\"hookEventName\":\"PermissionRequest\",\"decision\":{\"behavior\":\"allow\"}}}", - ), +#[tokio::test] +async fn parent_require_escalated_approval_escalates_intercepted_exec() -> anyhow::Result<()> { + let (session, turn_context) = make_session_and_context().await; + let workdir = test_sandbox_cwd(); + let provider = CoreShellActionProvider { + policy: Arc::new(RwLock::new(codex_execpolicy::Policy::empty())), + session: Arc::new(session), + turn: Arc::new(turn_context), + call_id: "parent-require-escalated".to_string(), + tool_name: GuardianCommandSource::Shell, + approval_policy: AskForApproval::OnRequest, + permission_profile: PermissionProfile::workspace_write(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::RequireEscalated, + approval_sandbox_permissions: SandboxPermissions::RequireEscalated, + prompt_permissions: None, + parent_sandbox_override_approved: true, + stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), + }; + + let action = codex_shell_escalation::EscalationPolicy::determine_action( + &provider, + &AbsolutePathBuf::from_absolute_path("/usr/bin/curl")?, + &["curl".to_string(), "example.com".to_string()], + &workdir, ) - .with_context(|| format!("write hook script to {}", script_path.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; + .await?; - let mut permissions = std::fs::metadata(&script_path) - .with_context(|| format!("read hook script metadata from {}", script_path.display()))? - .permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&script_path, permissions) - .with_context(|| format!("set hook script permissions on {}", script_path.display()))?; - } - std::fs::write( - turn_context.config.codex_home.join("hooks.json"), - serde_json::json!({ - "hooks": { - "PermissionRequest": [{ - "hooks": [{ - "type": "command", - "command": script_path.display().to_string(), - }] - }] - } - }) - .to_string(), + assert_eq!( + action, + codex_shell_escalation::EscalationDecision::Escalate(EscalationExecution::Unsandboxed) + ); + + Ok(()) +} + +#[tokio::test] +async fn execve_prompt_routes_to_guardian_in_strict_auto_review() -> anyhow::Result<()> { + let (session, turn_context) = make_session_and_context().await; + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + + assert!(!super::execve_prompt_routes_to_guardian(&session, &turn_context).await); + + enable_strict_auto_review_for_session(&session).await; + + assert!(super::execve_prompt_routes_to_guardian(&session, &turn_context).await); + + Ok(()) +} + +#[tokio::test] +async fn parent_approval_does_not_bypass_guardian_routed_exec_prompt() -> anyhow::Result<()> { + let (session, mut turn_context) = make_session_and_context().await; + let approval_policy = AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: false, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + }); + turn_context.approval_policy = Constrained::allow_any(approval_policy); + let mut config = (*turn_context.config).clone(); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + turn_context.config = Arc::new(config); + let workdir = test_sandbox_cwd(); + let provider = CoreShellActionProvider { + policy: Arc::new(RwLock::new(codex_execpolicy::Policy::empty())), + session: Arc::new(session), + turn: Arc::new(turn_context), + call_id: "parent-guardian-routed".to_string(), + tool_name: GuardianCommandSource::Shell, + approval_policy, + permission_profile: PermissionProfile::workspace_write(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::RequireEscalated, + approval_sandbox_permissions: SandboxPermissions::RequireEscalated, + prompt_permissions: None, + parent_sandbox_override_approved: true, + stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), + }; + + let action = codex_shell_escalation::EscalationPolicy::determine_action( + &provider, + &AbsolutePathBuf::from_absolute_path("/usr/bin/curl")?, + &["curl".to_string(), "example.com".to_string()], + &workdir, ) - .context("write hooks.json")?; - let config_toml_path = turn_context - .config - .codex_home - .join(codex_config::CONFIG_TOML_FILE); - let hook_list = codex_hooks::list_hooks(HooksConfig { - feature_enabled: true, - config_layer_stack: Some(turn_context.config.config_layer_stack.clone()), - ..HooksConfig::default() + .await?; + + assert_eq!( + action, + codex_shell_escalation::EscalationDecision::Deny { + reason: Some("Execution forbidden by policy".to_string()), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn parent_approval_does_not_bypass_strict_auto_review_exec_prompt() -> anyhow::Result<()> { + let (session, mut turn_context) = make_session_and_context().await; + enable_strict_auto_review_for_session(&session).await; + let approval_policy = AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: false, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, }); - assert_eq!(hook_list.hooks.len(), 1); - let trusted_config_layer_stack = turn_context.config.config_layer_stack.with_user_config( - &config_toml_path, - serde_json::from_value(serde_json::json!({ - "hooks": { - "state": { - hook_list.hooks[0].key.clone(): { - "trusted_hash": hook_list.hooks[0].current_hash.clone(), - }, - }, - }, - })) - .context("build trusted hook state")?, + turn_context.approval_policy = Constrained::allow_any(approval_policy); + let workdir = test_sandbox_cwd(); + let provider = CoreShellActionProvider { + policy: Arc::new(RwLock::new(codex_execpolicy::Policy::empty())), + session: Arc::new(session), + turn: Arc::new(turn_context), + call_id: "parent-strict-auto-review".to_string(), + tool_name: GuardianCommandSource::Shell, + approval_policy, + permission_profile: PermissionProfile::workspace_write(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::RequireEscalated, + approval_sandbox_permissions: SandboxPermissions::RequireEscalated, + prompt_permissions: None, + parent_sandbox_override_approved: true, + stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), + }; + + let action = codex_shell_escalation::EscalationPolicy::determine_action( + &provider, + &AbsolutePathBuf::from_absolute_path("/usr/bin/curl")?, + &["curl".to_string(), "example.com".to_string()], + &workdir, + ) + .await?; + + assert_eq!( + action, + codex_shell_escalation::EscalationDecision::Deny { + reason: Some("Execution forbidden by policy".to_string()), + } ); - let mut hook_shell_argv = session - .user_shell() - .derive_exec_args("", /*use_login_shell*/ false); - let hook_shell_program = hook_shell_argv.remove(0); - let _ = hook_shell_argv.pop(); - session - .services - .hooks - .store(Arc::new(Hooks::new(HooksConfig { - feature_enabled: true, - config_layer_stack: Some(trusted_config_layer_stack), - shell_program: Some(hook_shell_program), - shell_args: hook_shell_argv, - ..HooksConfig::default() - }))); + Ok(()) +} + +#[tokio::test] +async fn parent_approval_does_not_override_intercepted_exec_policy_prompt() -> anyhow::Result<()> { + let (session, turn_context) = make_session_and_context().await; + let mut parser = PolicyParser::new(); + parser.parse( + "test.rules", + r#"prefix_rule(pattern = ["curl"], decision = "prompt")"#, + )?; + let workdir = test_sandbox_cwd(); + let provider = CoreShellActionProvider { + policy: Arc::new(RwLock::new(parser.build())), + session: Arc::new(session), + turn: Arc::new(turn_context), + call_id: "parent-policy-prompt".to_string(), + tool_name: GuardianCommandSource::Shell, + approval_policy: AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: false, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + }), + permission_profile: PermissionProfile::workspace_write(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::RequireEscalated, + approval_sandbox_permissions: SandboxPermissions::RequireEscalated, + prompt_permissions: None, + parent_sandbox_override_approved: true, + stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), + }; + + let action = codex_shell_escalation::EscalationPolicy::determine_action( + &provider, + &AbsolutePathBuf::from_absolute_path("/usr/bin/curl")?, + &["curl".to_string(), "example.com".to_string()], + &workdir, + ) + .await?; + + assert_eq!( + action, + codex_shell_escalation::EscalationDecision::Deny { + reason: Some("Execution forbidden by policy".to_string()), + } + ); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Result<()> { + let (session, mut turn_context) = make_session_and_context().await; + let log_path = install_execve_permission_request_hook( + &session, + &turn_context, + &serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "PermissionRequest", + "decision": { "behavior": "allow" } + } + }), + )?; turn_context.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); turn_context.permission_profile = PermissionProfile::from_runtime_permissions( @@ -568,6 +821,7 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul sandbox_permissions: SandboxPermissions::RequireEscalated, approval_sandbox_permissions: SandboxPermissions::RequireEscalated, prompt_permissions: None, + parent_sandbox_override_approved: false, stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), }; @@ -609,6 +863,85 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul Ok(()) } +#[tokio::test(flavor = "current_thread")] +async fn parent_approval_preserves_execve_permission_request_hook_denial() -> anyhow::Result<()> { + let (session, mut turn_context) = make_session_and_context().await; + let log_path = install_execve_permission_request_hook( + &session, + &turn_context, + &serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": "PermissionRequest", + "decision": { + "behavior": "deny", + "message": "blocked by hook" + } + } + }), + )?; + + turn_context.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + turn_context.permission_profile = PermissionProfile::from_runtime_permissions( + &read_only_file_system_sandbox_policy(), + NetworkSandboxPolicy::Restricted, + ); + let workdir = AbsolutePathBuf::try_from(std::env::current_dir()?)?; + let target = std::env::temp_dir().join("execve-hook-denial.txt"); + let target_str = target.display().to_string(); + let command = vec!["touch".to_string(), target_str.clone()]; + let expected_hook_command = + codex_shell_command::parse_command::shlex_join(&["/usr/bin/touch".to_string(), target_str]); + let provider = CoreShellActionProvider { + policy: Arc::new(RwLock::new(codex_execpolicy::Policy::empty())), + session: Arc::new(session), + turn: Arc::new(turn_context), + call_id: "execve-parent-hook-denial".to_string(), + tool_name: GuardianCommandSource::Shell, + approval_policy: AskForApproval::OnRequest, + permission_profile: PermissionProfile::read_only(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::RequireEscalated, + approval_sandbox_permissions: SandboxPermissions::RequireEscalated, + prompt_permissions: None, + parent_sandbox_override_approved: true, + stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), + }; + + let action = tokio::time::timeout( + Duration::from_secs(5), + codex_shell_escalation::EscalationPolicy::determine_action( + &provider, + &AbsolutePathBuf::from_absolute_path("/usr/bin/touch") + .context("build touch absolute path")?, + &command, + &workdir, + ), + ) + .await + .context("timed out waiting for execve permission hook decision")??; + + assert_eq!( + action, + codex_shell_escalation::EscalationDecision::Deny { + reason: Some("blocked by hook".to_string()), + } + ); + + let hook_inputs: Vec = std::fs::read_to_string(&log_path) + .with_context(|| format!("read hook log at {}", log_path.display()))? + .lines() + .map(serde_json::from_str) + .collect::>() + .context("parse hook log")?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!( + hook_inputs[0]["tool_input"]["command"], + expected_hook_command + ); + + Ok(()) +} + #[test] fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_parsing_disabled() { let policy_src = r#"prefix_rule(pattern = ["npm", "publish"], decision = "prompt")"#; @@ -778,6 +1111,7 @@ prefix_rule(pattern = ["{cat_path_literal}"], decision = "allow") sandbox_permissions: SandboxPermissions::UseDefault, approval_sandbox_permissions: SandboxPermissions::UseDefault, prompt_permissions: None, + parent_sandbox_override_approved: false, stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), }; @@ -820,6 +1154,7 @@ async fn denied_reads_keep_granular_sandbox_rejection_for_escalation() -> anyhow sandbox_permissions: SandboxPermissions::RequireEscalated, approval_sandbox_permissions: SandboxPermissions::RequireEscalated, prompt_permissions: None, + parent_sandbox_override_approved: false, stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), };