From 254cf94e3822c171e80d9f3ec100202efece25c5 Mon Sep 17 00:00:00 2001 From: celia-oai Date: Tue, 17 Mar 2026 12:10:15 -0700 Subject: [PATCH] changes --- .../tools/runtimes/shell/unix_escalation.rs | 253 +++++++++++-- .../runtimes/shell/unix_escalation_tests.rs | 16 + codex-rs/core/tests/common/zsh_fork.rs | 29 +- codex-rs/core/tests/suite/skill_approval.rs | 349 ++++++++++++++++++ 4 files changed, 607 insertions(+), 40 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 89bf4d424c..ed72c1346b 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -9,8 +9,10 @@ use crate::features::Feature; use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; +use crate::path_utils; use crate::sandboxing::ExecRequest; use crate::sandboxing::SandboxPermissions; +use crate::sandboxing::merge_permission_profiles; use crate::shell::ShellType; use crate::skills::SkillMetadata; use crate::tools::runtimes::ExecveSessionApproval; @@ -50,6 +52,7 @@ use codex_shell_escalation::ShellCommandExecutor; use codex_shell_escalation::Stopwatch; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -62,6 +65,14 @@ pub(crate) struct PreparedUnifiedExecZshFork { pub(crate) escalation_session: EscalationSession, } +enum DirectSkillScriptOverride { + Denied(ExecToolCallOutput), + RunDirect { + command: Vec, + additional_permissions: PermissionProfile, + }, +} + const PROMPT_CONFLICT_REASON: &str = "approval required by policy, but AskForApproval is set to Never"; const REJECT_SANDBOX_APPROVAL_REASON: &str = @@ -106,7 +117,103 @@ pub(super) async fn try_run_zsh_fork( return Ok(None); } - let spec = build_command_spec( + let workdir = AbsolutePathBuf::try_from(req.cwd.clone()) + .map_err(|err| ToolError::Rejected(err.to_string()))?; + let base_spec = build_command_spec( + command, + &req.cwd, + &req.env, + req.timeout_ms.into(), + req.sandbox_permissions, + req.additional_permissions.clone(), + req.justification.clone(), + )?; + let base_sandbox_exec_request = attempt + .env_for(base_spec, req.network.as_ref()) + .map_err(|err| ToolError::Codex(err.into()))?; + let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?; + let effective_timeout = Duration::from_millis( + req.timeout_ms + .unwrap_or(crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS), + ); + let exec_policy = Arc::new(RwLock::new( + ctx.session.services.exec_policy.current().as_ref().clone(), + )); + let main_execve_wrapper_exe = ctx + .session + .services + .main_execve_wrapper_exe + .clone() + .ok_or_else(|| { + ToolError::Rejected( + "zsh fork feature enabled, but execve wrapper is not configured".to_string(), + ) + })?; + let exec_params = ExecParams { + command: script.clone(), + workdir: req.cwd.to_string_lossy().to_string(), + timeout_ms: Some(effective_timeout.as_millis() as u64), + login: Some(login), + }; + + // Note that Stopwatch starts immediately upon creation, so currently we try + // to minimize the time between creating the Stopwatch and starting the + // escalation server. + let stopwatch = Stopwatch::new(effective_timeout); + let cancel_token = stopwatch.cancellation_token(); + let base_approval_sandbox_permissions = approval_sandbox_permissions( + req.sandbox_permissions, + req.additional_permissions_preapproved, + ); + let base_escalation_policy = CoreShellActionProvider { + policy: Arc::clone(&exec_policy), + session: Arc::clone(&ctx.session), + turn: Arc::clone(&ctx.turn), + call_id: ctx.call_id.clone(), + tool_name: "shell", + approval_policy: ctx.turn.approval_policy.value(), + sandbox_policy: base_sandbox_exec_request.sandbox_policy.clone(), + file_system_sandbox_policy: base_sandbox_exec_request.file_system_sandbox_policy.clone(), + network_sandbox_policy: base_sandbox_exec_request.network_sandbox_policy, + sandbox_permissions: req.sandbox_permissions, + approval_sandbox_permissions: base_approval_sandbox_permissions, + prompt_permissions: req.additional_permissions.clone(), + stopwatch: stopwatch.clone(), + }; + match resolve_direct_skill_script_override( + &base_escalation_policy, + &script, + &workdir, + req.additional_permissions.as_ref(), + ) + .await + .map_err(|err| ToolError::Rejected(err.to_string()))? + { + Some(DirectSkillScriptOverride::Denied(output)) => return Ok(Some(output)), + Some(DirectSkillScriptOverride::RunDirect { + command, + additional_permissions, + }) => { + let direct_spec = build_command_spec( + &command, + &req.cwd, + &req.env, + req.timeout_ms.into(), + SandboxPermissions::WithAdditionalPermissions, + Some(additional_permissions), + req.justification.clone(), + )?; + let direct_exec_request = attempt + .env_for(direct_spec, req.network.as_ref()) + .map_err(|err| ToolError::Codex(err.into()))?; + let result = crate::sandboxing::execute_env(direct_exec_request, None) + .await + .map_err(|err| ToolError::Rejected(err.to_string()))?; + return Ok(Some(result)); + } + None => {} + } + let final_spec = build_command_spec( command, &req.cwd, &req.env, @@ -116,7 +223,7 @@ pub(super) async fn try_run_zsh_fork( req.justification.clone(), )?; let sandbox_exec_request = attempt - .env_for(spec, req.network.as_ref()) + .env_for(final_spec, req.network.as_ref()) .map_err(|err| ToolError::Codex(err.into()))?; let crate::sandboxing::ExecRequest { command, @@ -134,14 +241,6 @@ pub(super) async fn try_run_zsh_fork( justification, arg0, } = sandbox_exec_request; - let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?; - let effective_timeout = Duration::from_millis( - req.timeout_ms - .unwrap_or(crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS), - ); - let exec_policy = Arc::new(RwLock::new( - ctx.session.services.exec_policy.current().as_ref().clone(), - )); let command_executor = CoreShellCommandExecutor { command, cwd: sandbox_cwd, @@ -165,32 +264,6 @@ pub(super) async fn try_run_zsh_fork( codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(), use_legacy_landlock: ctx.turn.features.use_legacy_landlock(), }; - let main_execve_wrapper_exe = ctx - .session - .services - .main_execve_wrapper_exe - .clone() - .ok_or_else(|| { - ToolError::Rejected( - "zsh fork feature enabled, but execve wrapper is not configured".to_string(), - ) - })?; - let exec_params = ExecParams { - command: script, - workdir: req.cwd.to_string_lossy().to_string(), - timeout_ms: Some(effective_timeout.as_millis() as u64), - login: Some(login), - }; - - // Note that Stopwatch starts immediately upon creation, so currently we try - // to minimize the time between creating the Stopwatch and starting the - // escalation server. - let stopwatch = Stopwatch::new(effective_timeout); - let cancel_token = stopwatch.cancellation_token(); - let approval_sandbox_permissions = approval_sandbox_permissions( - req.sandbox_permissions, - req.additional_permissions_preapproved, - ); let escalation_policy = CoreShellActionProvider { policy: Arc::clone(&exec_policy), session: Arc::clone(&ctx.session), @@ -202,11 +275,13 @@ pub(super) async fn try_run_zsh_fork( file_system_sandbox_policy: command_executor.file_system_sandbox_policy.clone(), network_sandbox_policy: command_executor.network_sandbox_policy, sandbox_permissions: req.sandbox_permissions, - approval_sandbox_permissions, + approval_sandbox_permissions: approval_sandbox_permissions( + req.sandbox_permissions, + req.additional_permissions_preapproved, + ), prompt_permissions: req.additional_permissions.clone(), stopwatch: stopwatch.clone(), }; - let escalate_server = EscalateServer::new( shell_zsh_path.clone(), main_execve_wrapper_exe, @@ -1150,6 +1225,108 @@ fn join_program_and_argv(program: &AbsolutePathBuf, argv: &[String]) -> Vec>() } +async fn resolve_direct_skill_script_override( + action_provider: &CoreShellActionProvider, + script: &str, + workdir: &AbsolutePathBuf, + additional_permissions: Option<&PermissionProfile>, +) -> anyhow::Result> { + let Some((program, argv)) = resolve_direct_shell_command(script, workdir) else { + return Ok(None); + }; + let Some(skill) = action_provider.find_skill(&program).await else { + return Ok(None); + }; + + match action_provider + .determine_action(&program, &argv, workdir) + .await? + { + EscalationDecision::Deny { reason } => Ok(Some(DirectSkillScriptOverride::Denied( + denied_exec_tool_call_output(reason.as_deref()), + ))), + EscalationDecision::Escalate(EscalationExecution::Permissions( + EscalationPermissions::PermissionProfile(permission_profile), + )) => Ok(Some(DirectSkillScriptOverride::RunDirect { + command: join_program_and_argv(&program, &argv), + additional_permissions: merge_permission_profiles( + additional_permissions, + Some(&permission_profile), + ) + .unwrap_or(permission_profile), + })), + EscalationDecision::Escalate(EscalationExecution::Permissions( + EscalationPermissions::Permissions(_), + )) + | EscalationDecision::Escalate(EscalationExecution::TurnDefault) + | EscalationDecision::Escalate(EscalationExecution::Unsandboxed) + | EscalationDecision::Run => Ok(None), + } +} + +fn resolve_direct_shell_command( + script: &str, + workdir: &AbsolutePathBuf, +) -> Option<(AbsolutePathBuf, Vec)> { + let command = parse_direct_shell_command(script)?; + let (program, args) = command.split_first()?; + let program_path = Path::new(program); + if !program_path.is_absolute() && !program.contains('/') { + return None; + } + let program = if program_path.is_absolute() { + AbsolutePathBuf::from_absolute_path(program_path).ok()? + } else { + AbsolutePathBuf::resolve_path_against_base(program_path, workdir.as_path()).ok()? + }; + let normalized_program = path_utils::normalize_for_path_comparison(program.as_path()) + .ok() + .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()) + .unwrap_or(program); + let argv = std::iter::once(normalized_program.to_string_lossy().to_string()) + .chain(args.iter().cloned()) + .collect::>(); + Some((normalized_program, argv)) +} + +fn parse_direct_shell_command(script: &str) -> Option> { + let shell_command = vec!["zsh".to_string(), "-c".to_string(), script.to_string()]; + if let Some(commands) = parse_shell_lc_plain_commands(&shell_command) { + let [command] = commands.as_slice() else { + return None; + }; + return Some(command.clone()); + } + if let Some(command) = parse_shell_lc_single_command_prefix(&shell_command) { + return Some(command); + } + if script.contains("/.codex/shell_snapshots/") { + let tail = script + .rsplit("\n\n") + .find(|chunk| !chunk.trim().is_empty() && *chunk != script)?; + let tail_shell_command = vec!["zsh".to_string(), "-c".to_string(), tail.to_string()]; + if let Some(commands) = parse_shell_lc_plain_commands(&tail_shell_command) { + let [command] = commands.as_slice() else { + return None; + }; + return Some(command.clone()); + } + } + None +} + +fn denied_exec_tool_call_output(reason: Option<&str>) -> ExecToolCallOutput { + let stderr = reason + .map(|reason| format!("Execution denied: {reason}\n")) + .unwrap_or_else(|| "Execution denied\n".to_string()); + ExecToolCallOutput { + exit_code: 1, + stderr: crate::exec::StreamOutput::new(stderr.clone()), + aggregated_output: crate::exec::StreamOutput::new(stderr), + ..Default::default() + } +} + #[cfg(test)] #[path = "unix_escalation_tests.rs"] mod tests; 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 37c1b53a13..da233925cb 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 @@ -8,6 +8,7 @@ use super::evaluate_intercepted_exec_policy; use super::extract_shell_script; use super::join_program_and_argv; use super::map_exec_result; +use super::parse_direct_shell_command; #[cfg(target_os = "macos")] use crate::config::Constrained; #[cfg(target_os = "macos")] @@ -238,6 +239,21 @@ fn extract_shell_script_supports_wrapped_command_prefixes() { ); } +#[test] +fn parse_direct_shell_command_supports_snapshot_wrapped_command() { + let script = "\ +if . '/Users/celia/.codex/shell_snapshots/session.sh' >/dev/null 2>&1; then :; fi + +/Users/celia/code/codex/.codex/skills/proxy-a/scripts/fetch_example.sh"; + + assert_eq!( + parse_direct_shell_command(script), + Some(vec![ + "/Users/celia/code/codex/.codex/skills/proxy-a/scripts/fetch_example.sh".to_string(), + ]), + ); +} + #[test] fn extract_shell_script_rejects_unsupported_shell_invocation() { let err = extract_shell_script(&[ diff --git a/codex-rs/core/tests/common/zsh_fork.rs b/codex-rs/core/tests/common/zsh_fork.rs index ff9509699e..049530ad22 100644 --- a/codex-rs/core/tests/common/zsh_fork.rs +++ b/codex-rs/core/tests/common/zsh_fork.rs @@ -23,6 +23,7 @@ impl ZshForkRuntime { config: &mut Config, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, + allow_login_shell: bool, ) { config .features @@ -34,7 +35,7 @@ impl ZshForkRuntime { .expect("test config should allow feature update"); config.zsh_path = Some(self.zsh_path.clone()); config.main_execve_wrapper_exe = Some(self.main_execve_wrapper_exe.clone()); - config.permissions.allow_login_shell = false; + config.permissions.allow_login_shell = allow_login_shell; config.permissions.approval_policy = Constrained::allow_any(approval_policy); config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy); } @@ -86,7 +87,31 @@ where let mut builder = test_codex() .with_pre_build_hook(pre_build_hook) .with_config(move |config| { - runtime.apply_to_config(config, approval_policy, sandbox_policy); + runtime.apply_to_config( + config, + approval_policy, + sandbox_policy, + /*allow_login_shell*/ false, + ); + }); + builder.build(server).await +} + +pub async fn build_zsh_fork_test_with_login_shell( + server: &wiremock::MockServer, + runtime: ZshForkRuntime, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, + allow_login_shell: bool, + pre_build_hook: F, +) -> Result +where + F: FnOnce(&Path) + Send + 'static, +{ + let mut builder = test_codex() + .with_pre_build_hook(pre_build_hook) + .with_config(move |config| { + runtime.apply_to_config(config, approval_policy, sandbox_policy, allow_login_shell); }); builder.build(server).await } diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index b5fda12ae0..52ab65b9ba 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -21,6 +21,7 @@ use core_test_support::test_codex::TestCodex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; use core_test_support::zsh_fork::build_zsh_fork_test; +use core_test_support::zsh_fork::build_zsh_fork_test_with_login_shell; use core_test_support::zsh_fork::restrictive_workspace_write_policy; use core_test_support::zsh_fork::zsh_fork_runtime; use pretty_assertions::assert_eq; @@ -120,6 +121,50 @@ description: {name} skill Ok(script_path) } +#[cfg(unix)] +fn write_project_skill_with_shell_script_contents( + workspace_root: &Path, + name: &str, + script_name: &str, + script_contents: &str, +) -> Result { + use std::os::unix::fs::PermissionsExt; + + let skill_dir = workspace_root.join(".codex").join("skills").join(name); + let scripts_dir = skill_dir.join("scripts"); + let metadata_dir = skill_dir.join("agents"); + fs::create_dir_all(&scripts_dir)?; + fs::create_dir_all(&metadata_dir)?; + fs::write( + skill_dir.join("SKILL.md"), + format!( + r#"--- +name: {name} +description: {name} skill +--- +"# + ), + )?; + + let script_path = scripts_dir.join(script_name); + fs::write(&script_path, script_contents)?; + let mut permissions = fs::metadata(&script_path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&script_path, permissions)?; + Ok(script_path) +} + +fn write_project_skill_metadata(workspace_root: &Path, name: &str, contents: &str) -> Result<()> { + let metadata_dir = workspace_root + .join(".codex") + .join("skills") + .join(name) + .join("agents"); + fs::create_dir_all(&metadata_dir)?; + fs::write(metadata_dir.join("openai.yaml"), contents)?; + Ok(()) +} + fn skill_script_command(test: &TestCodex, script_name: &str) -> Result<(String, String)> { let script_path = fs::canonicalize( test.codex_home_path() @@ -669,6 +714,310 @@ async fn shell_zsh_fork_skill_without_permissions_inherits_turn_sandbox() -> Res Ok(()) } +/// Permissionless skills should inherit the turn sandbox even when the turn is +/// read-only, rather than running under a distinct skill-specific sandbox. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_zsh_fork_skill_without_permissions_inherits_turn_sandbox_when_read_only() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let Some(runtime) = zsh_fork_runtime("zsh-fork read-only inherited turn sandbox test")? else { + return Ok(()); + }; + + let approval_policy = AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: false, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + }); + let read_only_policy = SandboxPolicy::new_read_only_policy(); + let server = start_mock_server().await; + let tool_call_id = "zsh-fork-read-only-skill"; + let test = build_zsh_fork_test( + &server, + runtime, + approval_policy, + read_only_policy.clone(), + move |home| { + write_skill_with_shell_script_contents( + home, + "mbolin-test-skill", + "read-only.sh", + "#!/bin/sh\nprintf 'read-only-ok\\n'\n", + ) + .unwrap(); + }, + ) + .await?; + + let (_, command) = skill_script_command(&test, "read-only.sh")?; + let arguments = shell_command_arguments(&command)?; + let mocks = + mount_function_call_agent_response(&server, tool_call_id, &arguments, "shell_command") + .await; + + submit_turn_with_policies( + &test, + "use $mbolin-test-skill", + approval_policy, + read_only_policy, + ) + .await?; + + let approval = wait_for_exec_approval_request(&test).await; + assert!( + approval.is_none(), + "expected permissionless skill script to skip exec approval and inherit the turn sandbox" + ); + + wait_for_turn_complete(&test).await; + + let output = mocks + .completion + .single_request() + .function_call_output(tool_call_id)["output"] + .as_str() + .unwrap_or_default() + .to_string(); + assert!( + output.contains("read-only-ok"), + "expected permissionless skill script to execute while inheriting the read-only turn sandbox, got output: {output:?}" + ); + + Ok(()) +} + +/// Skills with declared permissions should still reach the approval prompt +/// under a restricted read-only turn sandbox before their script executes. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_zsh_fork_skill_with_permissions_prompts_under_read_only_sandbox() -> Result<()> { + skip_if_no_network!(Ok(())); + + let Some(runtime) = zsh_fork_runtime("zsh-fork read-only skill permissions prompt test")? + else { + return Ok(()); + }; + + let approval_policy = AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: false, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + }); + let read_only_policy = SandboxPolicy::new_read_only_policy(); + let server = start_mock_server().await; + let tool_call_id = "zsh-fork-read-only-skill-permissions"; + let test = build_zsh_fork_test( + &server, + runtime, + approval_policy, + read_only_policy.clone(), + move |home| { + write_skill_with_shell_script_contents( + home, + "mbolin-test-skill", + "read-only.sh", + "#!/usr/bin/env bash\nprintf 'proxy-style-ok\\n'\n", + ) + .unwrap(); + write_skill_metadata( + home, + "mbolin-test-skill", + r#" +permissions: + network: + enabled: true + allowed_domains: + - "www.example.com" +"#, + ) + .unwrap(); + }, + ) + .await?; + + let (script_path_str, command) = skill_script_command(&test, "read-only.sh")?; + let arguments = shell_command_arguments(&command)?; + let _mocks = + mount_function_call_agent_response(&server, tool_call_id, &arguments, "shell_command") + .await; + + submit_turn_with_policies( + &test, + "use $mbolin-test-skill", + approval_policy, + read_only_policy, + ) + .await?; + + let approval = wait_for_exec_approval_request(&test) + .await + .expect("expected skill approval prompt before script execution"); + assert_eq!(approval.call_id, tool_call_id); + assert_eq!(approval.command, vec![script_path_str]); + + Ok(()) +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_zsh_fork_relative_skill_path_with_permissions_prompts_under_read_only_sandbox() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let Some(runtime) = + zsh_fork_runtime("zsh-fork read-only relative skill permissions prompt test")? + else { + return Ok(()); + }; + + let approval_policy = AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: false, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + }); + let read_only_policy = SandboxPolicy::new_read_only_policy(); + let server = start_mock_server().await; + let tool_call_id = "zsh-fork-read-only-relative-skill-permissions"; + let test = build_zsh_fork_test( + &server, + runtime, + approval_policy, + read_only_policy.clone(), + |_home| {}, + ) + .await?; + + let script_path = write_project_skill_with_shell_script_contents( + test.cwd_path(), + "mbolin-test-skill", + "read-only.sh", + "#!/usr/bin/env bash\nprintf 'proxy-style-ok\\n'\n", + )?; + write_project_skill_metadata( + test.cwd_path(), + "mbolin-test-skill", + r#" +permissions: + network: + enabled: true + allowed_domains: + - "www.example.com" +"#, + )?; + + let command = shlex::try_join(["./.codex/skills/mbolin-test-skill/scripts/read-only.sh"])?; + let arguments = shell_command_arguments(&command)?; + let _mocks = + mount_function_call_agent_response(&server, tool_call_id, &arguments, "shell_command") + .await; + + submit_turn_with_policies( + &test, + "use $mbolin-test-skill", + approval_policy, + read_only_policy, + ) + .await?; + + let approval = wait_for_exec_approval_request(&test) + .await + .expect("expected skill approval prompt before relative script execution"); + assert_eq!(approval.call_id, tool_call_id); + assert_eq!( + approval.command, + vec![ + fs::canonicalize(script_path)? + .to_string_lossy() + .into_owned() + ] + ); + + Ok(()) +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_zsh_fork_skill_without_permissions_executes_under_read_only_login_shell() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let Some(runtime) = zsh_fork_runtime("zsh-fork read-only login shell test")? else { + return Ok(()); + }; + + let approval_policy = AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: false, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + }); + let read_only_policy = SandboxPolicy::new_read_only_policy(); + let server = start_mock_server().await; + let tool_call_id = "zsh-fork-read-only-login-shell"; + let test = build_zsh_fork_test_with_login_shell( + &server, + runtime, + approval_policy, + read_only_policy.clone(), + true, + move |home| { + write_skill_with_shell_script_contents( + home, + "mbolin-test-skill", + "read-only-login.sh", + "#!/usr/bin/env bash\nprintf 'login-shell-ok\\n'\n", + ) + .unwrap(); + }, + ) + .await?; + + let (_, command) = skill_script_command(&test, "read-only-login.sh")?; + let arguments = shell_command_arguments(&command)?; + let mocks = + mount_function_call_agent_response(&server, tool_call_id, &arguments, "shell_command") + .await; + + submit_turn_with_policies( + &test, + "use $mbolin-test-skill", + approval_policy, + read_only_policy, + ) + .await?; + + let approval = wait_for_exec_approval_request(&test).await; + assert!( + approval.is_none(), + "expected permissionless skill script to skip exec approval in read-only login shell" + ); + + wait_for_turn_complete(&test).await; + + let output = mocks + .completion + .single_request() + .function_call_output(tool_call_id)["output"] + .as_str() + .unwrap_or_default() + .to_string(); + assert!( + output.contains("login-shell-ok"), + "expected permissionless skill script to execute under read-only login-shell zsh-fork, got output: {output:?}" + ); + + Ok(()) +} + /// Empty skill permissions should behave like no skill override and inherit the /// turn sandbox without prompting. #[cfg(unix)]