This commit is contained in:
celia-oai
2026-03-16 17:45:39 -07:00
parent b77fe8fefe
commit e0b4bf5bb6
3 changed files with 738 additions and 38 deletions

View File

@@ -9,6 +9,8 @@ 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::merge_permission_profiles;
use crate::sandboxing::ExecRequest;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
@@ -62,6 +64,15 @@ pub(crate) struct PreparedUnifiedExecZshFork {
pub(crate) escalation_session: EscalationSession,
}
enum DirectSkillScriptOverride {
Denied(ExecToolCallOutput),
RunDirect {
command: Vec<String>,
additional_permissions: PermissionProfile,
network: Option<codex_network_proxy::NetworkProxy>,
},
}
const PROMPT_CONFLICT_REASON: &str =
"approval required by policy, but AskForApproval is set to Never";
const REJECT_SANDBOX_APPROVAL_REASON: &str =
@@ -106,7 +117,111 @@ 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,
network,
}) => {
let direct_network = network.or_else(|| req.network.clone());
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, direct_network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
log_sandbox_exec_command(
"zsh_fork.run.direct_skill_script_override",
&direct_exec_request.command,
&direct_exec_request.cwd,
direct_exec_request.arg0.as_deref(),
);
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 +231,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 +249,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 +272,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 +283,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,
@@ -663,6 +746,14 @@ impl EscalationPolicy for CoreShellActionProvider {
// turn sandbox directly; skills with declared permissions still
// prompt here before applying their permission profile.
let prompt_permissions = skill.permission_profile.clone();
tracing::debug!(
program = %program.as_path().display(),
skill_name = skill.name,
skill_path = %skill.path_to_skills_md.display(),
has_permission_profile = prompt_permissions.is_some(),
has_managed_network_override = skill.managed_network_override.is_some(),
"Matched intercepted program to skill"
);
if prompt_permissions
.as_ref()
.is_none_or(PermissionProfile::is_empty)
@@ -692,6 +783,11 @@ impl EscalationPolicy for CoreShellActionProvider {
)
.await;
}
tracing::debug!(
program = %program.as_path().display(),
turn_cwd = %self.turn.cwd.display(),
"No skill matched intercepted program; falling back to intercepted exec policy evaluation"
);
let evaluation = {
let policy = self.policy.read().await;
@@ -896,6 +992,13 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
}
}
log_sandbox_exec_command(
"zsh_fork.run.turn_default_exec_request",
&self.command,
&self.cwd,
self.arg0.as_deref(),
);
let result = crate::sandboxing::execute_exec_request_with_after_spawn(
crate::sandboxing::ExecRequest {
command: self.command.clone(),
@@ -1068,6 +1171,13 @@ impl CoreShellCommandExecutor {
network.apply_to_env(&mut exec_request.env);
}
log_sandbox_exec_command(
"zsh_fork.prepare_escalated_exec",
&exec_request.command,
&exec_request.cwd,
exec_request.arg0.as_deref(),
);
Ok(PreparedExec {
command: exec_request.command,
cwd: exec_request.cwd,
@@ -1150,6 +1260,239 @@ fn join_program_and_argv(program: &AbsolutePathBuf, argv: &[String]) -> Vec<Stri
.collect::<Vec<_>>()
}
fn log_sandbox_exec_command(context: &str, command: &[String], cwd: &Path, arg0: Option<&str>) {
tracing::debug!(
target: "codex_core::tools::runtimes::shell::unix_escalation",
context,
cwd = %cwd.display(),
?arg0,
?command,
"zsh-fork sandbox command"
);
#[cfg(target_os = "macos")]
if let Some((policy, target_command)) = extract_inline_seatbelt_policy(command) {
tracing::debug!(
target: "codex_core::tools::runtimes::shell::unix_escalation",
context,
seatbelt_target = ?target_command,
seatbelt_policy = %policy,
"zsh-fork inline seatbelt policy"
);
}
}
async fn resolve_direct_skill_script_override(
action_provider: &CoreShellActionProvider,
script: &str,
workdir: &AbsolutePathBuf,
additional_permissions: Option<&PermissionProfile>,
) -> anyhow::Result<Option<DirectSkillScriptOverride>> {
let Some((program, argv)) = resolve_direct_shell_command(script, workdir) else {
tracing::debug!(
script,
cwd = %workdir.display(),
"Direct skill script prelaunch matching skipped: shell script did not resolve to a single direct command"
);
return Ok(None);
};
tracing::debug!(
script,
cwd = %workdir.display(),
resolved_program = %program.as_path().display(),
resolved_argv = ?argv,
"Direct skill script prelaunch matching resolved shell script to direct command"
);
let Some(skill) = action_provider.find_skill(&program).await else {
tracing::debug!(
script,
cwd = %workdir.display(),
resolved_program = %program.as_path().display(),
"Direct skill script prelaunch matching found no skill for resolved command"
);
return Ok(None);
};
tracing::debug!(
script,
cwd = %workdir.display(),
resolved_program = %program.as_path().display(),
skill_name = skill.name,
skill_path = %skill.path_to_skills_md.display(),
"Direct skill script prelaunch matching resolved command to skill"
);
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),
network: action_provider
.session
.get_or_start_skill_network_proxy(&skill)
.await?,
})),
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<String>)> {
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::<Vec<_>>();
Some((normalized_program, argv))
}
fn parse_direct_shell_command(script: &str) -> Option<Vec<String>> {
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)?;
tracing::debug!(
shell_script = script,
snapshot_tail = tail,
"Direct skill script parser examining shell snapshot tail command"
);
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(target_os = "macos")]
fn extract_inline_seatbelt_policy(command: &[String]) -> Option<(&str, &[String])> {
let [sandbox_exec, flag, policy, target_command @ ..] = command else {
return None;
};
if sandbox_exec != "/usr/bin/sandbox-exec" || flag != "-p" {
return None;
}
Some((policy.as_str(), target_command))
}
async fn find_skill_for_program(
session: &crate::codex::Session,
cwd: &Path,
program: &AbsolutePathBuf,
) -> Option<SkillMetadata> {
let force_reload = false;
let skills_outcome = session
.services
.skills_manager
.skills_for_cwd(cwd, force_reload)
.await;
let loaded_skills = skills_outcome
.skills
.iter()
.map(|skill| {
let scripts_root = skill
.path_to_skills_md
.parent()
.map(|path| path.join("scripts"))
.map(|path| path.display().to_string())
.unwrap_or_else(|| "<missing-parent>".to_string());
format!(
"name={} skill_path={} scripts_root={} has_permission_profile={} has_managed_network_override={}",
skill.name,
skill.path_to_skills_md.display(),
scripts_root,
skill.permission_profile.is_some(),
skill.managed_network_override.is_some(),
)
})
.collect::<Vec<_>>();
tracing::debug!(
cwd = %cwd.display(),
program = %program.as_path().display(),
skill_count = skills_outcome.skills.len(),
error_count = skills_outcome.errors.len(),
skills = ?loaded_skills,
"Loaded skills for intercepted exec skill lookup"
);
let program_path = program.as_path();
for skill in skills_outcome.skills {
let Some(skill_root) = skill.path_to_skills_md.parent() else {
continue;
};
if program_path.starts_with(skill_root.join("scripts")) {
tracing::debug!(
program = %program_path.display(),
skill_name = skill.name,
skill_path = %skill.path_to_skills_md.display(),
"Skill lookup matched intercepted program"
);
return Some(skill);
}
}
tracing::debug!(
cwd = %cwd.display(),
program = %program_path.display(),
"Skill lookup found no matching intercepted program"
);
None
}
#[cfg(test)]
#[path = "unix_escalation_tests.rs"]
mod tests;

View File

@@ -3,6 +3,7 @@ use super::CoreShellActionProvider;
use super::CoreShellCommandExecutor;
use super::InterceptedExecPolicyContext;
use super::ParsedShellCommand;
use super::parse_direct_shell_command;
use super::commands_for_intercepted_exec_policy;
use super::evaluate_intercepted_exec_policy;
use super::extract_shell_script;
@@ -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(&[

View File

@@ -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,46 @@ 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<PathBuf> {
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 +710,306 @@ async fn shell_zsh_fork_skill_without_permissions_inherits_turn_sandbox() -> Res
Ok(())
}
/// Permissionless skills should still be executable under a restricted
/// read-only turn sandbox when both the configured zsh runtime and the skill
/// script live under readable roots.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_zsh_fork_skill_without_permissions_executes_under_read_only_sandbox() -> Result<()> {
skip_if_no_network!(Ok(()));
let Some(runtime) = zsh_fork_runtime("zsh-fork read-only inherited skill 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 in read-only 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 under read-only zsh-fork, 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)]