Compare commits

...

1 Commits

Author SHA1 Message Date
David Wiesen
768e9285c1 Avoid PowerShell profiles in elevated Windows sandbox 2026-05-06 12:29:22 -07:00
10 changed files with 248 additions and 19 deletions

View File

@@ -80,7 +80,7 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec<Stri
)
.ok()?;
Some((
command,
command.command,
invocation.turn.resolve_path(params.workdir).to_path_buf(),
))
}),

View File

@@ -12,6 +12,7 @@ use crate::function_tool::FunctionCallError;
use crate::maybe_emit_implicit_skill_invocation;
use crate::session::turn_context::TurnContext;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
@@ -90,6 +91,7 @@ struct RunExecLikeArgs {
tool_name: String,
exec_params: ExecParams,
hook_command: String,
shell_type: Option<ShellType>,
additional_permissions: Option<AdditionalPermissionProfile>,
prefix_rule: Option<Vec<String>>,
session: Arc<crate::session::session::Session>,
@@ -255,6 +257,7 @@ impl ToolHandler for ShellHandler {
tool_name: "shell".to_string(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
shell_type: None,
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
@@ -333,6 +336,7 @@ impl ToolHandler for ContainerExecHandler {
tool_name: "container.exec".to_string(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
shell_type: None,
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
@@ -414,6 +418,7 @@ impl ToolHandler for LocalShellHandler {
tool_name: "local_shell".to_string(),
exec_params,
hook_command: codex_shell_command::parse_command::shlex_join(&params.command),
shell_type: None,
additional_permissions: None,
prefix_rule: None,
session,
@@ -542,10 +547,12 @@ impl ToolHandler for ShellCommandHandler {
session.conversation_id,
turn.tools_config.allow_login_shell,
)?;
let shell_type = Some(session.user_shell().shell_type.clone());
ShellHandler::run_exec_like(RunExecLikeArgs {
tool_name: self.tool_name().display(),
exec_params,
hook_command: params.command,
shell_type,
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
@@ -565,6 +572,7 @@ impl ShellHandler {
tool_name,
exec_params,
hook_command,
shell_type,
additional_permissions,
prefix_rule,
session,
@@ -698,6 +706,7 @@ impl ShellHandler {
let req = ShellRequest {
command: exec_params.command.clone(),
shell_type,
hook_command,
cwd: exec_params.cwd.clone(),
timeout_ms: exec_params.expiration.timeout_ms(),

View File

@@ -2,6 +2,7 @@ use crate::function_tool::FunctionCallError;
use crate::maybe_emit_implicit_skill_invocation;
use crate::sandboxing::SandboxPermissions;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::shell::get_shell_by_model_provided_path;
use crate::tools::context::ExecCommandToolOutput;
use crate::tools::context::ToolInvocation;
@@ -91,6 +92,12 @@ struct WriteStdinArgs {
max_output_tokens: Option<usize>,
}
#[derive(Debug)]
pub(crate) struct ResolvedCommand {
pub(crate) command: Vec<String>,
pub(crate) shell_type: ShellType,
}
fn default_exec_yield_time_ms() -> u64 {
10_000
}
@@ -143,7 +150,7 @@ impl ToolHandler for ExecCommandHandler {
&invocation.turn.tools_config.unified_exec_shell_mode,
invocation.turn.tools_config.allow_login_shell,
) {
Ok(command) => command,
Ok(resolved) => resolved.command,
Err(_) => return true,
};
!is_known_safe_command(&command)
@@ -219,13 +226,15 @@ impl ToolHandler for ExecCommandHandler {
)
.await;
let process_id = manager.allocate_process_id().await;
let command = get_command(
let resolved_command = get_command(
&args,
session.user_shell(),
&turn.tools_config.unified_exec_shell_mode,
turn.tools_config.allow_login_shell,
)
.map_err(FunctionCallError::RespondToModel)?;
let command = resolved_command.command;
let shell_type = resolved_command.shell_type;
let command_for_display = codex_shell_command::parse_command::shlex_join(&command);
let ExecCommandArgs {
@@ -329,6 +338,7 @@ impl ToolHandler for ExecCommandHandler {
.exec_command(
ExecCommandRequest {
command,
shell_type,
hook_command: hook_command.clone(),
process_id,
yield_time_ms,
@@ -483,7 +493,7 @@ pub(crate) fn get_command(
session_shell: Arc<Shell>,
shell_mode: &UnifiedExecShellMode,
allow_login_shell: bool,
) -> Result<Vec<String>, String> {
) -> Result<ResolvedCommand, String> {
let use_login_shell = match args.login {
Some(true) if !allow_login_shell => {
return Err(
@@ -502,13 +512,19 @@ pub(crate) fn get_command(
shell
});
let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref());
Ok(shell.derive_exec_args(&args.cmd, use_login_shell))
Ok(ResolvedCommand {
command: shell.derive_exec_args(&args.cmd, use_login_shell),
shell_type: shell.shell_type.clone(),
})
}
UnifiedExecShellMode::ZshFork(zsh_fork_config) => Ok(vec![
zsh_fork_config.shell_zsh_path.to_string_lossy().to_string(),
if use_login_shell { "-lc" } else { "-c" }.to_string(),
args.cmd.clone(),
]),
UnifiedExecShellMode::ZshFork(zsh_fork_config) => Ok(ResolvedCommand {
command: vec![
zsh_fork_config.shell_zsh_path.to_string_lossy().to_string(),
if use_login_shell { "-lc" } else { "-c" }.to_string(),
args.cmd.clone(),
],
shell_type: ShellType::Zsh,
}),
}
}

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::shell::ShellType;
use crate::shell::default_user_shell;
use codex_tools::UnifiedExecShellMode;
use codex_tools::ZshForkConfig;
@@ -42,13 +43,14 @@ fn test_get_command_uses_default_shell_when_unspecified() -> anyhow::Result<()>
assert!(args.shell.is_none());
let command = get_command(
let resolved = get_command(
&args,
Arc::new(default_user_shell()),
&UnifiedExecShellMode::Direct,
/*allow_login_shell*/ true,
)
.map_err(anyhow::Error::msg)?;
let command = resolved.command;
assert_eq!(command.len(), 3);
assert_eq!(command[2], "echo hello");
@@ -63,13 +65,14 @@ fn test_get_command_respects_explicit_bash_shell() -> anyhow::Result<()> {
assert_eq!(args.shell.as_deref(), Some("/bin/bash"));
let command = get_command(
let resolved = get_command(
&args,
Arc::new(default_user_shell()),
&UnifiedExecShellMode::Direct,
/*allow_login_shell*/ true,
)
.map_err(anyhow::Error::msg)?;
let command = resolved.command;
assert_eq!(command.last(), Some(&"echo hello".to_string()));
if command
@@ -83,21 +86,37 @@ fn test_get_command_respects_explicit_bash_shell() -> anyhow::Result<()> {
#[test]
fn test_get_command_respects_explicit_powershell_shell() -> anyhow::Result<()> {
let json = r#"{"cmd": "echo hello", "shell": "powershell"}"#;
let temp_dir = tempfile::tempdir()?;
let powershell_path = temp_dir.path().join(if cfg!(windows) {
"powershell.exe"
} else {
"powershell"
});
std::fs::write(&powershell_path, "")?;
let json = serde_json::json!({
"cmd": "echo hello",
"shell": powershell_path,
})
.to_string();
let args: ExecCommandArgs = parse_arguments(json)?;
let args: ExecCommandArgs = parse_arguments(&json)?;
assert_eq!(args.shell.as_deref(), Some("powershell"));
assert_eq!(
args.shell.as_deref(),
Some(powershell_path.to_string_lossy().as_ref())
);
let command = get_command(
let resolved = get_command(
&args,
Arc::new(default_user_shell()),
&UnifiedExecShellMode::Direct,
/*allow_login_shell*/ true,
)
.map_err(anyhow::Error::msg)?;
let command = resolved.command;
assert_eq!(command[2], "echo hello");
assert_eq!(resolved.shell_type, ShellType::PowerShell);
Ok(())
}
@@ -109,13 +128,14 @@ fn test_get_command_respects_explicit_cmd_shell() -> anyhow::Result<()> {
assert_eq!(args.shell.as_deref(), Some("cmd"));
let command = get_command(
let resolved = get_command(
&args,
Arc::new(default_user_shell()),
&UnifiedExecShellMode::Direct,
/*allow_login_shell*/ true,
)
.map_err(anyhow::Error::msg)?;
let command = resolved.command;
assert_eq!(command[2], "echo hello");
Ok(())
@@ -159,7 +179,7 @@ fn test_get_command_ignores_explicit_shell_in_zsh_fork_mode() -> anyhow::Result<
})?,
});
let command = get_command(
let resolved = get_command(
&args,
Arc::new(default_user_shell()),
&shell_mode,
@@ -168,13 +188,14 @@ fn test_get_command_ignores_explicit_shell_in_zsh_fork_mode() -> anyhow::Result<
.map_err(anyhow::Error::msg)?;
assert_eq!(
command,
resolved.command,
vec![
shell_zsh_path.to_string_lossy().to_string(),
"-lc".to_string(),
"echo hello".to_string()
]
);
assert_eq!(resolved.shell_type, ShellType::Zsh);
Ok(())
}

View File

@@ -8,6 +8,7 @@ use crate::exec_env::CODEX_THREAD_ID_ENV_VAR;
use crate::path_utils;
use crate::sandboxing::SandboxPermissions;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::tools::sandboxing::ToolError;
#[cfg(target_os = "macos")]
use codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER;
@@ -15,8 +16,10 @@ use codex_network_proxy::PROXY_ACTIVE_ENV_KEY;
use codex_network_proxy::PROXY_ENV_KEYS;
#[cfg(target_os = "macos")]
use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxType;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
@@ -67,6 +70,35 @@ pub(crate) fn exec_env_for_sandbox_permissions(
env
}
pub(crate) fn disable_powershell_profile_for_elevated_windows_sandbox(
command: &[String],
shell_type: Option<&ShellType>,
sandbox: SandboxType,
windows_sandbox_level: WindowsSandboxLevel,
) -> Vec<String> {
if shell_type != Some(&ShellType::PowerShell)
|| sandbox != SandboxType::WindowsRestrictedToken
|| windows_sandbox_level != WindowsSandboxLevel::Elevated
|| command.is_empty()
{
return command.to_vec();
}
if command[1..]
.iter()
.any(|arg| arg.eq_ignore_ascii_case("-NoProfile"))
{
return command.to_vec();
}
// The elevated Windows sandbox runs as a dedicated sandbox account while
// HOME/USERPROFILE may still point at the real user profile. Loading
// PowerShell profiles in that mixed context is not a valid login shell.
let mut command = command.to_vec();
command.insert(1, "-NoProfile".to_string());
command
}
/// POSIX-only helper: for commands produced by `Shell::derive_exec_args`
/// for Bash/Zsh/sh of the form `[shell_path, "-lc", "<script>"]`, and
/// when a snapshot is configured on the session shell, rewrite the argv
@@ -257,6 +289,137 @@ fn shell_single_quote(input: &str) -> String {
input.replace('\'', r#"'"'"'"#)
}
#[cfg(test)]
mod disable_powershell_profile_tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn inserts_no_profile_for_elevated_windows_sandbox() {
let command = vec![
"powershell.exe".to_string(),
"-Command".to_string(),
"Write-Output ok".to_string(),
];
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
&command,
Some(&ShellType::PowerShell),
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Elevated,
);
assert_eq!(
rewritten,
vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"Write-Output ok".to_string(),
]
);
}
#[test]
fn inserts_no_profile_before_encoded_command() {
let command = vec![
"powershell.exe".to_string(),
"-EncodedCommand".to_string(),
"VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABvAGsA".to_string(),
];
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
&command,
Some(&ShellType::PowerShell),
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Elevated,
);
assert_eq!(
rewritten,
vec![
"powershell.exe".to_string(),
"-NoProfile".to_string(),
"-EncodedCommand".to_string(),
"VwByAGkAdABlAC0ATwB1AHQAcAB1AHQAIABvAGsA".to_string(),
]
);
}
#[test]
fn preserves_existing_no_profile() {
let command = vec![
"pwsh.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
"Write-Output ok".to_string(),
];
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
&command,
Some(&ShellType::PowerShell),
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Elevated,
);
assert_eq!(rewritten, command);
}
#[test]
fn leaves_legacy_restricted_token_backend_alone() {
let command = vec![
"powershell.exe".to_string(),
"-Command".to_string(),
"Write-Output ok".to_string(),
];
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
&command,
Some(&ShellType::PowerShell),
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::RestrictedToken,
);
assert_eq!(rewritten, command);
}
#[test]
fn leaves_unsandboxed_attempts_alone() {
let command = vec![
"powershell.exe".to_string(),
"-Command".to_string(),
"Write-Output ok".to_string(),
];
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
&command,
Some(&ShellType::PowerShell),
SandboxType::None,
WindowsSandboxLevel::Elevated,
);
assert_eq!(rewritten, command);
}
#[test]
fn leaves_non_powershell_alone() {
let command = vec![
"/bin/bash".to_string(),
"-lc".to_string(),
"echo ok".to_string(),
];
let rewritten = disable_powershell_profile_for_elevated_windows_sandbox(
&command,
Some(&ShellType::Bash),
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Elevated,
);
assert_eq!(rewritten, command);
}
}
#[cfg(all(test, unix))]
#[path = "mod_tests.rs"]
mod tests;

View File

@@ -20,6 +20,7 @@ use crate::shell::ShellType;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::build_sandbox_command;
use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sandbox;
use crate::tools::runtimes::exec_env_for_sandbox_permissions;
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::sandboxing::Approvable;
@@ -48,6 +49,7 @@ use std::collections::HashMap;
#[derive(Clone, Debug)]
pub struct ShellRequest {
pub command: Vec<String>,
pub shell_type: Option<ShellType>,
pub hook_command: String,
pub cwd: AbsolutePathBuf,
pub timeout_ms: Option<u64>,
@@ -256,6 +258,12 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
&req.explicit_env_overrides,
&env,
);
let command = disable_powershell_profile_for_elevated_windows_sandbox(
&command,
req.shell_type.as_ref(),
attempt.sandbox,
attempt.windows_sandbox_level,
);
let command = if matches!(session_shell.shell_type, ShellType::PowerShell) {
prefix_powershell_script_with_utf8(&command)
} else {

View File

@@ -17,6 +17,7 @@ use crate::shell::ShellType;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::build_sandbox_command;
use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sandbox;
use crate::tools::runtimes::exec_env_for_sandbox_permissions;
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::runtimes::shell::zsh_fork_backend;
@@ -57,6 +58,7 @@ use tokio_util::sync::CancellationToken;
#[derive(Clone, Debug)]
pub struct UnifiedExecRequest {
pub command: Vec<String>,
pub shell_type: ShellType,
pub hook_command: String,
pub process_id: i32,
pub cwd: AbsolutePathBuf,
@@ -271,6 +273,12 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
&env,
)
};
let command = disable_powershell_profile_for_elevated_windows_sandbox(
&command,
Some(&req.shell_type),
attempt.sandbox,
attempt.windows_sandbox_level,
);
let command = if matches!(session_shell.shell_type, ShellType::PowerShell) {
prefix_powershell_script_with_utf8(&command)
} else {

View File

@@ -38,6 +38,7 @@ use tokio::sync::Mutex;
use crate::sandboxing::SandboxPermissions;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::shell::ShellType;
use crate::tools::network_approval::DeferredNetworkApproval;
mod async_watcher;
@@ -90,6 +91,7 @@ impl UnifiedExecContext {
#[derive(Debug)]
pub(crate) struct ExecCommandRequest {
pub command: Vec<String>,
pub shell_type: ShellType,
pub hook_command: String,
pub process_id: i32,
pub yield_time_ms: u64,

View File

@@ -1020,6 +1020,7 @@ impl UnifiedExecProcessManager {
.await;
let req = UnifiedExecToolRequest {
command: request.command.clone(),
shell_type: request.shell_type.clone(),
hook_command: request.hook_command.clone(),
process_id: request.process_id,
cwd,

View File

@@ -171,6 +171,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() {
"-lc".to_string(),
"echo before".to_string(),
],
shell_type: crate::shell::ShellType::Sh,
hook_command: "echo before".to_string(),
process_id: 123,
yield_time_ms: 1000,