mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Resolve model-provided shells by type (#39607)
## Why A model-provided shell path should select the requested shell type without allowing that path to determine which executable Codex runs. ## What changed - Resolve model-provided shells through Codex's normal shell discovery and fallback logic after detecting their type. - Keep the configured packaged zsh executable when the zsh-fork feature is enabled and the file exists. - Update shell, unified exec, and network approval expectations to use the resolved local executable and arguments. GitOrigin-RevId: ebe6f7eec2cfd1c0548d5bf1a26b7a30dba02cc2
This commit is contained in:
@@ -1,349 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use codex_config::Constrained;
|
||||
use codex_core::TurnInputRequest;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
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::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_remote;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use test_case::test_case;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ShellAttack {
|
||||
ExactShellName,
|
||||
ExactShellNameWithAllowedInnerCommand,
|
||||
ExactShellNameWithForbiddenInnerCommand,
|
||||
DangerousCommandOnRequest,
|
||||
DangerousCommandNever,
|
||||
ApprovedCustomShell,
|
||||
SessionApprovalDoesNotTrustDifferentShell,
|
||||
SpoofedShellExtension,
|
||||
}
|
||||
|
||||
#[test_case(ShellAttack::ExactShellName; "workspace shell requires approval")]
|
||||
#[test_case(ShellAttack::ExactShellNameWithAllowedInnerCommand; "inner allow does not trust workspace shell")]
|
||||
#[test_case(ShellAttack::ExactShellNameWithForbiddenInnerCommand; "inner forbidden rule still rejects workspace shell")]
|
||||
#[test_case(ShellAttack::DangerousCommandOnRequest; "dangerous inner command requires approval")]
|
||||
#[test_case(ShellAttack::DangerousCommandNever; "dangerous inner command is forbidden without approval")]
|
||||
#[test_case(ShellAttack::ApprovedCustomShell; "approved custom shell still runs")]
|
||||
#[test_case(ShellAttack::SessionApprovalDoesNotTrustDifferentShell; "session approval does not trust a different shell")]
|
||||
#[test_case(ShellAttack::SpoofedShellExtension; "workspace shell with an extra extension requires approval")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn model_provided_shell_cannot_inherit_inner_command_trust(
|
||||
attack: ShellAttack,
|
||||
) -> Result<()> {
|
||||
skip_if_remote!(
|
||||
Ok(()),
|
||||
"remote executors already replace requested shell paths with their reported shell"
|
||||
);
|
||||
|
||||
let approval_policy = match attack {
|
||||
ShellAttack::DangerousCommandOnRequest => AskForApproval::OnRequest,
|
||||
ShellAttack::DangerousCommandNever => AskForApproval::Never,
|
||||
ShellAttack::ExactShellName
|
||||
| ShellAttack::ExactShellNameWithAllowedInnerCommand
|
||||
| ShellAttack::ExactShellNameWithForbiddenInnerCommand
|
||||
| ShellAttack::ApprovedCustomShell
|
||||
| ShellAttack::SessionApprovalDoesNotTrustDifferentShell
|
||||
| ShellAttack::SpoofedShellExtension => AskForApproval::UnlessTrusted,
|
||||
};
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::UnifiedExec)
|
||||
.expect("enable unified exec");
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
config.approvals_reviewer = ApprovalsReviewer::User;
|
||||
let inner_command_rule = match attack {
|
||||
ShellAttack::ExactShellNameWithAllowedInnerCommand => {
|
||||
Some("prefix_rule(pattern=[\"echo\"], decision=\"allow\")\n")
|
||||
}
|
||||
ShellAttack::ExactShellNameWithForbiddenInnerCommand => {
|
||||
if cfg!(windows) {
|
||||
Some(
|
||||
r#"prefix_rule(pattern=["Remove-Item", "C:\\important"], decision="forbidden")"#,
|
||||
)
|
||||
} else {
|
||||
Some("prefix_rule(pattern=[\"rm\"], decision=\"forbidden\")\n")
|
||||
}
|
||||
}
|
||||
ShellAttack::ExactShellName
|
||||
| ShellAttack::DangerousCommandOnRequest
|
||||
| ShellAttack::DangerousCommandNever
|
||||
| ShellAttack::ApprovedCustomShell
|
||||
| ShellAttack::SessionApprovalDoesNotTrustDifferentShell
|
||||
| ShellAttack::SpoofedShellExtension => None,
|
||||
};
|
||||
if let Some(inner_command_rule) = inner_command_rule {
|
||||
let policy_path = config.codex_home.join("rules/default.rules");
|
||||
fs::create_dir_all(policy_path.parent().expect("rules directory"))
|
||||
.expect("create rules directory");
|
||||
fs::write(policy_path, inner_command_rule).expect("write execution policy rule");
|
||||
}
|
||||
});
|
||||
#[cfg(windows)]
|
||||
if matches!(
|
||||
attack,
|
||||
ShellAttack::ExactShellNameWithForbiddenInnerCommand | ShellAttack::ApprovedCustomShell
|
||||
) {
|
||||
let system_root = std::env::var_os("SystemRoot").expect("Windows SystemRoot");
|
||||
let configured_shell = std::path::Path::new(&system_root)
|
||||
.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
.join("powershell.exe");
|
||||
builder = builder.with_user_shell(codex_core::shell::get_shell_by_model_provided_path(
|
||||
&configured_shell,
|
||||
));
|
||||
}
|
||||
let test = builder.build_with_auto_env(&server).await?;
|
||||
let shell_name = match attack {
|
||||
ShellAttack::ExactShellNameWithForbiddenInnerCommand | ShellAttack::ApprovedCustomShell
|
||||
if cfg!(windows) =>
|
||||
{
|
||||
"pwsh.exe"
|
||||
}
|
||||
ShellAttack::ExactShellName
|
||||
| ShellAttack::ExactShellNameWithAllowedInnerCommand
|
||||
| ShellAttack::ExactShellNameWithForbiddenInnerCommand
|
||||
| ShellAttack::DangerousCommandOnRequest
|
||||
| ShellAttack::DangerousCommandNever
|
||||
| ShellAttack::ApprovedCustomShell
|
||||
| ShellAttack::SessionApprovalDoesNotTrustDifferentShell => {
|
||||
if cfg!(windows) {
|
||||
"powershell.exe"
|
||||
} else {
|
||||
"bash"
|
||||
}
|
||||
}
|
||||
ShellAttack::SpoofedShellExtension => {
|
||||
if cfg!(windows) {
|
||||
"powershell.evil"
|
||||
} else {
|
||||
"bash.evil"
|
||||
}
|
||||
}
|
||||
};
|
||||
let shell = test.workspace_path(shell_name);
|
||||
let marker = test.workspace_path("attacker-executed");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
fs::write(&shell, "#!/bin/sh\nprintf ran > attacker-executed\n")?;
|
||||
fs::set_permissions(&shell, fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let test_executable = std::env::current_exe()?;
|
||||
fs::hard_link(&test_executable, &shell)
|
||||
.or_else(|_| fs::copy(&test_executable, &shell).map(|_| ()))?;
|
||||
fs::write(
|
||||
shell.with_file_name(".codex-executable-identity-fixture"),
|
||||
b"fake shell",
|
||||
)?;
|
||||
}
|
||||
let other_shell = if matches!(
|
||||
attack,
|
||||
ShellAttack::SessionApprovalDoesNotTrustDifferentShell
|
||||
) {
|
||||
let other_shell = test.workspace_path("another").join(shell_name);
|
||||
fs::create_dir_all(other_shell.parent().expect("alternate shell directory"))?;
|
||||
fs::copy(&shell, &other_shell)?;
|
||||
#[cfg(windows)]
|
||||
fs::write(
|
||||
other_shell.with_file_name(".codex-executable-identity-fixture"),
|
||||
b"fake shell",
|
||||
)?;
|
||||
Some(other_shell)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let call_id = "untrusted-shell-path";
|
||||
let other_call_id = "different-untrusted-shell-path";
|
||||
let command = match attack {
|
||||
ShellAttack::ExactShellName if cfg!(windows) => "Write-Output $env:USERNAME",
|
||||
ShellAttack::DangerousCommandOnRequest | ShellAttack::DangerousCommandNever => {
|
||||
if cfg!(windows) {
|
||||
"Remove-Item important -Force"
|
||||
} else {
|
||||
"rm -rf important"
|
||||
}
|
||||
}
|
||||
ShellAttack::ExactShellNameWithForbiddenInnerCommand => {
|
||||
if cfg!(windows) {
|
||||
r"echo shell-safe && Remove-Item C:\important"
|
||||
} else {
|
||||
"echo shell-safe; rm important"
|
||||
}
|
||||
}
|
||||
ShellAttack::ExactShellName
|
||||
| ShellAttack::ExactShellNameWithAllowedInnerCommand
|
||||
| ShellAttack::ApprovedCustomShell
|
||||
| ShellAttack::SessionApprovalDoesNotTrustDifferentShell
|
||||
| ShellAttack::SpoofedShellExtension => "echo shell-safe",
|
||||
};
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-untrusted-shell-1"),
|
||||
ev_function_call(
|
||||
call_id,
|
||||
"exec_command",
|
||||
&json!({ "cmd": command, "shell": shell }).to_string(),
|
||||
),
|
||||
ev_completed("resp-untrusted-shell-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
if let Some(other_shell) = other_shell.as_ref() {
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-different-untrusted-shell"),
|
||||
ev_function_call(
|
||||
other_call_id,
|
||||
"exec_command",
|
||||
&json!({ "cmd": command, "shell": other_shell }).to_string(),
|
||||
),
|
||||
ev_completed("resp-different-untrusted-shell"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let completed = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-untrusted-shell", "done"),
|
||||
ev_completed("resp-untrusted-shell-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
|
||||
test.codex
|
||||
.start_or_steer_turn(
|
||||
TurnInputRequest::user_input(vec![UserInput::Text {
|
||||
text: "inspect the repository".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}])
|
||||
.with_thread_settings(ThreadSettingsOverrides {
|
||||
approval_policy: Some(approval_policy),
|
||||
approvals_reviewer: Some(ApprovalsReviewer::User),
|
||||
sandbox_policy: Some(sandbox_policy),
|
||||
permission_profile,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let event = wait_for_event(&test.codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
||||
)
|
||||
})
|
||||
.await;
|
||||
if matches!(
|
||||
attack,
|
||||
ShellAttack::ExactShellNameWithForbiddenInnerCommand | ShellAttack::DangerousCommandNever
|
||||
) {
|
||||
assert!(matches!(event, EventMsg::TurnComplete(_)));
|
||||
let output = completed
|
||||
.single_request()
|
||||
.function_call_output_text(call_id)
|
||||
.expect("forbidden command output");
|
||||
assert!(
|
||||
output.contains("rejected"),
|
||||
"the forbidden command should be rejected: {output}"
|
||||
);
|
||||
#[cfg(windows)]
|
||||
if matches!(attack, ShellAttack::ExactShellNameWithForbiddenInnerCommand) {
|
||||
assert!(
|
||||
output.contains("Remove-Item"),
|
||||
"the forbidden PowerShell command should remain visible to policy: {output}"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let EventMsg::ExecApprovalRequest(approval) = event else {
|
||||
panic!("workspace shell bypassed approval");
|
||||
};
|
||||
assert_eq!(approval.call_id, call_id);
|
||||
assert!(!marker.exists(), "the shell ran before approval");
|
||||
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
turn_id: None,
|
||||
decision: match attack {
|
||||
ShellAttack::ApprovedCustomShell => ReviewDecision::Approved,
|
||||
ShellAttack::SessionApprovalDoesNotTrustDifferentShell => {
|
||||
ReviewDecision::ApprovedForSession
|
||||
}
|
||||
_ => ReviewDecision::denied("untrusted shell"),
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
if other_shell.is_some() {
|
||||
let event = wait_for_event(&test.codex, |event| {
|
||||
matches!(
|
||||
event,
|
||||
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let EventMsg::ExecApprovalRequest(approval) = event else {
|
||||
panic!("a different workspace shell reused the first shell's session approval");
|
||||
};
|
||||
assert_eq!(approval.call_id, other_call_id);
|
||||
assert!(
|
||||
marker.exists(),
|
||||
"the session-approved shell should have run"
|
||||
);
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::denied("different untrusted shell"),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
marker.exists(),
|
||||
matches!(
|
||||
attack,
|
||||
ShellAttack::ApprovedCustomShell
|
||||
| ShellAttack::SessionApprovalDoesNotTrustDifferentShell
|
||||
),
|
||||
"only an explicitly approved custom shell should run"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,21 +16,6 @@ use ctor::ctor;
|
||||
#[ctor]
|
||||
pub static CODEX_ALIASES_TEMP_DIR: Option<TestBinaryDispatchGuard> = {
|
||||
configure_test_binary_dispatch("codex-core-tests", |exe_name, argv1| {
|
||||
#[cfg(windows)]
|
||||
if exe_name.eq_ignore_ascii_case("powershell.exe")
|
||||
|| exe_name.eq_ignore_ascii_case("powershell.evil")
|
||||
|| exe_name.eq_ignore_ascii_case("pwsh.exe")
|
||||
{
|
||||
let executable = std::env::current_exe().expect("locate fake PowerShell executable");
|
||||
if executable
|
||||
.with_file_name(".codex-executable-identity-fixture")
|
||||
.is_file()
|
||||
{
|
||||
let marker = executable.with_file_name("attacker-executed");
|
||||
std::fs::write(marker, b"ran").expect("record fake PowerShell execution");
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
if argv1 == Some(CODEX_CORE_APPLY_PATCH_ARG1) {
|
||||
return TestBinaryDispatchMode::DispatchArg0Only;
|
||||
}
|
||||
@@ -77,7 +62,6 @@ mod cyber_exec_policy;
|
||||
mod deprecation_notice;
|
||||
mod exec;
|
||||
mod exec_policy;
|
||||
mod executable_identity;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod extension_sandbox;
|
||||
mod external_auth;
|
||||
|
||||
@@ -3,6 +3,8 @@ use anyhow::Result;
|
||||
use codex_config::types::ApprovalsReviewer;
|
||||
use codex_core::TurnInputRequest;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_core::shell::ShellType;
|
||||
use codex_core::shell::get_shell;
|
||||
use codex_exec_server::CreateDirectoryOptions;
|
||||
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
|
||||
use codex_exec_server::REMOTE_ENVIRONMENT_ID;
|
||||
@@ -96,6 +98,9 @@ async fn guardian_network_approval_preserves_action_and_outcome_routing() -> Res
|
||||
.context("expected network command")?
|
||||
.to_string();
|
||||
let second_command = first_command.clone();
|
||||
let expected_command = get_shell(ShellType::Sh)
|
||||
.context("expected local sh")?
|
||||
.derive_exec_args(&first_command, /*use_login_shell*/ false);
|
||||
let denial = "The destination is outside the approved test boundary.";
|
||||
let responses = mount_sse_sequence(
|
||||
&server,
|
||||
@@ -178,7 +183,7 @@ async fn guardian_network_approval_preserves_action_and_outcome_routing() -> Res
|
||||
"tool": "network_access",
|
||||
"trigger": {
|
||||
"callId": first_call_id,
|
||||
"command": ["/bin/sh", "-c", first_command],
|
||||
"command": expected_command,
|
||||
"cwd": test.config.cwd,
|
||||
"sandboxPermissions": "use_default",
|
||||
"toolName": "exec_command",
|
||||
@@ -1869,12 +1874,18 @@ async fn remote_guardian_network_decisions_are_scoped_to_each_request_and_enviro
|
||||
.await
|
||||
.context("remote session approval should bypass another network prompt")?;
|
||||
|
||||
let local_shell = get_shell(ShellType::Sh).context("expected local sh")?;
|
||||
let mut expected_actions = Vec::with_capacity(cases.len());
|
||||
for (call_id, command, environment, _, _) in &cases {
|
||||
let cwd = environment
|
||||
.cwd
|
||||
.to_abs_path()
|
||||
.with_context(|| format!("resolve the environment cwd for {call_id}"))?;
|
||||
let command = if environment.environment_id == LOCAL_ENVIRONMENT_ID {
|
||||
local_shell.derive_exec_args(command, /*use_login_shell*/ false)
|
||||
} else {
|
||||
vec!["/bin/sh".to_string(), "-c".to_string(), command.clone()]
|
||||
};
|
||||
expected_actions.push(json!({
|
||||
"host": NETWORK_TEST_HOST,
|
||||
"port": 80,
|
||||
@@ -1883,7 +1894,7 @@ async fn remote_guardian_network_decisions_are_scoped_to_each_request_and_enviro
|
||||
"tool": "network_access",
|
||||
"trigger": {
|
||||
"callId": call_id,
|
||||
"command": ["/bin/sh", "-c", command],
|
||||
"command": command,
|
||||
"cwd": cwd,
|
||||
"sandboxPermissions": "use_default",
|
||||
"toolName": "exec_command",
|
||||
|
||||
Reference in New Issue
Block a user