mirror of
https://github.com/openai/codex.git
synced 2026-03-18 04:34:00 +00:00
Compare commits
1 Commits
etraut/thr
...
dev/cc/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
254cf94e38 |
@@ -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<String>,
|
||||
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<Stri
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
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 {
|
||||
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<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)?;
|
||||
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;
|
||||
|
||||
@@ -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(&[
|
||||
|
||||
@@ -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<F>(
|
||||
server: &wiremock::MockServer,
|
||||
runtime: ZshForkRuntime,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
allow_login_shell: bool,
|
||||
pre_build_hook: F,
|
||||
) -> Result<TestCodex>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<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 +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)]
|
||||
|
||||
Reference in New Issue
Block a user