mirror of
https://github.com/openai/codex.git
synced 2026-09-05 15:18:41 +00:00
fix: sandbox zsh fork unified exec trampoline
This commit is contained in:
@@ -630,9 +630,11 @@ impl EscalationPolicy for CoreShellActionProvider {
|
||||
let decision_driven_by_policy =
|
||||
Self::decision_driven_by_policy(&evaluation.matched_rules, evaluation.decision);
|
||||
let unsandboxed_allowed = unsandboxed_execution_allowed(&self.file_system_sandbox_policy);
|
||||
let needs_escalation = unsandboxed_allowed
|
||||
&& (self.sandbox_permissions.requires_escalated_permissions()
|
||||
|| decision_driven_by_policy);
|
||||
let needs_escalation = match self.sandbox_permissions {
|
||||
SandboxPermissions::UseDefault => unsandboxed_allowed && decision_driven_by_policy,
|
||||
SandboxPermissions::RequireEscalated => unsandboxed_allowed,
|
||||
SandboxPermissions::WithAdditionalPermissions => true,
|
||||
};
|
||||
|
||||
let decision_source = if decision_driven_by_policy {
|
||||
DecisionSource::PrefixRule
|
||||
@@ -858,7 +860,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
EscalationExecution::Unsandboxed => PreparedExec {
|
||||
command,
|
||||
cwd: workdir.to_path_buf(),
|
||||
env,
|
||||
env: exec_env_for_sandbox_permissions(&env, SandboxPermissions::RequireEscalated),
|
||||
arg0: Some(first_arg.clone()),
|
||||
},
|
||||
EscalationExecution::TurnDefault => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::CoreShellActionProvider;
|
||||
use super::CoreShellCommandExecutor;
|
||||
use super::InterceptedExecPolicyContext;
|
||||
use super::ParsedShellCommand;
|
||||
use super::commands_for_intercepted_exec_policy;
|
||||
@@ -16,6 +17,8 @@ use codex_execpolicy::PolicyParser;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
use codex_hooks::Hooks;
|
||||
use codex_hooks::HooksConfig;
|
||||
use codex_network_proxy::PROXY_ACTIVE_ENV_KEY;
|
||||
use codex_network_proxy::PROXY_ENV_KEYS;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::AdditionalPermissionProfile;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
@@ -30,13 +33,16 @@ use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::GranularApprovalConfig;
|
||||
use codex_protocol::protocol::GuardianCommandSource;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_sandboxing::policy_transforms::effective_permission_profile;
|
||||
use codex_shell_escalation::EscalationExecution;
|
||||
use codex_shell_escalation::EscalationPermissions;
|
||||
use codex_shell_escalation::ExecResult;
|
||||
use codex_shell_escalation::ResolvedPermissionProfile;
|
||||
use codex_shell_escalation::ShellCommandExecutor;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -349,6 +355,108 @@ fn shell_request_escalation_execution_is_explicit() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsandboxed_intercepted_exec_strips_managed_network_env() -> anyhow::Result<()> {
|
||||
let workdir = test_sandbox_cwd();
|
||||
let executor = CoreShellCommandExecutor {
|
||||
command: Vec::new(),
|
||||
cwd: workdir.clone(),
|
||||
permission_profile: PermissionProfile::workspace_write(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
|
||||
sandbox: SandboxType::None,
|
||||
env: HashMap::new(),
|
||||
network: None,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
arg0: None,
|
||||
sandbox_policy_cwd: workdir.clone(),
|
||||
windows_sandbox_workspace_roots: vec![workdir.clone()],
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_legacy_landlock: false,
|
||||
};
|
||||
let mut env = HashMap::new();
|
||||
env.insert(PROXY_ACTIVE_ENV_KEY.to_string(), "1".to_string());
|
||||
for key in PROXY_ENV_KEYS {
|
||||
env.insert((*key).to_string(), format!("proxy-{key}"));
|
||||
}
|
||||
|
||||
let prepared = executor
|
||||
.prepare_escalated_exec(
|
||||
&AbsolutePathBuf::from_absolute_path("/usr/bin/curl")?,
|
||||
&["curl".to_string(), "example.com".to_string()],
|
||||
&workdir,
|
||||
env,
|
||||
EscalationExecution::Unsandboxed,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
!prepared.env.contains_key(PROXY_ACTIVE_ENV_KEY),
|
||||
"unsandboxed intercepted exec should strip the managed-network active marker"
|
||||
);
|
||||
for key in PROXY_ENV_KEYS {
|
||||
assert!(
|
||||
!prepared.env.contains_key(*key),
|
||||
"unsandboxed intercepted exec should strip managed-network proxy env var {key}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preapproved_additional_permissions_escalate_intercepted_exec() -> anyhow::Result<()> {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let requested_permissions = AdditionalPermissionProfile {
|
||||
file_system: Some(FileSystemPermissions::from_read_write_roots(
|
||||
/*read*/ None,
|
||||
Some(vec![
|
||||
AbsolutePathBuf::from_absolute_path("/tmp/output").unwrap(),
|
||||
]),
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
let workdir = test_sandbox_cwd();
|
||||
let permission_profile = effective_permission_profile(
|
||||
&PermissionProfile::workspace_write(),
|
||||
Some(&requested_permissions),
|
||||
);
|
||||
let provider = CoreShellActionProvider {
|
||||
policy: Arc::new(RwLock::new(codex_execpolicy::Policy::empty())),
|
||||
session: Arc::new(session),
|
||||
turn: Arc::new(turn_context),
|
||||
call_id: "preapproved-additional-permissions".to_string(),
|
||||
tool_name: GuardianCommandSource::Shell,
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
permission_profile: permission_profile.clone(),
|
||||
file_system_sandbox_policy: read_only_file_system_sandbox_policy(),
|
||||
sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
|
||||
approval_sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prompt_permissions: Some(requested_permissions),
|
||||
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/printf")?,
|
||||
&["printf".to_string(), "hello".to_string()],
|
||||
&workdir,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let expected = codex_shell_escalation::EscalationDecision::Escalate(
|
||||
EscalationExecution::Permissions(EscalationPermissions::ResolvedPermissionProfile(
|
||||
ResolvedPermissionProfile { permission_profile },
|
||||
)),
|
||||
);
|
||||
assert_eq!(
|
||||
action, expected,
|
||||
"preapproved with_additional_permissions should escalate through the resolved permission profile"
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
@@ -265,17 +265,15 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
|
||||
let base_command = &req.command;
|
||||
let session_shell = ctx.session.user_shell();
|
||||
let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions();
|
||||
let sandbox_permissions = sandbox_permissions_preserving_denied_reads(
|
||||
let launch_sandbox_permissions = sandbox_permissions_preserving_denied_reads(
|
||||
req.sandbox_permissions,
|
||||
&file_system_sandbox_policy,
|
||||
);
|
||||
let req = &UnifiedExecRequest {
|
||||
sandbox_permissions,
|
||||
..req.clone()
|
||||
};
|
||||
let managed_network =
|
||||
managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions);
|
||||
let mut env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions);
|
||||
let managed_network = managed_network_for_sandbox_permissions(
|
||||
req.network.as_ref(),
|
||||
launch_sandbox_permissions,
|
||||
);
|
||||
let mut env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions);
|
||||
if let Some(network) = managed_network {
|
||||
network.apply_to_env(&mut env);
|
||||
}
|
||||
@@ -412,6 +410,7 @@ mod tests {
|
||||
use crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS;
|
||||
use crate::tools::sandboxing::ToolRuntime;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_tools::ZshForkConfig;
|
||||
use std::time::Duration;
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -473,4 +472,107 @@ mod tests {
|
||||
|
||||
assert_eq!(runtime.sandbox_cwd(&request), Some(&sandbox_cwd));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zsh_fork_first_attempt_preserves_parent_sandbox_override() {
|
||||
let manager = UnifiedExecProcessManager::default();
|
||||
let request = test_request(
|
||||
SandboxPermissions::RequireEscalated,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
);
|
||||
let direct_runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct);
|
||||
let zsh_fork_runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode());
|
||||
|
||||
assert_eq!(
|
||||
direct_runtime.sandbox_permissions(&request),
|
||||
SandboxPermissions::RequireEscalated,
|
||||
"direct unified exec should preserve a parent require_escalated request"
|
||||
);
|
||||
assert_eq!(
|
||||
zsh_fork_runtime.sandbox_permissions(&request),
|
||||
SandboxPermissions::RequireEscalated,
|
||||
"zsh-fork unified exec should preserve the same parent require_escalated request"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zsh_fork_first_attempt_preserves_additional_permissions_request() {
|
||||
let manager = UnifiedExecProcessManager::default();
|
||||
let request = test_request(
|
||||
SandboxPermissions::WithAdditionalPermissions,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
);
|
||||
let zsh_fork_runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode());
|
||||
|
||||
assert_eq!(
|
||||
zsh_fork_runtime.sandbox_permissions(&request),
|
||||
SandboxPermissions::WithAdditionalPermissions,
|
||||
"zsh-fork unified exec should keep bounded additional-permissions requests sandboxed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zsh_fork_execpolicy_allow_preserves_parent_sandbox_override() {
|
||||
let manager = UnifiedExecProcessManager::default();
|
||||
let request = test_request(
|
||||
SandboxPermissions::UseDefault,
|
||||
ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
proposed_execpolicy_amendment: None,
|
||||
},
|
||||
);
|
||||
let runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode());
|
||||
|
||||
assert_eq!(
|
||||
runtime.exec_approval_requirement(&request),
|
||||
Some(ExecApprovalRequirement::Skip {
|
||||
bypass_sandbox: true,
|
||||
proposed_execpolicy_amendment: None,
|
||||
}),
|
||||
"zsh-fork unified exec should preserve exec-policy allow decisions that bypass the sandbox"
|
||||
);
|
||||
}
|
||||
|
||||
fn test_request(
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
exec_approval_requirement: ExecApprovalRequirement,
|
||||
) -> UnifiedExecRequest {
|
||||
let cwd = AbsolutePathBuf::try_from(std::env::current_dir().unwrap())
|
||||
.expect("current dir is absolute");
|
||||
UnifiedExecRequest {
|
||||
command: vec!["zsh".to_string(), "-c".to_string(), "echo hi".to_string()],
|
||||
shell_type: ShellType::Zsh,
|
||||
hook_command: "echo hi".to_string(),
|
||||
process_id: 1000,
|
||||
cwd: cwd.clone(),
|
||||
sandbox_cwd: cwd,
|
||||
environment: Arc::new(Environment::default_for_tests()),
|
||||
env: HashMap::new(),
|
||||
exec_server_env_config: None,
|
||||
explicit_env_overrides: HashMap::new(),
|
||||
network: None,
|
||||
tty: false,
|
||||
sandbox_permissions,
|
||||
additional_permissions: None,
|
||||
#[cfg(unix)]
|
||||
additional_permissions_preapproved: false,
|
||||
justification: None,
|
||||
exec_approval_requirement,
|
||||
}
|
||||
}
|
||||
|
||||
fn zsh_fork_mode() -> UnifiedExecShellMode {
|
||||
let cwd = std::env::current_dir().expect("read current dir");
|
||||
UnifiedExecShellMode::ZshFork(ZshForkConfig {
|
||||
shell_zsh_path: AbsolutePathBuf::try_from(cwd.join("zsh")).expect("absolute zsh path"),
|
||||
main_execve_wrapper_exe: AbsolutePathBuf::try_from(cwd.join("execve-wrapper"))
|
||||
.expect("absolute wrapper path"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,33 @@ where
|
||||
builder.build(server).await
|
||||
}
|
||||
|
||||
pub async fn build_unified_exec_zsh_fork_test<F>(
|
||||
server: &wiremock::MockServer,
|
||||
runtime: ZshForkRuntime,
|
||||
approval_policy: AskForApproval,
|
||||
permission_profile: PermissionProfile,
|
||||
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, permission_profile);
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::UnifiedExec)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::UnifiedExecZshFork)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
builder.build(server).await
|
||||
}
|
||||
|
||||
fn find_test_zsh_path() -> Result<Option<PathBuf>> {
|
||||
let repo_root = codex_utils_cargo_bin::repo_root()?;
|
||||
let dotslash_zsh = repo_root.join("codex-rs/app-server/tests/suite/zsh");
|
||||
|
||||
@@ -115,6 +115,8 @@ mod tools;
|
||||
mod truncation;
|
||||
mod turn_state;
|
||||
mod unified_exec;
|
||||
#[cfg(unix)]
|
||||
mod unified_exec_zsh_fork_approvals;
|
||||
mod unstable_features_warning;
|
||||
mod user_notification;
|
||||
mod user_shell_cmd;
|
||||
|
||||
567
codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs
Normal file
567
codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs
Normal file
@@ -0,0 +1,567 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use codex_config::permissions_toml::FilesystemPermissionToml;
|
||||
use codex_config::permissions_toml::PermissionProfileToml;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_core::sandboxing::SandboxPermissions;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecApprovalRequestEvent;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ResponseMock;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_with_timeout;
|
||||
use core_test_support::zsh_fork::build_unified_exec_zsh_fork_test;
|
||||
use core_test_support::zsh_fork::restrictive_workspace_write_profile;
|
||||
use core_test_support::zsh_fork::zsh_fork_runtime;
|
||||
use pretty_assertions::assert_eq;
|
||||
use regex_lite::Regex;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use toml_edit::Key as TomlKey;
|
||||
use wiremock::MockServer;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_zsh_fork_parent_approval_preserves_denied_reads() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let denied_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
|
||||
let denied_path = denied_dir.path().join("secret.env");
|
||||
let secret = "unified-exec-zsh-fork-denied-read-secret";
|
||||
fs::write(&denied_path, format!("{secret}\n"))?;
|
||||
let permission_profile = denied_read_permission_profile(&denied_path)?;
|
||||
assert!(
|
||||
permission_profile
|
||||
.file_system_sandbox_policy()
|
||||
.has_denied_read_restrictions(),
|
||||
"test must exercise a permission profile with denied reads"
|
||||
);
|
||||
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let command = format!("cat {denied_path:?}");
|
||||
let Some((server, test)) = build_unified_exec_zsh_fork_test_or_skip(
|
||||
"unified-exec zsh-fork denied-read approval test",
|
||||
approval_policy,
|
||||
permission_profile,
|
||||
move |_home| {},
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let call_id = "uexec-zsh-fork-parent-approval-denied-read";
|
||||
let results = mount_unified_exec_command(
|
||||
&server,
|
||||
"uexec-zsh-fork-denied-read",
|
||||
call_id,
|
||||
&command,
|
||||
"attempt a denied read for the test",
|
||||
)
|
||||
.await?;
|
||||
submit_turn_with_session_permissions(
|
||||
&test,
|
||||
"run approved unified exec denied read through zsh fork",
|
||||
approval_policy,
|
||||
)
|
||||
.await?;
|
||||
approve_expected_exec(&test, &command).await?;
|
||||
wait_for_completion_without_approval(&test).await;
|
||||
|
||||
let result = command_result(&results, call_id);
|
||||
assert_ne!(
|
||||
result.exit_code.unwrap_or(0),
|
||||
0,
|
||||
"denied-read command should stay sandboxed after parent approval"
|
||||
);
|
||||
assert!(
|
||||
!result.stdout.contains(secret),
|
||||
"denied-read command unexpectedly printed the secret: {}",
|
||||
result.stdout
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_zsh_fork_parent_approval_escalates_intercepted_exec() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let permission_profile = restrictive_workspace_write_profile();
|
||||
let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
|
||||
let outside_path = outside_dir
|
||||
.path()
|
||||
.join("unified-exec-zsh-fork-parent-approval.txt");
|
||||
let command = format!("printf hi > {outside_path:?}");
|
||||
|
||||
let outside_path_for_hook = outside_path.clone();
|
||||
let Some((server, test)) = build_unified_exec_zsh_fork_test_or_skip(
|
||||
"unified-exec zsh-fork parent approval test",
|
||||
approval_policy,
|
||||
permission_profile,
|
||||
move |_home| {
|
||||
let _ = fs::remove_file(&outside_path_for_hook);
|
||||
},
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let call_id = "uexec-zsh-fork-parent-approval";
|
||||
let results = mount_unified_exec_command(
|
||||
&server,
|
||||
"uexec-zsh-fork-parent-approval",
|
||||
call_id,
|
||||
&command,
|
||||
"write outside the workspace for the test",
|
||||
)
|
||||
.await?;
|
||||
submit_turn_with_session_permissions(
|
||||
&test,
|
||||
"run approved unified exec through zsh fork",
|
||||
approval_policy,
|
||||
)
|
||||
.await?;
|
||||
approve_expected_exec(&test, &command).await?;
|
||||
wait_for_completion_without_approval(&test).await;
|
||||
|
||||
let result = command_result(&results, call_id);
|
||||
assert_eq!(
|
||||
result.exit_code.unwrap_or(0),
|
||||
0,
|
||||
"approved unified exec zsh-fork command should complete: {}",
|
||||
result.stdout
|
||||
);
|
||||
let contents = fs::read_to_string(&outside_path)
|
||||
.with_context(|| format!("read {}", outside_path.display()))?;
|
||||
assert_eq!(
|
||||
contents, "hi",
|
||||
"approved parent sandbox override should allow zsh-fork shell redirection to write outside the workspace"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_zsh_fork_parent_approval_keeps_explicit_prompt_rule() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let approval_policy = AskForApproval::OnRequest;
|
||||
let permission_profile = restrictive_workspace_write_profile();
|
||||
let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
|
||||
let outside_path = outside_dir
|
||||
.path()
|
||||
.join("unified-exec-zsh-fork-explicit-prompt-rule.txt");
|
||||
let command = format!("touch {outside_path:?}");
|
||||
let rules = r#"prefix_rule(pattern=["touch"], decision="prompt")"#.to_string();
|
||||
|
||||
let outside_path_for_hook = outside_path.clone();
|
||||
let Some((server, test)) = build_unified_exec_zsh_fork_test_or_skip(
|
||||
"unified-exec zsh-fork prompt rule approval test",
|
||||
approval_policy,
|
||||
permission_profile,
|
||||
move |home| {
|
||||
let _ = fs::remove_file(&outside_path_for_hook);
|
||||
let rules_dir = home.join("rules");
|
||||
fs::create_dir_all(&rules_dir).unwrap();
|
||||
fs::write(rules_dir.join("default.rules"), &rules).unwrap();
|
||||
},
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let call_id = "uexec-zsh-fork-parent-approval-explicit-prompt-rule";
|
||||
let results = mount_unified_exec_command(
|
||||
&server,
|
||||
"uexec-zsh-fork-prompt-rule",
|
||||
call_id,
|
||||
&command,
|
||||
"write outside the workspace for the test",
|
||||
)
|
||||
.await?;
|
||||
submit_turn_with_session_permissions(
|
||||
&test,
|
||||
"run approved unified exec prompt rule through zsh fork",
|
||||
approval_policy,
|
||||
)
|
||||
.await?;
|
||||
approve_expected_exec(&test, &command).await?;
|
||||
|
||||
let approval_event = wait_for_event_with_timeout(
|
||||
&test.codex,
|
||||
|event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
||||
)
|
||||
},
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await;
|
||||
let EventMsg::ExecApprovalRequest(inner_approval) = approval_event else {
|
||||
panic!("expected explicit prompt rule approval before completion");
|
||||
};
|
||||
assert!(
|
||||
inner_approval
|
||||
.command
|
||||
.iter()
|
||||
.any(|arg| arg.ends_with("/touch"))
|
||||
&& inner_approval
|
||||
.command
|
||||
.iter()
|
||||
.any(|arg| arg == outside_path.to_string_lossy().as_ref()),
|
||||
"expected explicit prompt rule approval for intercepted touch, got: {:?}",
|
||||
inner_approval.command
|
||||
);
|
||||
|
||||
approve_exec(&test, inner_approval.effective_approval_id()).await?;
|
||||
wait_for_completion(&test).await;
|
||||
|
||||
let result = command_result(&results, call_id);
|
||||
assert_eq!(
|
||||
result.exit_code.unwrap_or(0),
|
||||
0,
|
||||
"approved unified exec zsh-fork prompt-rule command should complete: {}",
|
||||
result.stdout
|
||||
);
|
||||
assert!(
|
||||
outside_path.exists(),
|
||||
"approved intercepted touch should create the out-of-workspace file"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct CommandResult {
|
||||
exit_code: Option<i64>,
|
||||
stdout: String,
|
||||
}
|
||||
|
||||
async fn build_unified_exec_zsh_fork_test_or_skip<F>(
|
||||
test_name: &str,
|
||||
approval_policy: AskForApproval,
|
||||
permission_profile: PermissionProfile,
|
||||
pre_build_hook: F,
|
||||
) -> Result<Option<(MockServer, TestCodex)>>
|
||||
where
|
||||
F: FnOnce(&Path) + Send + 'static,
|
||||
{
|
||||
let Some(runtime) = zsh_fork_runtime(test_name)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let test = build_unified_exec_zsh_fork_test(
|
||||
&server,
|
||||
runtime,
|
||||
approval_policy,
|
||||
permission_profile,
|
||||
pre_build_hook,
|
||||
)
|
||||
.await?;
|
||||
Ok(Some((server, test)))
|
||||
}
|
||||
|
||||
fn denied_read_permission_profile(denied_path: &Path) -> Result<PermissionProfile> {
|
||||
let denied_path_key = TomlKey::new(denied_path.to_string_lossy().into_owned());
|
||||
permission_profile_from_toml(&format!(
|
||||
r#"
|
||||
[filesystem]
|
||||
"/" = "read"
|
||||
":project_roots" = "write"
|
||||
{denied_path_key} = "deny"
|
||||
|
||||
[network]
|
||||
enabled = false
|
||||
"#
|
||||
))
|
||||
}
|
||||
|
||||
fn permission_profile_from_toml(profile: &str) -> Result<PermissionProfile> {
|
||||
let profile = toml::from_str::<PermissionProfileToml>(profile)
|
||||
.context("test permission profile should deserialize")?;
|
||||
let filesystem = profile
|
||||
.filesystem
|
||||
.as_ref()
|
||||
.context("test permission profile should include filesystem entries")?;
|
||||
let entries = filesystem
|
||||
.entries
|
||||
.iter()
|
||||
.map(|(path, permission)| {
|
||||
let FilesystemPermissionToml::Access(access) = permission else {
|
||||
anyhow::bail!("unexpected scoped filesystem permission in test profile: {path}");
|
||||
};
|
||||
let path = match path.as_str() {
|
||||
"/" => FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Root,
|
||||
},
|
||||
":project_roots" => FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
|
||||
},
|
||||
_ if *access == FileSystemAccessMode::Deny => FileSystemPath::GlobPattern {
|
||||
pattern: path.clone(),
|
||||
},
|
||||
_ => anyhow::bail!("unexpected filesystem entry in test profile: {path}"),
|
||||
};
|
||||
Ok(FileSystemSandboxEntry {
|
||||
path,
|
||||
access: *access,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(entries);
|
||||
file_system_sandbox_policy.glob_scan_max_depth = filesystem.glob_scan_max_depth;
|
||||
let network_sandbox_policy = match profile.network.as_ref().and_then(|network| network.enabled)
|
||||
{
|
||||
Some(true) => NetworkSandboxPolicy::Enabled,
|
||||
Some(false) | None => NetworkSandboxPolicy::Restricted,
|
||||
};
|
||||
|
||||
Ok(PermissionProfile::from_runtime_permissions(
|
||||
&file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
))
|
||||
}
|
||||
|
||||
async fn mount_unified_exec_command(
|
||||
server: &MockServer,
|
||||
response_prefix: &str,
|
||||
call_id: &str,
|
||||
command: &str,
|
||||
justification: &str,
|
||||
) -> Result<ResponseMock> {
|
||||
let first_response_id = format!("resp-{response_prefix}-1");
|
||||
let second_response_id = format!("resp-{response_prefix}-2");
|
||||
let message_id = format!("msg-{response_prefix}-1");
|
||||
let event = exec_command_event(
|
||||
call_id,
|
||||
command,
|
||||
Some(30_000),
|
||||
SandboxPermissions::RequireEscalated,
|
||||
justification,
|
||||
)?;
|
||||
let _ = mount_sse_once(
|
||||
server,
|
||||
sse(vec![
|
||||
ev_response_created(&first_response_id),
|
||||
event,
|
||||
ev_completed(&first_response_id),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let results = mount_sse_once(
|
||||
server,
|
||||
sse(vec![
|
||||
ev_assistant_message(&message_id, "done"),
|
||||
ev_completed(&second_response_id),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn submit_turn_with_session_permissions(
|
||||
test: &TestCodex,
|
||||
prompt: &str,
|
||||
approval_policy: AskForApproval,
|
||||
) -> Result<()> {
|
||||
let session_model = test.session_configured.model.clone();
|
||||
let (sandbox_policy, permission_profile) = turn_permission_fields(
|
||||
test.session_configured.permission_profile.clone(),
|
||||
test.cwd.path(),
|
||||
);
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: prompt.into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
environments: None,
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: ThreadSettingsOverrides {
|
||||
cwd: Some(test.config.cwd.clone()),
|
||||
approval_policy: Some(approval_policy),
|
||||
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||||
sandbox_policy: Some(sandbox_policy),
|
||||
permission_profile,
|
||||
collaboration_mode: Some(CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
model: session_model,
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
},
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn approve_expected_exec(test: &TestCodex, expected_command: &str) -> Result<()> {
|
||||
let approval = expect_exec_approval(test, expected_command).await;
|
||||
approve_exec(test, approval.effective_approval_id()).await
|
||||
}
|
||||
|
||||
async fn approve_exec(test: &TestCodex, approval_id: String) -> Result<()> {
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval_id,
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::Approved,
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command_result(results: &ResponseMock, call_id: &str) -> CommandResult {
|
||||
parse_result(&results.single_request().function_call_output(call_id))
|
||||
}
|
||||
|
||||
fn exec_command_event(
|
||||
call_id: &str,
|
||||
cmd: &str,
|
||||
yield_time_ms: Option<u64>,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
justification: &str,
|
||||
) -> Result<Value> {
|
||||
let mut args = json!({
|
||||
"cmd": cmd.to_string(),
|
||||
});
|
||||
if let Some(yield_time_ms) = yield_time_ms {
|
||||
args["yield_time_ms"] = json!(yield_time_ms);
|
||||
}
|
||||
if sandbox_permissions.requests_sandbox_override() {
|
||||
args["sandbox_permissions"] = json!(sandbox_permissions);
|
||||
args["justification"] = json!(justification);
|
||||
}
|
||||
let args_str = serde_json::to_string(&args)?;
|
||||
Ok(ev_function_call(call_id, "exec_command", &args_str))
|
||||
}
|
||||
|
||||
fn parse_result(item: &Value) -> CommandResult {
|
||||
let Some(output_str) = item.get("output").and_then(Value::as_str) else {
|
||||
return CommandResult {
|
||||
exit_code: None,
|
||||
stdout: String::new(),
|
||||
};
|
||||
};
|
||||
match serde_json::from_str::<Value>(output_str) {
|
||||
Ok(parsed) => {
|
||||
let exit_code = parsed["metadata"]["exit_code"].as_i64();
|
||||
let stdout = parsed["output"].as_str().unwrap_or_default().to_string();
|
||||
CommandResult { exit_code, stdout }
|
||||
}
|
||||
Err(_) => parsed_regex_result(r"(?s)^Exit code:\s*(-?\d+).*?Output:\n(.*)$", output_str)
|
||||
.or_else(|| {
|
||||
parsed_regex_result(
|
||||
r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$",
|
||||
output_str,
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| CommandResult {
|
||||
exit_code: None,
|
||||
stdout: output_str.to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parsed_regex_result(pattern: &str, output_str: &str) -> Option<CommandResult> {
|
||||
let regex = Regex::new(pattern).ok()?;
|
||||
let captures = regex.captures(output_str)?;
|
||||
let exit_code = captures.get(1)?.as_str().parse::<i64>().ok()?;
|
||||
let output = captures.get(2)?.as_str();
|
||||
Some(CommandResult {
|
||||
exit_code: Some(exit_code),
|
||||
stdout: output.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn expect_exec_approval(
|
||||
test: &TestCodex,
|
||||
expected_command: &str,
|
||||
) -> ExecApprovalRequestEvent {
|
||||
let event = wait_for_event(&test.codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
match event {
|
||||
EventMsg::ExecApprovalRequest(approval) => {
|
||||
let last_arg = approval
|
||||
.command
|
||||
.last()
|
||||
.map(std::string::String::as_str)
|
||||
.unwrap_or_default();
|
||||
assert_eq!(
|
||||
last_arg, expected_command,
|
||||
"approval request should be for the parent unified-exec command"
|
||||
);
|
||||
approval
|
||||
}
|
||||
EventMsg::TurnComplete(_) => panic!("expected approval request before completion"),
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_completion_without_approval(test: &TestCodex) {
|
||||
let event = wait_for_event(&test.codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
match event {
|
||||
EventMsg::TurnComplete(_) => {}
|
||||
EventMsg::ExecApprovalRequest(event) => {
|
||||
panic!("unexpected approval request: {:?}", event.command)
|
||||
}
|
||||
other => panic!("unexpected event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_completion(test: &TestCodex) {
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Reference in New Issue
Block a user