Harden model-selected shell approval boundaries

This commit is contained in:
Chris Bookholt
2026-07-03 09:04:38 -07:00
parent e791609b0f
commit fbd4ac4a4d
22 changed files with 4106 additions and 295 deletions
+3 -23
View File
@@ -1,29 +1,9 @@
use codex_shell_command::bash::extract_bash_command;
use codex_shell_command::bash::parse_shell_lc_plain_commands;
const CANONICAL_BASH_SCRIPT_PREFIX: &str = "__codex_shell_script__";
/// Canonicalize command argv for approval-cache matching.
///
/// Bash word-only scripts retain their historical inner-command identity. For
/// PowerShell, the executable, wrapper flags, and exact script are all part of
/// the key because each affects the authorization boundary.
/// Approval identity preserves the literal executable, wrapper arguments, and
/// script text. Each affects the authorization boundary, so even semantically
/// similar argv vectors remain distinct cache entries.
pub(crate) fn canonicalize_command_for_approval(command: &[String]) -> Vec<String> {
if let Some(commands) = parse_shell_lc_plain_commands(command)
&& let [single_command] = commands.as_slice()
{
return single_command.clone();
}
if let Some((_shell, script)) = extract_bash_command(command) {
let shell_mode = command.get(1).cloned().unwrap_or_default();
return vec![
CANONICAL_BASH_SCRIPT_PREFIX.to_string(),
shell_mode,
script.to_string(),
];
}
command.to_vec()
}
@@ -2,6 +2,10 @@ use super::canonicalize_command_for_approval;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
fn posix(shell: &str, mode: &str, script: &str) -> Vec<String> {
vec![shell.to_string(), mode.to_string(), script.to_string()]
}
fn powershell(executable: &str, command_flag: &str, script: &str) -> Vec<String> {
vec![
executable.to_string(),
@@ -12,54 +16,58 @@ fn powershell(executable: &str, command_flag: &str, script: &str) -> Vec<String>
}
#[test]
fn canonicalizes_word_only_shell_scripts_to_inner_command() {
let command_a = vec![
"/bin/bash".to_string(),
"-lc".to_string(),
"cargo test -p codex-core".to_string(),
];
let command_b = vec![
"bash".to_string(),
"-lc".to_string(),
"cargo test -p codex-core".to_string(),
fn preserves_exact_posix_approval_identity() {
let base = posix("/bin/bash", "-lc", "printf '%s\\n' value");
let commands = vec![
base.clone(),
posix("bash", "-lc", "printf '%s\\n' value"),
posix("./bash", "-lc", "printf '%s\\n' value"),
posix("/tmp/workspace/bash", "-lc", "printf '%s\\n' value"),
posix("/bin/zsh", "-lc", "printf '%s\\n' value"),
posix("/bin/sh", "-lc", "printf '%s\\n' value"),
posix("/bin/bash", "-c", "printf '%s\\n' value"),
posix("/bin/bash", "-lc", "printf '%s\\n' value"),
posix("/bin/bash", "-lc", "printf \"%s\\n\" value"),
posix("/bin/bash", "-lc", "printf '%s\\n' *"),
posix("/bin/bash", "-lc", "printf '%s\\n' ~"),
posix("/bin/bash", "-lc", "printf '%s\\n' \"$(id)\""),
posix("/bin/bash", "-lc", "printf '%s\\n' value >out"),
posix("/bin/bash", "-lc", "python3 <<'PY'\nprint('value')\nPY"),
posix("/bin/bash", "-lc", ""),
posix("/bin/bash", "-lc", "printf '%s\\n' changed"),
vec![
"printf".to_string(),
"%s\\n".to_string(),
"value".to_string(),
],
];
let keys: HashSet<_> = commands
.iter()
.map(|command| canonicalize_command_for_approval(command))
.collect();
assert_eq!(keys.len(), commands.len());
assert_eq!(
canonicalize_command_for_approval(&command_a),
vec![
"cargo".to_string(),
"test".to_string(),
"-p".to_string(),
"codex-core".to_string(),
]
);
assert_eq!(
canonicalize_command_for_approval(&command_a),
canonicalize_command_for_approval(&command_b)
canonicalize_command_for_approval(&base),
canonicalize_command_for_approval(&base)
);
for command in commands {
assert_eq!(canonicalize_command_for_approval(&command), command);
}
}
#[test]
fn canonicalizes_heredoc_scripts_to_stable_script_key() {
let script = "python3 <<'PY'\nprint('hello')\nPY";
let command_a = vec![
"/bin/zsh".to_string(),
"-lc".to_string(),
script.to_string(),
];
let command_b = vec!["zsh".to_string(), "-lc".to_string(), script.to_string()];
fn posix_identity_does_not_collide_with_raw_marker_argv() {
let shell = posix("/bin/bash", "-lc", "echo marker");
let mut marker_argv = vec!["__codex_shell_script__".to_string()];
marker_argv.extend(shell.iter().cloned());
assert_eq!(
canonicalize_command_for_approval(&command_a),
vec![
"__codex_shell_script__".to_string(),
"-lc".to_string(),
script.to_string(),
]
);
assert_eq!(
canonicalize_command_for_approval(&command_a),
canonicalize_command_for_approval(&command_b)
assert_eq!(canonicalize_command_for_approval(&shell), shell);
assert_eq!(canonicalize_command_for_approval(&marker_argv), marker_argv);
assert_ne!(
canonicalize_command_for_approval(&shell),
canonicalize_command_for_approval(&marker_argv)
);
}
+93 -6
View File
@@ -280,13 +280,100 @@ fn create_env_preserves_existing_pathext_case_insensitively_on_windows() {
let result = create_env_from_vars(vars, &policy, /*thread_id*/ None);
let pathext_vars = result
.iter()
.filter(|(key, _)| key.eq_ignore_ascii_case("PATHEXT"))
.collect::<Vec<_>>();
assert_eq!(
result,
HashMap::from([(
"PATHEXT".to_string(),
".COM;.EXE;.BAT;.CMD;.PS1".to_string(),
)])
);
}
assert_eq!(pathext_vars.len(), 1);
assert_eq!(pathext_vars[0].1, ".COM;.EXE;.BAT;.CMD;.PS1");
#[test]
#[cfg(target_os = "windows")]
fn create_env_prefers_configured_canonical_path_over_inherited_aliases() {
let vars = make_vars(&[
("Path", "C:\\inherited-mixed"),
("path", "C:\\inherited-lower"),
]);
let mut policy = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::All,
ignore_default_excludes: true,
..Default::default()
};
policy
.r#set
.insert("PATH".to_string(), "C:\\configured".to_string());
let result = create_env_from_vars(vars, &policy, /*thread_id*/ None);
assert_eq!(
result.get("PATH").map(String::as_str),
Some("C:\\configured")
);
assert_eq!(
result
.keys()
.filter(|key| key.eq_ignore_ascii_case("PATH"))
.count(),
1
);
}
#[test]
#[cfg(target_os = "windows")]
fn create_env_prefers_configured_path_alias_over_inherited_canonical_key() {
let vars = make_vars(&[("PATH", "C:\\inherited")]);
let mut policy = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::All,
ignore_default_excludes: true,
..Default::default()
};
policy
.r#set
.insert("path".to_string(), "C:\\configured-alias".to_string());
let result = create_env_from_vars(vars, &policy, /*thread_id*/ None);
assert_eq!(
result.get("PATH").map(String::as_str),
Some("C:\\configured-alias")
);
assert_eq!(
result
.keys()
.filter(|key| key.eq_ignore_ascii_case("PATH"))
.count(),
1
);
}
#[test]
#[cfg(target_os = "windows")]
fn create_env_prefers_configured_canonical_pathext_and_removes_aliases() {
let vars = make_vars(&[("PathExt", ".COM;.EXE;.BAT;.CMD")]);
let mut policy = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::All,
ignore_default_excludes: true,
..Default::default()
};
policy
.r#set
.insert("pathext".to_string(), ".EXE;.CMD".to_string());
policy
.r#set
.insert("PATHEXT".to_string(), ".EXE".to_string());
let result = create_env_from_vars(vars, &policy, /*thread_id*/ None);
assert_eq!(result.get("PATHEXT").map(String::as_str), Some(".EXE"));
assert_eq!(
result
.keys()
.filter(|key| key.eq_ignore_ascii_case("PATHEXT"))
.count(),
1
);
}
#[test]
+774 -69
View File
@@ -36,8 +36,12 @@ use crate::config::Config;
use crate::sandboxing::SandboxPermissions;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::unsandboxed_execution_allowed;
use codex_shell_command::bash::extract_bash_command;
use codex_shell_command::bash::parse_shell_lc_plain_commands;
use codex_shell_command::bash::parse_shell_lc_single_command_prefix;
use codex_shell_command::bash::try_parse_shell;
use codex_shell_command::shell_detect::ShellType;
use codex_shell_command::shell_detect::detect_shell_type;
use codex_utils_absolute_path::AbsolutePathBuf;
use shlex::try_join as shlex_try_join;
@@ -139,6 +143,484 @@ struct ExecPolicyCommands {
command_origin: ExecPolicyCommandOrigin,
}
const MAX_POSIX_POLICY_DEPTH: usize = 8;
const MAX_POSIX_POLICY_CANDIDATES: usize = 64;
const MAX_POSIX_POLICY_SCRIPT_BYTES: usize = 64 * 1024;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ShellRuntimeSource {
EnvironmentSelected,
ModelResolved,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ShellSelectionSource {
Configured,
ModelSelected,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct ShellApprovalProvenance {
runtime: ShellRuntimeSource,
selection: ShellSelectionSource,
}
impl ShellApprovalProvenance {
pub(crate) const fn configured() -> Self {
Self {
runtime: ShellRuntimeSource::EnvironmentSelected,
selection: ShellSelectionSource::Configured,
}
}
pub(crate) const fn local_model_resolved() -> Self {
Self {
runtime: ShellRuntimeSource::ModelResolved,
selection: ShellSelectionSource::ModelSelected,
}
}
pub(crate) const fn remote_model_hint() -> Self {
Self {
runtime: ShellRuntimeSource::EnvironmentSelected,
selection: ShellSelectionSource::ModelSelected,
}
}
const fn requires_outer_policy(self) -> bool {
matches!(self.runtime, ShellRuntimeSource::ModelResolved)
}
pub(crate) const fn is_local_model_resolved(self) -> bool {
self.requires_outer_policy()
}
const fn selection_is_model_supplied(self) -> bool {
matches!(self.selection, ShellSelectionSource::ModelSelected)
}
const fn allows_generated_amendment(self) -> bool {
!self.selection_is_model_supplied()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PosixAnalysisCompleteness {
Complete,
Incomplete,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ExecPolicyCandidateRole {
UntrustedWrapper,
InnerCommand,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ExecPolicyCandidate {
argv: Vec<String>,
role: ExecPolicyCandidateRole,
command_origin: ExecPolicyCommandOrigin,
}
#[derive(Debug, Eq, PartialEq)]
struct PosixPolicyAnalysis {
candidates: Vec<ExecPolicyCandidate>,
completeness: PosixAnalysisCompleteness,
contains_untrusted_wrapper: bool,
script_bytes: usize,
}
impl PosixPolicyAnalysis {
fn new() -> Self {
Self {
candidates: Vec::new(),
completeness: PosixAnalysisCompleteness::Complete,
contains_untrusted_wrapper: false,
script_bytes: 0,
}
}
fn mark_incomplete(&mut self) {
self.completeness = PosixAnalysisCompleteness::Incomplete;
}
fn push_candidate(&mut self, candidate: ExecPolicyCandidate) -> bool {
if self.candidates.len() >= MAX_POSIX_POLICY_CANDIDATES {
self.mark_incomplete();
return false;
}
self.candidates.push(candidate);
true
}
fn add_script_bytes(&mut self, bytes: usize) -> bool {
let Some(total) = self.script_bytes.checked_add(bytes) else {
self.mark_incomplete();
return false;
};
if total > MAX_POSIX_POLICY_SCRIPT_BYTES {
self.mark_incomplete();
return false;
}
self.script_bytes = total;
true
}
}
fn is_posix_shell_executable(program: &str) -> bool {
matches!(
detect_shell_type(Path::new(program)),
Some(ShellType::Bash | ShellType::Sh | ShellType::Zsh)
)
}
fn executable_basename_lowercase(program: &str) -> String {
let basename = program
.rsplit(['/', '\\'])
.next()
.unwrap_or(program)
.to_ascii_lowercase();
basename
.strip_suffix(".exe")
.unwrap_or(&basename)
.to_string()
}
fn executable_spelling_is_absolute(program: &str) -> bool {
// Intentionally use controller-native path semantics. A spelling that is
// absolute only for a foreign remote target conservatively cannot establish
// authority to bypass this controller's sandbox or approval boundary.
Path::new(program).is_absolute()
}
fn executable_may_hide_nested_execution(program: &str) -> bool {
let basename = executable_basename_lowercase(program);
matches!(
basename.as_str(),
"." | "source"
| "trap"
| "env"
| "sudo"
| "doas"
| "su"
| "runuser"
| "command"
| "nice"
| "nohup"
| "timeout"
| "time"
| "watch"
| "chroot"
| "setsid"
| "setpriv"
| "stdbuf"
| "ionice"
| "taskset"
| "exec"
| "eval"
| "builtin"
| "noglob"
| "nocorrect"
| "xargs"
| "parallel"
| "busybox"
| "ash"
| "csh"
| "dash"
| "fish"
| "ksh"
| "mksh"
| "rc"
| "tcsh"
| "cmd"
| "powershell"
| "pwsh"
)
}
fn transparent_executor_is_query_only(command: &[String]) -> bool {
let arguments = command.get(1..).unwrap_or_default();
match command.first().map(String::as_str) {
Some("command") => {
arguments.is_empty()
|| arguments
.first()
.is_some_and(|flag| matches!(flag.as_str(), "-v" | "-V"))
}
Some("trap") => {
arguments.is_empty()
|| matches!(arguments, [flag] if matches!(flag.as_str(), "-l" | "-p"))
}
Some(_) | None => false,
}
}
fn command_may_hide_nested_execution(command: &[String]) -> bool {
let Some(program) = command.first() else {
return true;
};
let basename = executable_basename_lowercase(program);
if executable_may_hide_nested_execution(program) {
return !transparent_executor_is_query_only(command);
}
basename == "find"
&& command
.iter()
.skip(1)
.any(|argument| matches!(argument.as_str(), "-exec" | "-execdir" | "-ok" | "-okdir"))
}
/// Returns true when a configured shell body that the strict argv extractor
/// could not fully reduce still has structural evidence of descendant
/// execution. A single complex command (for example, a heredoc or redirect)
/// remains on the legacy configured-shell path, while parse errors, multiple
/// commands, nested shells, and known delegators fail closed when policy rules
/// are active.
fn incomplete_posix_body_may_hide_descendant_execution(command: &[String]) -> bool {
let Some((_shell, script)) = extract_bash_command(command) else {
return true;
};
if script.len() > MAX_POSIX_POLICY_SCRIPT_BYTES {
return true;
}
let Some(tree) = try_parse_shell(script) else {
return true;
};
let root = tree.root_node();
if root.has_error() {
return true;
}
let mut stack = vec![root];
let mut command_count = 0usize;
while let Some(node) = stack.pop() {
if matches!(
node.kind(),
"c_style_for_statement"
| "case_statement"
| "command_substitution"
| "compound_statement"
| "for_statement"
| "function_definition"
| "if_statement"
| "process_substitution"
| "subshell"
| "while_statement"
) {
return true;
}
if node.kind() == "command" {
command_count += 1;
if command_count > 1 {
return true;
}
let mut cursor = node.walk();
let mut command_argv = Vec::new();
let mut saw_command_name = false;
let mut has_dynamic_argument = false;
for child in node.named_children(&mut cursor) {
match child.kind() {
"command_name" => {
let Some(word) = child.named_child(0) else {
return true;
};
if word.kind() != "word" || word.named_child_count() != 0 {
return true;
}
let Ok(program) = word.utf8_text(script.as_bytes()) else {
return true;
};
command_argv.push(program.to_string());
saw_command_name = true;
}
"word" | "number" | "concatenation" | "expansion" | "simple_expansion"
if saw_command_name =>
{
has_dynamic_argument |= matches!(
child.kind(),
"concatenation" | "expansion" | "simple_expansion"
) || child.named_child_count() != 0;
let Ok(argument) = child.utf8_text(script.as_bytes()) else {
return true;
};
command_argv.push(argument.to_string());
}
"raw_string" | "string" if saw_command_name => {
if child.kind() == "string" {
let mut argument_cursor = child.walk();
has_dynamic_argument |=
child.named_children(&mut argument_cursor).any(|part| {
matches!(
part.kind(),
"arithmetic_expansion"
| "command_substitution"
| "expansion"
| "process_substitution"
| "simple_expansion"
)
});
}
let Ok(argument) = child.utf8_text(script.as_bytes()) else {
return true;
};
let unquoted = argument
.strip_prefix('\'')
.and_then(|argument| argument.strip_suffix('\''))
.or_else(|| {
argument
.strip_prefix('"')
.and_then(|argument| argument.strip_suffix('"'))
})
.unwrap_or(argument);
command_argv.push(unquoted.to_string());
}
"variable_assignment" if !saw_command_name => {}
"file_redirect" | "heredoc_redirect" => {}
_ => return true,
}
}
let Some(program) = command_argv.first() else {
return true;
};
let basename = executable_basename_lowercase(program);
if has_dynamic_argument
&& (basename == "find"
|| (executable_may_hide_nested_execution(program)
&& !transparent_executor_is_query_only(&command_argv)))
{
return true;
}
if is_posix_shell_executable(program)
|| command_may_hide_nested_execution(&command_argv)
{
return true;
}
}
let mut cursor = node.walk();
stack.extend(node.children(&mut cursor));
}
false
}
fn analyze_posix_policy(
command: &[String],
provenance: ShellApprovalProvenance,
) -> Option<PosixPolicyAnalysis> {
let program = command.first()?;
if !is_posix_shell_executable(program) {
return None;
}
let mut analysis = PosixPolicyAnalysis::new();
analyze_posix_wrapper(
command,
provenance.requires_outer_policy(),
/*depth*/ 0,
&mut analysis,
);
Some(analysis)
}
fn opaque_untrusted_wrapper_analysis(command: &[String]) -> PosixPolicyAnalysis {
PosixPolicyAnalysis {
candidates: vec![ExecPolicyCandidate {
argv: command.to_vec(),
role: ExecPolicyCandidateRole::UntrustedWrapper,
command_origin: ExecPolicyCommandOrigin::Generic,
}],
completeness: PosixAnalysisCompleteness::Incomplete,
contains_untrusted_wrapper: true,
script_bytes: 0,
}
}
fn analyze_posix_wrapper(
command: &[String],
untrusted_wrapper: bool,
depth: usize,
analysis: &mut PosixPolicyAnalysis,
) {
if untrusted_wrapper {
analysis.contains_untrusted_wrapper = true;
if !analysis.push_candidate(ExecPolicyCandidate {
argv: command.to_vec(),
role: ExecPolicyCandidateRole::UntrustedWrapper,
command_origin: ExecPolicyCommandOrigin::Generic,
}) {
return;
}
}
if depth >= MAX_POSIX_POLICY_DEPTH {
analysis.mark_incomplete();
return;
}
let Some((_shell, script)) = extract_bash_command(command) else {
analysis.mark_incomplete();
return;
};
if script.trim().is_empty() || !analysis.add_script_bytes(script.len()) {
analysis.mark_incomplete();
return;
}
if let Some(commands) = parse_shell_lc_plain_commands(command)
&& !commands.is_empty()
{
for inner in commands {
analyze_posix_inner_command(inner, depth, analysis);
}
return;
}
if let Some(inner) = parse_shell_lc_single_command_prefix(command) {
analyze_posix_inner_command(inner, depth, analysis);
}
analysis.mark_incomplete();
}
fn analyze_posix_inner_command(
command: Vec<String>,
parent_depth: usize,
analysis: &mut PosixPolicyAnalysis,
) {
if command.is_empty() {
analysis.mark_incomplete();
return;
}
if command
.first()
.is_some_and(|program| is_posix_shell_executable(program))
{
analyze_posix_wrapper(
&command,
/*untrusted_wrapper*/ true,
parent_depth + 1,
analysis,
);
return;
}
let may_hide_nested_execution = command_may_hide_nested_execution(&command);
if !analysis.push_candidate(ExecPolicyCandidate {
argv: command,
role: ExecPolicyCandidateRole::InnerCommand,
command_origin: ExecPolicyCommandOrigin::Generic,
}) {
return;
}
if may_hide_nested_execution {
analysis.contains_untrusted_wrapper = true;
analysis.mark_incomplete();
}
}
pub(crate) fn child_uses_parent_exec_policy(parent_config: &Config, child_config: &Config) -> bool {
fn exec_policy_config_folders(config: &Config) -> Vec<AbsolutePathBuf> {
config
@@ -295,6 +777,34 @@ impl ExecPolicyManager {
pub(crate) async fn create_exec_approval_requirement_for_command(
&self,
req: ExecApprovalRequest<'_>,
) -> ExecApprovalRequirement {
let permission_expansion_was_requested =
req.sandbox_permissions.requests_sandbox_override();
self.create_exec_approval_requirement_for_configured_command(
req,
permission_expansion_was_requested,
)
.await
}
pub(crate) async fn create_exec_approval_requirement_for_configured_command(
&self,
req: ExecApprovalRequest<'_>,
permission_expansion_was_requested: bool,
) -> ExecApprovalRequirement {
self.create_exec_approval_requirement_for_command_with_provenance(
req,
ShellApprovalProvenance::configured(),
permission_expansion_was_requested,
)
.await
}
pub(crate) async fn create_exec_approval_requirement_for_command_with_provenance(
&self,
req: ExecApprovalRequest<'_>,
provenance: ShellApprovalProvenance,
permission_expansion_was_requested: bool,
) -> ExecApprovalRequirement {
let ExecApprovalRequest {
command,
@@ -304,6 +814,8 @@ impl ExecPolicyManager {
sandbox_permissions,
prefix_rule,
} = req;
let permission_expansion_was_requested =
permission_expansion_was_requested || sandbox_permissions.requests_sandbox_override();
let exec_policy = self.current();
#[cfg(windows)]
let (parsed_powershell, powershell_outer_authority) =
@@ -346,6 +858,53 @@ impl ExecPolicyManager {
let exec_policy_commands = commands_for_exec_policy(command);
#[cfg(not(windows))]
let powershell_outer_authority = false;
let posix_analysis = analyze_posix_policy(command, provenance);
let command_rules_active = exec_policy.rules().iter_all().next().is_some();
if let Some(analysis) = posix_analysis.as_ref()
&& (provenance.requires_outer_policy()
|| (command_rules_active
&& (analysis.contains_untrusted_wrapper
|| (analysis.completeness == PosixAnalysisCompleteness::Incomplete
&& incomplete_posix_body_may_hide_descendant_execution(command)))))
{
return create_untrusted_wrapper_approval_requirement(
exec_policy.as_ref(),
command,
analysis,
/*allow_exact_opaque_wrapper_for_environment_runtime*/
!provenance.requires_outer_policy(),
UnmatchedCommandContext {
approval_policy,
permission_profile: &permission_profile,
windows_sandbox_level,
sandbox_permissions,
used_complex_parsing: matches!(
analysis.completeness,
PosixAnalysisCompleteness::Incomplete
),
command_origin: ExecPolicyCommandOrigin::Generic,
},
);
}
if provenance.requires_outer_policy() {
let analysis = opaque_untrusted_wrapper_analysis(command);
return create_untrusted_wrapper_approval_requirement(
exec_policy.as_ref(),
command,
&analysis,
/*allow_exact_opaque_wrapper_for_environment_runtime*/ false,
UnmatchedCommandContext {
approval_policy,
permission_profile: &permission_profile,
windows_sandbox_level,
sandbox_permissions,
used_complex_parsing: true,
command_origin: ExecPolicyCommandOrigin::Generic,
},
);
}
let ExecPolicyCommands {
commands,
used_complex_parsing,
@@ -354,7 +913,9 @@ impl ExecPolicyManager {
// Keep heredoc prefix parsing for rule evaluation so existing
// allow/prompt/forbidden rules still apply, but avoid auto-derived
// amendments when only the heredoc fallback parser matched.
let auto_amendment_allowed = !used_complex_parsing;
let auto_amendment_allowed = !used_complex_parsing
&& provenance.allows_generated_amendment()
&& !permission_expansion_was_requested;
let exec_policy_fallback = |cmd: &[String]| {
render_decision_for_unmatched_command(
cmd,
@@ -416,16 +977,31 @@ impl ExecPolicyManager {
.max()
.unwrap_or(Decision::Forbidden);
let permission_delta_requires_outer =
permission_delta_requires_outer_authority(&permission_profile, sandbox_permissions);
let parsed_powershell_needs_outer_approval = parsed_powershell_outer.is_some()
&& !exact_outer_allow
&& (permission_delta_requires_outer
let every_command_explicit_allow = !commands.is_empty()
&& commands.iter().all(|command| {
exec_policy
.matches_for_command_with_options(
command,
/*heuristics_fallback*/ None,
&match_options,
)
.iter()
.any(|rule_match| {
is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
})
});
let effective_full_policy_authority = evaluation.decision == Decision::Allow
&& parsed_powershell_outer.map_or(every_command_explicit_allow, |_| exact_outer_allow);
let permission_or_backend_gate =
permission_delta_requires_outer_authority(&permission_profile, sandbox_permissions)
|| missing_managed_windows_sandbox_backend(
&permission_profile,
windows_sandbox_level,
));
if evaluation.decision != Decision::Forbidden && parsed_powershell_needs_outer_approval {
);
if evaluation.decision != Decision::Forbidden
&& permission_or_backend_gate
&& !effective_full_policy_authority
{
evaluation
.matched_rules
.push(RuleMatch::HeuristicsRuleMatch {
@@ -455,7 +1031,7 @@ impl ExecPolicyManager {
None
};
match evaluation.decision {
let requirement = match evaluation.decision {
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
reason: derive_forbidden_reason(command, &evaluation),
},
@@ -468,6 +1044,9 @@ impl ExecPolicyManager {
None => ExecApprovalRequirement::NeedsApproval {
reason: derive_prompt_reason(command, &evaluation),
proposed_execpolicy_amendment: requested_amendment.or_else(|| {
if !auto_amendment_allowed {
return None;
}
match (
parsed_powershell_outer,
causes.rules,
@@ -489,33 +1068,19 @@ impl ExecPolicyManager {
}
Decision::Allow => ExecApprovalRequirement::Skip {
// Lowered PowerShell command names do not bind runtime module, profile, or PATH
// resolution. Only an exact full-wrapper Allow may authorize sandbox bypass.
bypass_sandbox: if parsed_powershell_outer.is_some() {
exact_outer_allow
} else {
commands.iter().all(|command| {
exec_policy
.matches_for_command_with_options(
command,
/*heuristics_fallback*/ None,
&match_options,
)
.iter()
.any(|rule_match| {
is_policy_match(rule_match)
&& rule_match.decision() == Decision::Allow
})
})
},
proposed_execpolicy_amendment: if let Some(outer) = parsed_powershell_outer {
(!exact_outer_allow).then(|| ExecPolicyAmendment::new(outer.to_vec()))
} else if auto_amendment_allowed {
try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules)
} else {
// resolution. Only effective aggregate Allow plus exact authored authority may
// authorize sandbox bypass.
bypass_sandbox: effective_full_policy_authority,
proposed_execpolicy_amendment: if !auto_amendment_allowed {
None
} else if let Some(outer) = parsed_powershell_outer {
(!exact_outer_allow).then(|| ExecPolicyAmendment::new(outer.to_vec()))
} else {
try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules)
},
},
}
};
narrow_requirement_for_shell_provenance(requirement, provenance)
}
pub(crate) async fn append_amendment_and_update(
@@ -615,28 +1180,38 @@ impl ExecPolicyManager {
}
}
#[cfg(windows)]
fn create_untrusted_powershell_approval_requirement(
fn create_untrusted_wrapper_approval_requirement(
exec_policy: &Policy,
outer_argv: &[String],
commands: &[Vec<String>],
display_argv: &[String],
analysis: &PosixPolicyAnalysis,
allow_exact_opaque_wrapper_for_environment_runtime: bool,
context: UnmatchedCommandContext<'_>,
) -> ExecApprovalRequirement {
let match_options = MatchOptions {
resolve_host_executables: true,
};
let inner_fallback =
|command: &[String]| render_decision_for_unmatched_command(command, context);
let mut matched_rules = Vec::new();
let mut every_inner_explicit_allow = !commands.is_empty();
for command in commands {
let mut every_candidate_explicit_allow = !analysis.candidates.is_empty();
// Preserve the parent's PowerShell match ordering (inner commands, then
// outer wrapper) while applying per-candidate origins and match options.
for candidate in analysis
.candidates
.iter()
.filter(|candidate| candidate.role == ExecPolicyCandidateRole::InnerCommand)
{
let candidate_context = UnmatchedCommandContext {
command_origin: candidate.command_origin,
..context
};
let inner_fallback =
|command: &[String]| render_decision_for_unmatched_command(command, candidate_context);
let inner_matches = exec_policy.matches_for_command_with_options(
command,
&candidate.argv,
Some(&inner_fallback),
&match_options,
);
every_inner_explicit_allow &= !command.is_empty()
every_candidate_explicit_allow &= !candidate.argv.is_empty()
&& inner_matches.iter().any(|rule_match| {
matches!(
rule_match,
@@ -655,32 +1230,45 @@ fn create_untrusted_powershell_approval_requirement(
Decision::Prompt
}
};
matched_rules.extend(
exec_policy
.matches_for_command_with_restrictive_host_rules(outer_argv, Some(&outer_fallback)),
);
let raw_match_options = MatchOptions {
resolve_host_executables: false,
};
let raw_full_outer_allow = exec_policy
.matches_for_command_with_options(
outer_argv,
/*heuristics_fallback*/ None,
&raw_match_options,
)
for candidate in analysis
.candidates
.iter()
.any(|rule_match| {
matches!(
rule_match,
RuleMatch::PrefixRuleMatch {
matched_prefix,
decision: Decision::Allow,
..
} if matched_prefix.len() == outer_argv.len()
)
});
let composed_full_authority = raw_full_outer_allow && every_inner_explicit_allow;
.filter(|candidate| candidate.role == ExecPolicyCandidateRole::UntrustedWrapper)
{
matched_rules.extend(exec_policy.matches_for_command_with_restrictive_host_rules(
&candidate.argv,
Some(&outer_fallback),
));
let raw_full_outer_allow = candidate
.argv
.first()
.is_some_and(|program| executable_spelling_is_absolute(program))
&& exec_policy
.matches_for_command_with_options(
&candidate.argv,
/*heuristics_fallback*/ None,
&raw_match_options,
)
.iter()
.any(|rule_match| {
matches!(
rule_match,
RuleMatch::PrefixRuleMatch {
matched_prefix,
decision: Decision::Allow,
..
} if matched_prefix.len() == candidate.argv.len()
)
});
every_candidate_explicit_allow &= raw_full_outer_allow;
}
let complete_composed_full_authority = analysis.completeness
== PosixAnalysisCompleteness::Complete
&& every_candidate_explicit_allow;
let mut evaluation = Evaluation {
decision: matched_rules
@@ -690,6 +1278,53 @@ fn create_untrusted_powershell_approval_requirement(
.unwrap_or(Decision::Forbidden),
matched_rules,
};
// Preserve configured-shell compatibility for a directly authored exact
// allow of an opaque nested shell argv (for example, `/bin/sh script`).
// This exception is unavailable to a locally model-resolved outer runtime,
// and it does not apply when any parsed inner leaf, Prompt, or Forbidden is
// present.
let exact_opaque_environment_wrapper_authority =
allow_exact_opaque_wrapper_for_environment_runtime
&& analysis.completeness == PosixAnalysisCompleteness::Incomplete
&& analysis
.candidates
.iter()
.all(|candidate| candidate.role == ExecPolicyCandidateRole::UntrustedWrapper)
&& every_candidate_explicit_allow
&& evaluation.decision == Decision::Allow;
let composed_full_authority = evaluation.decision == Decision::Allow
&& (complete_composed_full_authority || exact_opaque_environment_wrapper_authority);
if analysis.completeness == PosixAnalysisCompleteness::Incomplete
&& evaluation.decision != Decision::Forbidden
&& !exact_opaque_environment_wrapper_authority
{
if exec_policy.rules().iter_all().next().is_some() {
let reason = if analysis.contains_untrusted_wrapper {
"cannot completely inspect an untrusted shell wrapper"
} else {
"cannot completely inspect nested execution in this shell command"
};
return ExecApprovalRequirement::Forbidden {
reason: format!(
"`{}` rejected: {reason} while command policy rules are active",
render_shlex_command(display_argv),
),
};
}
// With no command rules to conceal, incomplete analysis may proceed
// only through a callback-scoped prompt. Never let safe-command
// heuristics turn an opaque wrapper into a sandboxed Skip.
evaluation
.matched_rules
.push(RuleMatch::HeuristicsRuleMatch {
command: display_argv.to_vec(),
decision: Decision::Prompt,
});
evaluation.decision = Decision::Prompt;
}
let permission_or_backend_gate = permission_delta_requires_outer_authority(
context.permission_profile,
context.sandbox_permissions,
@@ -704,7 +1339,7 @@ fn create_untrusted_powershell_approval_requirement(
evaluation
.matched_rules
.push(RuleMatch::HeuristicsRuleMatch {
command: outer_argv.to_vec(),
command: display_argv.to_vec(),
decision: Decision::Prompt,
});
evaluation.decision = evaluation
@@ -717,7 +1352,7 @@ fn create_untrusted_powershell_approval_requirement(
match evaluation.decision {
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
reason: derive_forbidden_reason(outer_argv, &evaluation),
reason: derive_forbidden_reason(display_argv, &evaluation),
},
Decision::Prompt => {
match prompt_is_rejected_by_policy(
@@ -728,7 +1363,7 @@ fn create_untrusted_powershell_approval_requirement(
reason: reason.to_string(),
},
None => ExecApprovalRequirement::NeedsOneShotApproval {
reason: derive_prompt_reason(outer_argv, &evaluation),
reason: derive_prompt_reason(display_argv, &evaluation),
},
}
}
@@ -739,6 +1374,63 @@ fn create_untrusted_powershell_approval_requirement(
}
}
#[cfg(windows)]
fn create_untrusted_powershell_approval_requirement(
exec_policy: &Policy,
outer_argv: &[String],
commands: &[Vec<String>],
context: UnmatchedCommandContext<'_>,
) -> ExecApprovalRequirement {
let mut candidates = commands
.iter()
.cloned()
.map(|argv| ExecPolicyCandidate {
argv,
role: ExecPolicyCandidateRole::InnerCommand,
command_origin: context.command_origin,
})
.collect::<Vec<_>>();
candidates.push(ExecPolicyCandidate {
argv: outer_argv.to_vec(),
role: ExecPolicyCandidateRole::UntrustedWrapper,
command_origin: ExecPolicyCommandOrigin::Generic,
});
let analysis = PosixPolicyAnalysis {
candidates,
completeness: PosixAnalysisCompleteness::Complete,
contains_untrusted_wrapper: true,
script_bytes: 0,
};
create_untrusted_wrapper_approval_requirement(
exec_policy,
outer_argv,
&analysis,
/*allow_exact_opaque_wrapper_for_environment_runtime*/ false,
context,
)
}
fn narrow_requirement_for_shell_provenance(
requirement: ExecApprovalRequirement,
provenance: ShellApprovalProvenance,
) -> ExecApprovalRequirement {
if !provenance.selection_is_model_supplied() {
return requirement;
}
match requirement {
ExecApprovalRequirement::NeedsApproval { reason, .. } => {
ExecApprovalRequirement::NeedsOneShotApproval { reason }
}
ExecApprovalRequirement::Skip { bypass_sandbox, .. } => ExecApprovalRequirement::Skip {
bypass_sandbox,
proposed_execpolicy_amendment: None,
},
ExecApprovalRequirement::NeedsOneShotApproval { .. }
| ExecApprovalRequirement::Forbidden { .. } => requirement,
}
}
impl Default for ExecPolicyManager {
fn default() -> Self {
Self::new(Arc::new(Policy::empty()))
@@ -922,6 +1614,19 @@ pub(crate) fn render_decision_for_unmatched_command(
&& windows_sandbox_level == WindowsSandboxLevel::Disabled
&& profile_has_managed_filesystem_restrictions(permission_profile);
// A requested permission expansion is a separate authority boundary. It
// must be considered before a known-safe command can inherit trust from
// the generic safelist. Effective preapproved permissions are normalized
// to UseDefault before reaching exec-policy.
if permission_delta_requires_outer_authority(permission_profile, sandbox_permissions) {
return match approval_policy {
AskForApproval::Never => Decision::Forbidden,
AskForApproval::OnRequest
| AskForApproval::UnlessTrusted
| AskForApproval::Granular(_) => Decision::Prompt,
};
}
if is_known_safe
&& !used_complex_parsing
&& (approval_policy == AskForApproval::UnlessTrusted
File diff suppressed because it is too large Load Diff
+23
View File
@@ -1,7 +1,10 @@
use codex_exec_server::ShellInfo;
use codex_shell_command::shell_detect::DetectedShell;
use codex_shell_command::shell_detect::ModelShellResolveError;
use serde::Deserialize;
use serde::Serialize;
use std::ffi::OsStr;
use std::path::Path;
use std::path::PathBuf;
pub use codex_shell_command::shell_detect::ShellType;
@@ -81,10 +84,30 @@ fn ultimate_fallback_shell() -> Shell {
codex_shell_command::shell_detect::ultimate_fallback_shell().into()
}
/// Legacy configured-shell compatibility helper.
///
/// Production model-selected shell input must use
/// [`resolve_model_provided_shell_in`] so a missing executable cannot fall
/// back to a different shell.
pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> Shell {
codex_shell_command::shell_detect::get_shell_by_model_provided_path(shell_path).into()
}
pub(crate) fn resolve_model_provided_shell_in(
shell_path: &Path,
search_path: &OsStr,
path_ext: Option<&OsStr>,
cwd: &Path,
) -> Result<Shell, ModelShellResolveError> {
codex_shell_command::shell_detect::resolve_model_provided_shell_in(
shell_path,
search_path,
path_ext,
cwd,
)
.map(Into::into)
}
pub fn get_shell(shell_type: ShellType, path: Option<&PathBuf>) -> Option<Shell> {
codex_shell_command::shell_detect::get_shell(shell_type, path).map(Into::into)
}
+31 -16
View File
@@ -165,22 +165,37 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
);
emitter.begin(event_ctx).await;
let exec_approval_requirement = session
.services
.exec_policy
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &exec_params.command,
approval_policy: turn.approval_policy.value(),
permission_profile: turn.permission_profile(),
windows_sandbox_level: turn.windows_sandbox_level,
sandbox_permissions: if effective_additional_permissions.permissions_preapproved {
codex_protocol::models::SandboxPermissions::UseDefault
} else {
effective_additional_permissions.sandbox_permissions
},
prefix_rule,
})
.await;
let exec_approval_request = ExecApprovalRequest {
command: &exec_params.command,
approval_policy: turn.approval_policy.value(),
permission_profile: turn.permission_profile(),
windows_sandbox_level: turn.windows_sandbox_level,
sandbox_permissions: if effective_additional_permissions.permissions_preapproved {
codex_protocol::models::SandboxPermissions::UseDefault
} else {
effective_additional_permissions.sandbox_permissions
},
prefix_rule,
};
let exec_approval_requirement = if effective_additional_permissions.permissions_preapproved {
session
.services
.exec_policy
.create_exec_approval_requirement_for_configured_command(
exec_approval_request,
/*permission_expansion_was_requested*/
effective_additional_permissions
.sandbox_permissions
.requests_sandbox_override(),
)
.await
} else {
session
.services
.exec_policy
.create_exec_approval_requirement_for_command(exec_approval_request)
.await
};
let req = ShellRequest {
command: exec_params.command.clone(),
@@ -1,7 +1,7 @@
use crate::exec_policy::ShellApprovalProvenance;
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::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
@@ -11,7 +11,6 @@ use codex_exec_server::Environment;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_tools::UnifiedExecShellMode;
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::Arc;
#[cfg(test)]
@@ -69,10 +68,11 @@ fn default_tty() -> bool {
false
}
#[derive(Debug)]
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct ResolvedCommand {
pub(crate) command: Vec<String>,
pub(crate) shell_type: ShellType,
pub(crate) shell_approval_provenance: ShellApprovalProvenance,
}
fn post_unified_exec_tool_use_payload(
@@ -96,7 +96,8 @@ fn post_unified_exec_tool_use_payload(
pub(crate) fn get_command(
args: &ExecCommandArgs,
session_shell: Arc<Shell>,
selected_shell: Arc<Shell>,
shell_approval_provenance: ShellApprovalProvenance,
shell_mode: &UnifiedExecShellMode,
allow_login_shell: bool,
) -> Result<ResolvedCommand, String> {
@@ -111,17 +112,11 @@ pub(crate) fn get_command(
};
match shell_mode {
UnifiedExecShellMode::Direct => {
let model_shell = args
.shell
.as_ref()
.map(|shell_str| get_shell_by_model_provided_path(&PathBuf::from(shell_str)));
let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref());
Ok(ResolvedCommand {
command: shell.derive_exec_args(&args.cmd, use_login_shell),
shell_type: shell.shell_type,
})
}
UnifiedExecShellMode::Direct => Ok(ResolvedCommand {
command: selected_shell.derive_exec_args(&args.cmd, use_login_shell),
shell_type: selected_shell.shell_type,
shell_approval_provenance,
}),
UnifiedExecShellMode::ZshFork(zsh_fork_config) => {
if args.shell.is_some() {
return Err(
@@ -136,6 +131,7 @@ pub(crate) fn get_command(
args.cmd.clone(),
],
shell_type: ShellType::Zsh,
shell_approval_provenance: ShellApprovalProvenance::configured(),
})
}
}
@@ -1,8 +1,11 @@
use std::path::Path;
use std::sync::Arc;
use crate::exec_env::create_env;
use crate::exec_policy::ShellApprovalProvenance;
use crate::function_tool::FunctionCallError;
use crate::maybe_emit_implicit_skill_invocation;
use crate::shell::resolve_model_provided_shell_in;
use crate::tools::context::ExecCommandToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
@@ -32,7 +35,7 @@ use codex_otel::TOOL_CALL_UNIFIED_EXEC_METRIC;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use codex_shell_command::shell_detect::detect_shell_type;
use codex_shell_command::shell_detect::detect_shell_type_from_hint;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use codex_utils_output_truncation::approx_token_count;
@@ -58,6 +61,29 @@ pub struct ExecCommandHandler {
options: ExecCommandHandlerOptions,
}
#[cfg(windows)]
pub(super) fn shell_environment_value<'a>(
environment: &'a std::collections::HashMap<String, String>,
name: &str,
) -> Option<&'a str> {
if let Some(value) = environment.get(name) {
return Some(value);
}
environment
.iter()
.filter(|(key, _)| key.eq_ignore_ascii_case(name))
.min_by(|(left, _), (right, _)| left.cmp(right))
.map(|(_, value)| value.as_str())
}
#[cfg(not(windows))]
pub(super) fn shell_environment_value<'a>(
environment: &'a std::collections::HashMap<String, String>,
name: &str,
) -> Option<&'a str> {
environment.get(name).map(String::as_str)
}
impl Default for ExecCommandHandler {
fn default() -> Self {
Self {
@@ -204,41 +230,83 @@ impl ExecCommandHandler {
shell_mode_for_environment(&turn.unified_exec_shell_mode, environment.as_ref());
// Remote environments may use a different OS and must build commands with their native
// shell; fall back to the session shell when the environment did not report one.
let shell = turn_environment
let environment_shell = turn_environment
.shell
.clone()
.map(Arc::new)
.unwrap_or_else(|| session.user_shell());
// TODO(anp): Resolve requested shells in remote environments instead of restricting
// commands to the reported default shell.
if environment.is_remote()
&& let Some(requested_shell) = args.shell.take()
{
let Some(remote_shell) = turn_environment.shell.as_ref() else {
return Err(FunctionCallError::RespondToModel(format!(
"environment `{}` does not report a shell",
turn_environment.environment_id
)));
};
if detect_shell_type(Path::new(&requested_shell)) != Some(remote_shell.shell_type) {
return Err(FunctionCallError::RespondToModel(format!(
"environment `{}` only supports `{}`",
turn_environment.environment_id,
remote_shell.name()
)));
let (selected_shell, shell_approval_provenance) = if environment.is_remote() {
match args.shell.take() {
Some(requested_shell) => {
let Some(remote_shell) = turn_environment.shell.as_ref() else {
return Err(FunctionCallError::RespondToModel(format!(
"environment `{}` does not report a shell",
turn_environment.environment_id
)));
};
if detect_shell_type_from_hint(&requested_shell)
!= Some(remote_shell.shell_type)
{
return Err(FunctionCallError::RespondToModel(format!(
"environment `{}` only supports `{}`",
turn_environment.environment_id,
remote_shell.name()
)));
}
(
Arc::new(remote_shell.clone()),
ShellApprovalProvenance::remote_model_hint(),
)
}
None => (environment_shell, ShellApprovalProvenance::configured()),
}
}
let process_id = manager.allocate_process_id().await;
} else if matches!(&shell_mode, codex_tools::UnifiedExecShellMode::Direct) {
match args.shell.as_deref() {
Some(requested_shell) => {
let resolution_cwd = native_cwd.as_ref().ok_or_else(|| {
FunctionCallError::RespondToModel(
"cannot resolve a local shell against a foreign working directory"
.to_string(),
)
})?;
let shell_env = create_env(
&turn.config.permissions.shell_environment_policy,
/*thread_id*/ None,
);
let search_path =
shell_environment_value(&shell_env, "PATH").unwrap_or_default();
let path_ext =
shell_environment_value(&shell_env, "PATHEXT").map(std::ffi::OsStr::new);
let resolved_shell = resolve_model_provided_shell_in(
Path::new(requested_shell),
std::ffi::OsStr::new(search_path),
path_ext,
resolution_cwd.as_path(),
)
.map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?;
(
Arc::new(resolved_shell),
ShellApprovalProvenance::local_model_resolved(),
)
}
None => (environment_shell, ShellApprovalProvenance::configured()),
}
} else {
(environment_shell, ShellApprovalProvenance::configured())
};
let resolved_command = get_command(
&args,
shell,
selected_shell,
shell_approval_provenance,
&shell_mode,
turn.config.permissions.allow_login_shell,
)
.map_err(FunctionCallError::RespondToModel)?;
let command = resolved_command.command;
let shell_type = resolved_command.shell_type;
let shell_approval_provenance = resolved_command.shell_approval_provenance;
let command_for_display = codex_shell_command::parse_command::shlex_join(&command);
let process_id = manager.allocate_process_id().await;
let ExecCommandArgs {
tty,
@@ -361,6 +429,7 @@ impl ExecCommandHandler {
.permissions_preapproved,
justification,
prefix_rule,
shell_approval_provenance,
},
&context,
)
@@ -1,4 +1,6 @@
use super::*;
use crate::exec_policy::ShellApprovalProvenance;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::shell::default_user_shell;
use codex_exec_server::Environment;
@@ -7,6 +9,8 @@ use codex_tools::ZshForkConfig;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_output_truncation::TruncationPolicy;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use crate::session::step_context::StepContext;
@@ -22,6 +26,46 @@ use tokio::sync::Mutex;
const TEST_TRUNCATION_POLICY: TruncationPolicy = TruncationPolicy::Tokens(10_000);
fn selected_shell(shell_type: ShellType, shell_path: impl Into<PathBuf>) -> Arc<Shell> {
Arc::new(Shell {
shell_type,
shell_path: shell_path.into(),
})
}
#[test]
fn shell_environment_lookup_prefers_exact_canonical_key() {
let environment = HashMap::from([
("Path".to_string(), "inherited-path".to_string()),
("PATH".to_string(), "configured-path".to_string()),
("PathExt".to_string(), ".CMD".to_string()),
("PATHEXT".to_string(), ".EXE".to_string()),
]);
assert_eq!(
super::exec_command::shell_environment_value(&environment, "PATH"),
Some("configured-path")
);
assert_eq!(
super::exec_command::shell_environment_value(&environment, "PATHEXT"),
Some(".EXE")
);
}
#[test]
fn shell_environment_lookup_case_folds_only_on_windows() {
let environment = HashMap::from([("Path".to_string(), "inherited-path".to_string())]);
assert_eq!(
super::exec_command::shell_environment_value(&environment, "PATH"),
if cfg!(windows) {
Some("inherited-path")
} else {
None
}
);
}
async fn invocation_for_payload(
tool_name: &str,
call_id: &str,
@@ -53,6 +97,7 @@ fn test_get_command_uses_default_shell_when_unspecified() -> anyhow::Result<()>
let resolved = get_command(
&args,
Arc::new(default_user_shell()),
ShellApprovalProvenance::configured(),
&UnifiedExecShellMode::Direct,
/*allow_login_shell*/ true,
)
@@ -61,33 +106,42 @@ fn test_get_command_uses_default_shell_when_unspecified() -> anyhow::Result<()>
assert_eq!(command.len(), 3);
assert_eq!(command[2], "echo hello");
assert_eq!(
resolved.shell_approval_provenance,
ShellApprovalProvenance::configured()
);
Ok(())
}
#[test]
fn test_get_command_respects_explicit_bash_shell() -> anyhow::Result<()> {
let json = r#"{"cmd": "echo hello", "shell": "/bin/bash"}"#;
fn test_get_command_uses_already_resolved_explicit_bash_shell() -> anyhow::Result<()> {
let json = r#"{"cmd": "echo hello", "shell": "bash"}"#;
let args: ExecCommandArgs = parse_arguments(json)?;
assert_eq!(args.shell.as_deref(), Some("/bin/bash"));
assert_eq!(args.shell.as_deref(), Some("bash"));
let resolved_path = if cfg!(windows) {
r"C:\resolved\bash.exe"
} else {
"/resolved/bash"
};
let resolved = get_command(
&args,
Arc::new(default_user_shell()),
selected_shell(ShellType::Bash, resolved_path),
ShellApprovalProvenance::local_model_resolved(),
&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
.iter()
.any(|arg| arg.eq_ignore_ascii_case("-Command"))
{
assert!(command.contains(&"-NoProfile".to_string()));
}
assert_eq!(command.first().map(String::as_str), Some(resolved_path));
assert_eq!(command.last().map(String::as_str), Some("echo hello"));
assert_eq!(
resolved.shell_approval_provenance,
ShellApprovalProvenance::local_model_resolved()
);
Ok(())
}
@@ -115,7 +169,8 @@ fn test_get_command_respects_explicit_powershell_shell() -> anyhow::Result<()> {
let resolved = get_command(
&args,
Arc::new(default_user_shell()),
selected_shell(ShellType::PowerShell, powershell_path),
ShellApprovalProvenance::local_model_resolved(),
&UnifiedExecShellMode::Direct,
/*allow_login_shell*/ true,
)
@@ -124,6 +179,10 @@ fn test_get_command_respects_explicit_powershell_shell() -> anyhow::Result<()> {
assert_eq!(command[2], "echo hello");
assert_eq!(resolved.shell_type, ShellType::PowerShell);
assert_eq!(
resolved.shell_approval_provenance,
ShellApprovalProvenance::local_model_resolved()
);
Ok(())
}
@@ -137,7 +196,8 @@ fn test_get_command_respects_explicit_cmd_shell() -> anyhow::Result<()> {
let resolved = get_command(
&args,
Arc::new(default_user_shell()),
selected_shell(ShellType::Cmd, "cmd"),
ShellApprovalProvenance::local_model_resolved(),
&UnifiedExecShellMode::Direct,
/*allow_login_shell*/ true,
)
@@ -145,6 +205,46 @@ fn test_get_command_respects_explicit_cmd_shell() -> anyhow::Result<()> {
let command = resolved.command;
assert_eq!(command[2], "echo hello");
assert_eq!(
resolved.shell_approval_provenance,
ShellApprovalProvenance::local_model_resolved()
);
Ok(())
}
#[test]
fn test_get_command_preserves_remote_hint_provenance_after_discarding_spelling()
-> anyhow::Result<()> {
let json = r#"{"cmd": "pwd", "shell": "/attacker/bash"}"#;
let mut args: ExecCommandArgs = parse_arguments(json)?;
assert_eq!(args.shell.take().as_deref(), Some("/attacker/bash"));
let reported_shell = if cfg!(windows) {
r"C:\environment\bash.exe"
} else {
"/environment/bash"
};
let resolved = get_command(
&args,
selected_shell(ShellType::Bash, reported_shell),
ShellApprovalProvenance::remote_model_hint(),
&UnifiedExecShellMode::Direct,
/*allow_login_shell*/ false,
)
.map_err(anyhow::Error::msg)?;
assert_eq!(
resolved,
ResolvedCommand {
command: vec![
reported_shell.to_string(),
"-c".to_string(),
"pwd".to_string(),
],
shell_type: ShellType::Bash,
shell_approval_provenance: ShellApprovalProvenance::remote_model_hint(),
}
);
Ok(())
}
@@ -156,6 +256,7 @@ fn test_get_command_rejects_explicit_login_when_disallowed() -> anyhow::Result<(
let err = get_command(
&args,
Arc::new(default_user_shell()),
ShellApprovalProvenance::configured(),
&UnifiedExecShellMode::Direct,
/*allow_login_shell*/ false,
)
@@ -189,6 +290,7 @@ fn test_get_command_rejects_explicit_shell_in_zsh_fork_mode() -> anyhow::Result<
let err = get_command(
&args,
Arc::new(default_user_shell()),
ShellApprovalProvenance::configured(),
&shell_mode,
/*allow_login_shell*/ true,
)
@@ -201,6 +303,39 @@ fn test_get_command_rejects_explicit_shell_in_zsh_fork_mode() -> anyhow::Result<
Ok(())
}
#[test]
fn test_get_command_marks_zsh_fork_configured() -> anyhow::Result<()> {
let args: ExecCommandArgs = parse_arguments(r#"{"cmd": "echo hello"}"#)?;
let shell_zsh_path = AbsolutePathBuf::from_absolute_path(if cfg!(windows) {
r"C:\opt\codex\zsh"
} else {
"/opt/codex/zsh"
})?;
let shell_mode = UnifiedExecShellMode::ZshFork(ZshForkConfig {
shell_zsh_path,
main_execve_wrapper_exe: AbsolutePathBuf::from_absolute_path(if cfg!(windows) {
r"C:\opt\codex\codex-execve-wrapper"
} else {
"/opt/codex/codex-execve-wrapper"
})?,
});
let resolved = get_command(
&args,
Arc::new(default_user_shell()),
ShellApprovalProvenance::configured(),
&shell_mode,
/*allow_login_shell*/ false,
)
.map_err(anyhow::Error::msg)?;
assert_eq!(
resolved.shell_approval_provenance,
ShellApprovalProvenance::configured()
);
Ok(())
}
#[tokio::test]
async fn shell_mode_for_environment_uses_direct_mode_for_remote_environments() -> anyhow::Result<()>
{
@@ -7,6 +7,7 @@ the process manager to spawn PTYs once an ExecRequest is prepared.
use crate::command_canonicalization::canonicalize_command_for_approval;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::exec_policy::ShellApprovalProvenance;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::GuardianNetworkAccessTrigger;
use crate::guardian::review_approval_request;
@@ -43,11 +44,13 @@ use crate::unified_exec::UnifiedExecProcess;
use crate::unified_exec::UnifiedExecProcessManager;
use codex_network_proxy::ManagedNetworkSandboxContext;
use codex_network_proxy::NetworkProxy;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use codex_shell_command::powershell::prefix_powershell_script_with_utf8;
use codex_tools::UnifiedExecShellMode;
@@ -80,6 +83,7 @@ pub struct UnifiedExecRequest {
pub additional_permissions_preapproved: bool,
pub justification: Option<String>,
pub exec_approval_requirement: ExecApprovalRequirement,
pub shell_approval_provenance: ShellApprovalProvenance,
}
/// Cache key for approval decisions that can be reused across equivalent
@@ -134,6 +138,57 @@ fn build_unified_exec_sandbox_command(
})
}
fn maybe_wrap_environment_shell_command_with_snapshot(
command: &[String],
shell_approval_provenance: ShellApprovalProvenance,
session_shell: &crate::shell::Shell,
shell_snapshot: Option<&codex_utils_absolute_path::AbsolutePathBuf>,
explicit_env_overrides: &HashMap<String, String>,
env: &HashMap<String, String>,
runtime_path_prepends: &RuntimePathPrepends,
) -> Vec<String> {
if shell_approval_provenance.is_local_model_resolved() {
return command.to_vec();
}
maybe_wrap_shell_lc_with_snapshot(
command,
session_shell,
shell_snapshot,
explicit_env_overrides,
env,
runtime_path_prepends,
)
}
fn apply_post_policy_powershell_runtime_adjustments(
command: &[String],
shell_type: ShellType,
shell_approval_provenance: ShellApprovalProvenance,
sandbox: SandboxType,
windows_sandbox_level: WindowsSandboxLevel,
) -> Vec<String> {
// A locally model-selected executable is part of the command identity that
// was resolved and evaluated by policy. Do not mutate its approved argv at
// runtime; configured and remote-environment shells retain the established
// profile and UTF-8 adjustments.
if shell_approval_provenance.is_local_model_resolved() {
return command.to_vec();
}
let command = disable_powershell_profile_for_elevated_windows_sandbox(
command,
Some(&shell_type),
sandbox,
windows_sandbox_level,
);
if matches!(shell_type, ShellType::PowerShell) {
prefix_powershell_script_with_utf8(&command)
} else {
command
}
}
impl<'a> UnifiedExecRuntime<'a> {
/// Creates a runtime bound to the shared unified-exec process manager.
pub fn new(manager: &'a UnifiedExecProcessManager, shell_mode: UnifiedExecShellMode) -> Self {
@@ -384,8 +439,9 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
let command = if environment_is_remote {
base_command.to_vec()
} else {
maybe_wrap_shell_lc_with_snapshot(
maybe_wrap_environment_shell_command_with_snapshot(
base_command,
req.shell_approval_provenance,
shell,
shell_snapshot_location.as_ref(),
&explicit_env_overrides,
@@ -393,17 +449,13 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
&runtime_path_prepends,
)
};
let command = disable_powershell_profile_for_elevated_windows_sandbox(
let command = apply_post_policy_powershell_runtime_adjustments(
&command,
Some(&req.shell_type),
req.shell_type,
req.shell_approval_provenance,
attempt.sandbox,
attempt.windows_sandbox_level,
);
let command = if matches!(req.shell_type, ShellType::PowerShell) {
prefix_powershell_script_with_utf8(&command)
} else {
command
};
if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode {
let command = build_unified_exec_sandbox_command(
@@ -713,6 +765,99 @@ mod tests {
assert_eq!(decision, ReviewDecision::Approved);
}
#[cfg(unix)]
#[test]
fn local_model_resolved_shell_reaches_spawn_command_without_snapshot_wrapper() {
let temp_dir = tempdir().expect("create snapshot temp dir");
let snapshot_path = temp_dir.path().join("shell.snapshot");
std::fs::write(&snapshot_path, "export SNAPSHOT_MARKER=1\n").expect("write shell snapshot");
let snapshot = AbsolutePathBuf::try_from(snapshot_path).expect("absolute snapshot path");
let session_shell = crate::shell::Shell {
shell_type: ShellType::Sh,
shell_path: "/bin/sh".into(),
};
let command = vec![
"/workspace/fake/sh".to_string(),
"-lc".to_string(),
"echo exact".to_string(),
];
let explicit_env_overrides = HashMap::new();
let env = HashMap::new();
let runtime_path_prepends = RuntimePathPrepends::default();
let model_resolved = maybe_wrap_environment_shell_command_with_snapshot(
&command,
ShellApprovalProvenance::local_model_resolved(),
&session_shell,
Some(&snapshot),
&explicit_env_overrides,
&env,
&runtime_path_prepends,
);
let configured = maybe_wrap_environment_shell_command_with_snapshot(
&command,
ShellApprovalProvenance::configured(),
&session_shell,
Some(&snapshot),
&explicit_env_overrides,
&env,
&runtime_path_prepends,
);
assert_eq!(model_resolved, command);
assert_eq!(model_resolved[0], "/workspace/fake/sh");
assert_ne!(configured, model_resolved);
assert_eq!(configured[0], "/bin/sh");
}
#[test]
fn local_model_resolved_powershell_argv_is_not_mutated_after_policy() {
let command = vec![
"C:/workspace/fake/powershell.exe".to_string(),
"-Command".to_string(),
"Write-Output exact".to_string(),
];
let transformed = vec![
"C:/workspace/fake/powershell.exe".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
format!(
"{}Write-Output exact",
codex_shell_command::powershell::UTF8_OUTPUT_PREFIX
),
];
for (name, provenance, expected) in [
(
"local model-resolved",
ShellApprovalProvenance::local_model_resolved(),
&command,
),
(
"configured",
ShellApprovalProvenance::configured(),
&transformed,
),
(
"remote model hint",
ShellApprovalProvenance::remote_model_hint(),
&transformed,
),
] {
assert_eq!(
apply_post_policy_powershell_runtime_adjustments(
&command,
ShellType::PowerShell,
provenance,
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Elevated,
),
*expected,
"{name} behavior changed"
);
}
}
#[tokio::test]
async fn unified_exec_uses_the_trusted_sandbox_cwd() {
let cwd_dir = tempdir().expect("create process temp dir");
@@ -745,6 +890,7 @@ mod tests {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
},
shell_approval_provenance: ShellApprovalProvenance::configured(),
};
assert_eq!(
@@ -844,6 +990,7 @@ mod tests {
additional_permissions_preapproved: false,
justification: None,
exec_approval_requirement,
shell_approval_provenance: ShellApprovalProvenance::configured(),
}
}
+2
View File
@@ -36,6 +36,7 @@ use rand::Rng;
use rand::rng;
use tokio::sync::Mutex;
use crate::exec_policy::ShellApprovalProvenance;
use crate::sandboxing::SandboxPermissions;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
@@ -107,6 +108,7 @@ pub(crate) struct ExecCommandRequest {
pub additional_permissions_preapproved: bool,
pub justification: Option<String>,
pub prefix_rule: Option<Vec<String>>,
pub shell_approval_provenance: ShellApprovalProvenance,
}
#[derive(Debug)]
@@ -1134,18 +1134,23 @@ impl UnifiedExecProcessManager {
.session
.services
.exec_policy
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &request.command,
approval_policy: context.turn.approval_policy.value(),
permission_profile: context.turn.permission_profile(),
windows_sandbox_level: context.turn.windows_sandbox_level,
sandbox_permissions: if request.additional_permissions_preapproved {
crate::sandboxing::SandboxPermissions::UseDefault
} else {
request.sandbox_permissions
.create_exec_approval_requirement_for_command_with_provenance(
ExecApprovalRequest {
command: &request.command,
approval_policy: context.turn.approval_policy.value(),
permission_profile: context.turn.permission_profile(),
windows_sandbox_level: context.turn.windows_sandbox_level,
sandbox_permissions: if request.additional_permissions_preapproved {
crate::sandboxing::SandboxPermissions::UseDefault
} else {
request.sandbox_permissions
},
prefix_rule: request.prefix_rule.clone(),
},
prefix_rule: request.prefix_rule.clone(),
})
request.shell_approval_provenance,
/*permission_expansion_was_requested*/
request.sandbox_permissions.requests_sandbox_override(),
)
.await;
let req = UnifiedExecToolRequest {
command: request.command.clone(),
@@ -1172,6 +1177,7 @@ impl UnifiedExecProcessManager {
additional_permissions_preapproved: request.additional_permissions_preapproved,
justification: request.justification.clone(),
exec_approval_requirement,
shell_approval_provenance: request.shell_approval_provenance,
};
let tool_ctx = ToolCtx {
session: context.session.clone(),
@@ -292,6 +292,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() {
additional_permissions_preapproved: false,
justification: None,
prefix_rule: None,
shell_approval_provenance: crate::exec_policy::ShellApprovalProvenance::configured(),
};
let transcript = Arc::new(tokio::sync::Mutex::new(HeadTailBuffer::default()));
+205 -20
View File
@@ -1045,7 +1045,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
outcome: Outcome::ExecApprovalWithAmendment {
decision: ReviewDecision::Denied,
expected_reason: None,
expected_execpolicy_amendment: Some(&["echo", "known-safe-escalation"]),
expected_execpolicy_amendment: None,
},
expectation: Expectation::CommandFailure {
output_contains: "rejected by user",
@@ -1792,6 +1792,164 @@ fn scenarios() -> Vec<ScenarioSpec> {
]
}
#[cfg(unix)]
#[test_case("./ls", "ls" ; "direct_relative_executable")]
#[test_case("./zsh -c ls", "zsh" ; "configured_shell_with_relative_nested_shell")]
#[tokio::test(flavor = "current_thread")]
async fn fake_shell_sandbox_override_prompts_before_execution(
authored_command: &str,
executable_name: &str,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let approval_policy = AskForApproval::OnRequest;
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let sandbox_policy_for_config = sandbox_policy.clone();
let configured_shell =
codex_core::shell::get_shell_by_model_provided_path(&PathBuf::from("/bin/sh"));
let expected_command =
configured_shell.derive_exec_args(authored_command, /*use_login_shell*/ false);
let mut builder = test_codex()
.with_user_shell(configured_shell)
.with_config(move |config| {
config.permissions.allow_login_shell = false;
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config
.set_legacy_sandbox_policy(sandbox_policy_for_config)
.expect("set workspace-write sandbox policy");
config.approvals_reviewer = ApprovalsReviewer::User;
});
let test = builder.build(&server).await?;
let fake_executable = test.cwd.path().join(executable_name);
let sentinel = test.cwd.path().join(format!("{executable_name}.started"));
fs::write(
&fake_executable,
"#!/bin/sh\nprintf '%s\\n' started > \"$0.started\"\n",
)?;
let mut permissions = fs::metadata(&fake_executable)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&fake_executable, permissions)?;
assert!(!sentinel.exists(), "sentinel must start absent");
let call_id = format!("fake-shell-sandbox-override-{executable_name}");
let event = shell_event(
&call_id,
authored_command,
/*timeout_ms*/ 1_000,
SandboxPermissions::RequireEscalated,
)?;
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-fake-shell-1"),
event,
ev_completed("resp-fake-shell-1"),
]),
)
.await;
let results_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-fake-shell-1", "done"),
ev_completed("resp-fake-shell-2"),
]),
)
.await;
submit_turn(
&test,
"run the fake shell with an explicit sandbox override",
approval_policy,
sandbox_policy,
)
.await?;
// Shell-command begin/end events describe the logical tool call, not the
// child-process lifetime: the begin event intentionally precedes policy
// evaluation. The executable sentinel is the authority boundary here.
let approval = loop {
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_)
| EventMsg::ExecCommandBegin(_)
| EventMsg::ExecCommandEnd(_)
| EventMsg::TurnComplete(_)
)
})
.await;
assert!(
!sentinel.exists(),
"fake executable must not run before approval"
);
match event {
EventMsg::ExecApprovalRequest(approval) => break approval,
EventMsg::ExecCommandBegin(_) => continue,
EventMsg::ExecCommandEnd(end) => {
panic!("logical shell call ended before approval: {end:?}")
}
EventMsg::TurnComplete(_) => panic!("expected approval before turn completion"),
_ => unreachable!(),
}
};
assert_eq!(approval.call_id, call_id);
assert_eq!(approval.command, expected_command);
assert_eq!(approval.proposed_execpolicy_amendment, None);
assert!(
!sentinel.exists(),
"fake executable must remain stopped while approval is pending"
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Denied,
})
.await?;
loop {
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ExecCommandEnd(_) | EventMsg::TurnComplete(_)
)
})
.await;
assert!(
!sentinel.exists(),
"denying approval must not run the fake executable"
);
if matches!(event, EventMsg::TurnComplete(_)) {
break;
}
}
assert!(
!sentinel.exists(),
"denying approval must not run the fake executable"
);
let output = parse_result(
&results_mock
.single_request()
.function_call_output(call_id.as_str()),
);
assert!(
output.stdout.contains("rejected by user"),
"tool result should report the normal user rejection: {}",
output.stdout
);
Ok(())
}
#[test_case(ScenarioGroup::DangerFullAccess ; "danger_full_access")]
#[test_case(ScenarioGroup::ReadOnly ; "read_only")]
#[test_case(ScenarioGroup::WorkspaceWrite ; "workspace_write")]
@@ -3211,7 +3369,7 @@ exec {remote_bash_exec} "$@"
#[tokio::test(flavor = "current_thread")]
#[cfg(unix)]
async fn invalid_requested_prefix_rule_falls_back_for_compound_command() -> Result<()> {
async fn permission_expansion_does_not_offer_fallback_rule_for_compound_command() -> Result<()> {
let server = start_mock_server().await;
let approval_policy = AskForApproval::OnRequest;
let sandbox_policy = SandboxPolicy::new_read_only_policy();
@@ -3230,7 +3388,7 @@ async fn invalid_requested_prefix_rule_falls_back_for_compound_command() -> Resu
let event = shell_event_with_prefix_rule(
call_id,
command,
/*timeout_ms*/ 1_000,
/*timeout_ms*/ 10_000,
SandboxPermissions::RequireEscalated,
Some(vec!["touch".to_string()]),
)?;
@@ -3244,6 +3402,14 @@ async fn invalid_requested_prefix_rule_falls_back_for_compound_command() -> Resu
]),
)
.await;
let _results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-invalid-prefix-no-amendment", "done"),
ev_completed("resp-invalid-prefix-no-amendment-results"),
]),
)
.await;
submit_turn(
&test,
@@ -3254,17 +3420,22 @@ async fn invalid_requested_prefix_rule_falls_back_for_compound_command() -> Resu
.await?;
let approval = expect_exec_approval(&test, command).await;
let amendment = approval
.proposed_execpolicy_amendment
.expect("should have a proposed execpolicy amendment");
assert!(amendment.command.contains(&command.to_string()));
assert_eq!(approval.proposed_execpolicy_amendment, None);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Denied,
})
.await?;
wait_for_completion(&test).await;
Ok(())
}
#[tokio::test(flavor = "current_thread")]
#[cfg(unix)]
async fn approving_fallback_rule_for_compound_command_works() -> Result<()> {
async fn approving_permission_expansion_does_not_persist_compound_command() -> Result<()> {
let server = start_mock_server().await;
let approval_policy = AskForApproval::OnRequest;
let sandbox_policy = SandboxPolicy::new_read_only_policy();
@@ -3283,7 +3454,7 @@ async fn approving_fallback_rule_for_compound_command_works() -> Result<()> {
let event = shell_event_with_prefix_rule(
call_id,
command,
/*timeout_ms*/ 1_000,
/*timeout_ms*/ 10_000,
SandboxPermissions::RequireEscalated,
Some(vec!["touch".to_string()]),
)?;
@@ -3297,6 +3468,14 @@ async fn approving_fallback_rule_for_compound_command_works() -> Result<()> {
]),
)
.await;
let first_results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-invalid-prefix-first", "done"),
ev_completed("resp-invalid-prefix-first-results"),
]),
)
.await;
submit_turn(
&test,
@@ -3308,29 +3487,27 @@ async fn approving_fallback_rule_for_compound_command_works() -> Result<()> {
let approval = expect_exec_approval(&test, command).await;
let approval_id = approval.effective_approval_id();
let amendment = approval
.proposed_execpolicy_amendment
.expect("should have a proposed execpolicy amendment");
assert!(amendment.command.contains(&command.to_string()));
assert_eq!(approval.proposed_execpolicy_amendment, None);
test.codex
.submit(Op::ExecApproval {
id: approval_id,
turn_id: None,
decision: ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: amendment.clone(),
},
decision: ReviewDecision::Approved,
})
.await?;
wait_for_completion(&test).await;
let first_output = parse_result(&first_results.single_request().function_call_output(call_id));
assert_eq!(first_output.exit_code.unwrap_or(0), 0);
let call_id = "invalid-prefix-rule-again";
let command =
"touch /tmp/codex-fallback-rule-test.txt && echo hello > /tmp/codex-fallback-rule-test.txt";
let event = shell_event_with_prefix_rule(
call_id,
command,
/*timeout_ms*/ 1_000,
/*timeout_ms*/ 10_000,
SandboxPermissions::RequireEscalated,
Some(vec!["touch".to_string()]),
)?;
@@ -3361,16 +3538,24 @@ async fn approving_fallback_rule_for_compound_command_works() -> Result<()> {
)
.await?;
wait_for_completion_without_approval(&test).await;
let approval = expect_exec_approval(&test, command).await;
assert_eq!(approval.proposed_execpolicy_amendment, None);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Denied,
})
.await?;
wait_for_completion(&test).await;
let second_output = parse_result(
&second_results
.single_request()
.function_call_output(call_id),
);
assert_eq!(second_output.exit_code.unwrap_or(0), 0);
assert!(
second_output.stdout.is_empty(),
second_output.stdout.contains("rejected by user"),
"unexpected stdout: {}",
second_output.stdout
);
+163 -4
View File
@@ -8,12 +8,12 @@ use codex_protocol::config_types::Settings;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
#[cfg(windows)]
#[cfg(any(unix, windows))]
use codex_protocol::protocol::ExecApprovalPurpose;
use codex_protocol::protocol::Op;
#[cfg(windows)]
#[cfg(any(unix, windows))]
use codex_protocol::protocol::ReviewDecision;
#[cfg(windows)]
#[cfg(any(unix, windows))]
use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_assistant_message;
@@ -30,6 +30,8 @@ use core_test_support::wait_for_event;
use serde_json::Value;
use serde_json::json;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(windows)]
use std::path::PathBuf;
@@ -103,7 +105,6 @@ fn installed_windows_powershell() -> PathBuf {
.into_path_buf()
}
#[cfg(windows)]
fn enable_unified_exec(config: &mut codex_core::config::Config) {
config
.features
@@ -111,6 +112,164 @@ fn enable_unified_exec(config: &mut codex_core::config::Config) {
.expect("test config should allow feature update");
}
#[cfg(unix)]
#[tokio::test]
async fn unified_exec_model_selected_posix_shell_requires_one_shot_approval_before_execution()
-> Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex().with_config(enable_unified_exec);
let test = builder.build(&server).await?;
let model_shell_dir = test.config.cwd.join("model-shell");
fs::create_dir_all(&model_shell_dir)?;
let model_shell = model_shell_dir.join("sh");
let sentinel = test.config.cwd.join("model-shell-started.txt");
let sentinel_arg = sentinel
.to_str()
.expect("the sentinel path must be valid UTF-8")
.replace('\'', "'\"'\"'");
fs::write(
&model_shell,
format!("#!/bin/sh\nprintf started > '{sentinel_arg}'\nexec /bin/sh \"$@\"\n"),
)?;
fs::set_permissions(&model_shell, fs::Permissions::from_mode(0o755))?;
let requested_shell = "./model-shell/sh";
let resolved_model_shell = model_shell
.to_str()
.expect("the model-selected shell path must be valid UTF-8")
.to_string();
let expected_command = vec![resolved_model_shell, "-c".to_string(), "ls".to_string()];
let call_id = "unified-exec-model-selected-posix-one-shot";
let args = json!({
"shell": requested_shell,
"cmd": "ls",
"login": false,
"yield_time_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-model-selected-posix-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-model-selected-posix-1"),
]),
)
.await;
let results_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-model-selected-posix-1", "done"),
ev_completed("resp-model-selected-posix-2"),
]),
)
.await;
submit_user_turn(
&test,
"run a read-only command with a model-selected POSIX shell",
AskForApproval::UnlessTrusted,
PermissionProfile::Disabled,
/*collaboration_mode*/ None,
)
.await?;
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ExecApprovalRequest(_)
| EventMsg::ExecCommandBegin(_)
| EventMsg::ExecCommandEnd(_)
| EventMsg::TurnAborted(_)
| EventMsg::TurnComplete(_)
)
})
.await;
assert!(
!sentinel.exists(),
"the model-selected POSIX shell must not start before approval"
);
let approval = match event {
EventMsg::ExecApprovalRequest(approval) => approval,
EventMsg::ExecCommandBegin(begin) => {
panic!("model-selected POSIX shell began before approval: {begin:?}")
}
EventMsg::ExecCommandEnd(end) => {
panic!("model-selected POSIX shell completed before approval: {end:?}")
}
EventMsg::TurnAborted(aborted) => {
panic!("turn aborted before one-shot approval: {aborted:?}")
}
EventMsg::TurnComplete(_) => panic!("expected one-shot approval before completion"),
_ => unreachable!(),
};
assert_eq!(approval.call_id, call_id);
assert_eq!(approval.command, expected_command);
assert_eq!(approval.cwd, test.config.cwd);
let approval_id = approval
.approval_id
.clone()
.expect("one-shot approval must carry a callback ID");
assert_ne!(approval_id, call_id);
assert_eq!(
approval.approval_purpose,
Some(ExecApprovalPurpose::Initial)
);
assert_eq!(
approval.effective_approval_purpose(),
ExecApprovalPurpose::Initial
);
assert_eq!(
approval.effective_available_decisions(),
vec![ReviewDecision::Approved, ReviewDecision::Abort]
);
assert_eq!(approval.proposed_execpolicy_amendment, None);
assert!(!sentinel.exists());
let approval_turn_id = approval.turn_id.clone();
test.codex
.submit(Op::ExecApproval {
id: approval_id,
turn_id: Some(approval_turn_id.clone()),
decision: ReviewDecision::Abort,
})
.await?;
let aborted = match wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ExecCommandBegin(_)
| EventMsg::ExecCommandEnd(_)
| EventMsg::TurnAborted(_)
| EventMsg::TurnComplete(_)
)
})
.await
{
EventMsg::TurnAborted(aborted) => aborted,
EventMsg::ExecCommandBegin(begin) => {
panic!("model-selected POSIX shell began after abort: {begin:?}")
}
EventMsg::ExecCommandEnd(end) => {
panic!("model-selected POSIX shell completed after abort: {end:?}")
}
EventMsg::TurnComplete(_) => panic!("aborted approval unexpectedly completed the turn"),
_ => unreachable!(),
};
assert_eq!(aborted.turn_id.as_deref(), Some(approval_turn_id.as_str()));
assert_eq!(aborted.reason, TurnAbortReason::Interrupted);
assert!(
!sentinel.exists(),
"aborting one-shot approval must never start the model-selected POSIX shell"
);
assert!(
results_mock.requests().is_empty(),
"aborting one-shot approval must not continue the turn"
);
Ok(())
}
#[cfg(windows)]
#[tokio::test]
async fn unified_exec_workspace_powershell_path_requires_one_shot_approval_before_execution()
+13 -13
View File
@@ -248,32 +248,27 @@ async fn remote_test_env_exposes_target_shell_to_model() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> {
async fn compatible_remote_path_hint_uses_environment_shell_in_remote_cwd() -> Result<()> {
const CALL_ID: &str = "remote-explicit-shell";
skip_if_no_remote_env!(Ok(()));
let (shell, command) = match test_target_os() {
TestTargetOs::Linux => (
"bash",
r#"case "$PWD" in /tmp/codex-core-test-cwd-*) ;; *) echo "unexpected cwd: $PWD" >&2; exit 1 ;; esac"#,
),
TestTargetOs::Windows => (
"powershell",
r#"$cwd = (Get-Location).Path; if ($cwd -notlike 'C:\codex-core-test-cwd-*') { Write-Error "unexpected cwd: $cwd"; exit 1 }"#,
),
let (shell, expected_cwd_prefix) = match test_target_os() {
TestTargetOs::Linux => ("/attacker/bash", "/tmp/codex-core-test-cwd-"),
TestTargetOs::Windows => (r"C:\attacker\PowerShell.ExE", r"C:\codex-core-test-cwd-"),
TestTargetOs::MacOs => unreachable!("remote test targets do not run macOS"),
};
let server = start_mock_server().await;
let arguments = serde_json::to_string(&json!({
"cmd": command,
"cmd": "pwd",
"shell": shell,
"login": false,
"yield_time_ms": 10_000,
}))?;
let mut builder = test_codex().with_config(|config| {
config.use_experimental_unified_exec_tool = true;
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
config
.features
.enable(Feature::UnifiedExec)
@@ -312,9 +307,14 @@ async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> {
.function_call_output_content_and_success(CALL_ID)
.context("remote shell tool result should be present")?;
assert_ne!(success, Some(false));
let output = output.context("remote shell command should return output")?;
assert!(
output.is_some_and(|output| output.contains("Process exited with code 0")),
"remote shell command should exit successfully",
output.contains("Process exited with code 0"),
"remote shell command should exit successfully: {output}",
);
assert!(
output.contains(expected_cwd_prefix),
"remote shell command should run in the remote cwd: {output}",
);
Ok(())
+156
View File
@@ -2024,6 +2024,162 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_shell_resolution_failure_precedes_process_allocation_and_events() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
});
let test = builder.build_with_auto_env(&server).await?;
let invalid_call_id = "uexec-missing-model-shell";
let invalid_args = serde_json::json!({
"shell": "./missing/sh",
"cmd": "true",
"login": false,
});
let start_call_id = "uexec-after-missing-model-shell";
let start_args = serde_json::json!({
"cmd": "tail -f /dev/null",
"yield_time_ms": 10,
"tty": false,
});
let interrupt_call_id = "uexec-after-missing-model-shell-interrupt";
let interrupt_args = serde_json::json!({
"chars": "\u{3}",
"session_id": 1000,
"yield_time_ms": 1000,
});
let responses = vec![
sse(vec![
ev_response_created("resp-missing-shell-1"),
ev_function_call(
invalid_call_id,
"exec_command",
&serde_json::to_string(&invalid_args)?,
),
ev_completed("resp-missing-shell-1"),
]),
sse(vec![
ev_response_created("resp-missing-shell-2"),
ev_function_call(
start_call_id,
"exec_command",
&serde_json::to_string(&start_args)?,
),
ev_completed("resp-missing-shell-2"),
]),
sse(vec![
ev_response_created("resp-missing-shell-3"),
ev_function_call(
interrupt_call_id,
"write_stdin",
&serde_json::to_string(&interrupt_args)?,
),
ev_completed("resp-missing-shell-3"),
]),
sse(vec![
ev_response_created("resp-missing-shell-4"),
ev_assistant_message("msg-missing-shell", "done"),
ev_completed("resp-missing-shell-4"),
]),
];
let request_log = mount_sse_sequence(&server, responses).await;
submit_unified_exec_turn(
&test,
"resolve a model-selected shell before allocating a process",
PermissionProfile::Disabled,
)
.await?;
let mut valid_begin = None;
loop {
let event = wait_for_event(&test.codex, |_| true).await;
match event {
EventMsg::ExecApprovalRequest(approval) if approval.call_id == invalid_call_id => {
panic!("shell resolution failure must precede approval: {approval:?}")
}
EventMsg::ExecCommandBegin(begin) if begin.call_id == invalid_call_id => {
panic!("shell resolution failure must precede begin events: {begin:?}")
}
EventMsg::ExecCommandEnd(end) if end.call_id == invalid_call_id => {
panic!("shell resolution failure must precede end events: {end:?}")
}
EventMsg::ExecCommandBegin(begin) if begin.call_id == start_call_id => {
assert!(
valid_begin.is_none(),
"expected one begin event for the valid follow-up command"
);
valid_begin = Some(begin);
}
EventMsg::TurnComplete(_) => break,
_ => {}
}
}
let valid_begin = valid_begin.expect("the valid follow-up command should begin normally");
assert_eq!(
valid_begin.process_id.as_deref(),
Some("1000"),
"failed shell resolution must not consume the first process ID"
);
let invalid_output = request_log
.function_call_output_text(invalid_call_id)
.expect("missing typed shell-resolution failure output");
assert!(
invalid_output.contains("model-provided shell")
&& invalid_output.contains("does not exist"),
"unexpected shell-resolution failure: {invalid_output:?}"
);
assert!(
!invalid_output.contains("Process running with session ID"),
"failed shell resolution must not claim a running process: {invalid_output:?}"
);
let start_output = parse_unified_exec_output(
&request_log
.function_call_output_text(start_call_id)
.expect("missing valid follow-up exec_command output"),
)?;
assert_eq!(
start_output.process_id.as_deref(),
Some("1000"),
"the first successfully allocated process should retain deterministic ID 1000"
);
assert!(
start_output.exit_code.is_none(),
"the valid process should still be running before write_stdin interrupts it"
);
let interrupt_output = parse_unified_exec_output(
&request_log
.function_call_output_text(interrupt_call_id)
.expect("missing write_stdin interrupt output"),
)?;
assert!(
interrupt_output.process_id.is_none(),
"write_stdin should clear the interrupted process from the session map"
);
assert_eq!(
interrupt_output.exit_code,
Some(130),
"write_stdin should preserve the normal Unix SIGINT exit status"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn write_stdin_ctrl_c_interrupts_non_tty_session() -> Result<()> {
// TODO(anp): Add a target-Windows test for explicit interrupt handling.
@@ -46,6 +46,12 @@ enum PushedExecScenario {
ReplayGap,
}
#[derive(Debug)]
struct ExecServerObservation {
process_read_requests: usize,
process_start_argv: Vec<String>,
}
async fn read_exec_server_json(websocket: &mut WebSocketStream<TcpStream>) -> Value {
loop {
match timeout(Duration::from_secs(5), websocket.next())
@@ -109,7 +115,7 @@ async fn send_environment_info(websocket: &mut WebSocketStream<TcpStream>) {
async fn serve_exec_with_pushed_events(
listener: TcpListener,
scenario: PushedExecScenario,
) -> usize {
) -> ExecServerObservation {
let mut websocket = accept_initialized_exec_server(listener).await;
send_environment_info(&mut websocket).await;
@@ -144,6 +150,16 @@ async fn serve_exec_with_pushed_events(
.as_str()
.expect("process/start should include processId")
.to_string();
let process_start_argv = process_start["params"]["argv"]
.as_array()
.expect("process/start should include argv")
.iter()
.map(|arg| {
arg.as_str()
.expect("process/start argv entries should be strings")
.to_string()
})
.collect();
let replay_output = |seq| -> &'static [u8] {
match seq {
@@ -350,7 +366,10 @@ async fn serve_exec_with_pushed_events(
}),
)
.await;
return process_read_requests;
return ExecServerObservation {
process_read_requests,
process_start_argv,
};
}
method => panic!("unexpected exec-server request: {method:?}"),
}
@@ -448,7 +467,7 @@ async fn exec_command_consumes_pushed_remote_process_events(
_ => {}
}
}
let process_read_requests = timeout(Duration::from_secs(5), exec_server)
let observation = timeout(Duration::from_secs(5), exec_server)
.await
.context("fake exec-server should observe process cleanup")??;
let request = response_mock
@@ -464,26 +483,161 @@ async fn exec_command_consumes_pushed_remote_process_events(
assert!(saw_exec_command_begin);
assert!(output.contains("Process exited with code 0"));
assert!(output.contains(COMPLETE_OUTPUT));
assert_eq!(process_read_requests, 0, "unexpected compatibility read");
assert_eq!(
observation.process_read_requests, 0,
"unexpected compatibility read"
);
}
PushedExecScenario::DirectDenied => {
assert!(!saw_exec_command_begin);
assert!(output.contains("Process exited with code 1"));
assert_eq!(process_read_requests, 0, "unexpected compatibility read");
assert_eq!(
observation.process_read_requests, 0,
"unexpected compatibility read"
);
}
PushedExecScenario::LegacyExit => {
assert!(!saw_exec_command_begin);
assert!(output.contains("Process exited with code 1"));
assert_eq!(process_read_requests, 1, "expected compatibility read");
assert_eq!(
observation.process_read_requests, 1,
"expected compatibility read"
);
}
PushedExecScenario::ReplayGap => {
assert_ne!(success, Some(false));
assert!(saw_exec_command_begin);
assert_eq!(output.matches(RECOVERED_OUTPUT).count(), 1);
assert_eq!(output.matches(RETAINED_OUTPUT).count(), 1);
assert_eq!(process_read_requests, 1, "expected replay recovery read");
assert_eq!(
observation.process_read_requests, 1,
"expected replay recovery read"
);
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_explicit_shell_hint_uses_environment_reported_executable() -> Result<()> {
const MODEL_SHELL_HINT: &str = r"C:\attacker\ZSH.ExE";
const COMMAND: &str = "echo remote-hint-ok";
let listener = TcpListener::bind("127.0.0.1:0").await?;
let server = start_mock_server().await;
let response_mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
CALL_ID,
"exec_command",
&json!({
"shell": MODEL_SHELL_HINT,
"cmd": COMMAND,
"login": false,
"yield_time_ms": 1_000,
})
.to_string(),
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-2", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
let exec_server_url = format!("ws://{}", listener.local_addr()?);
let exec_server = tokio::spawn(serve_exec_with_pushed_events(
listener,
PushedExecScenario::Complete,
));
let mut builder = test_codex()
.with_exec_server_url(exec_server_url)
.with_config(|config| {
config.project_doc_max_bytes = 0;
config.use_experimental_unified_exec_tool = true;
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
});
let test = timeout(Duration::from_secs(5), builder.build(&server))
.await
.context("thread startup should connect to the fake exec-server")??;
let (sandbox_policy, permission_profile) =
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "run a remote command with an explicit shell hint".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
approval_policy: Some(AskForApproval::Never),
sandbox_policy: Some(sandbox_policy),
permission_profile,
collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
mode: codex_protocol::config_types::ModeKind::Default,
settings: codex_protocol::config_types::Settings {
model: test.session_configured.model.clone(),
reasoning_effort: None,
developer_instructions: None,
},
}),
..Default::default()
},
})
.await?;
loop {
let event = timeout(Duration::from_secs(5), test.codex.next_event())
.await
.context("turn should complete")??
.msg;
if matches!(event, EventMsg::TurnComplete(_)) {
break;
}
}
let observation = timeout(Duration::from_secs(5), exec_server)
.await
.context("fake exec-server should observe process cleanup")??;
assert_eq!(
observation.process_start_argv,
["/bin/zsh", "-c", COMMAND],
"the model hint must not replace the environment-reported shell"
);
assert!(
observation
.process_start_argv
.iter()
.all(|arg| !arg.to_ascii_lowercase().contains("attacker")),
"process/start must not contain the model-supplied path"
);
assert_eq!(
observation.process_read_requests, 0,
"unexpected compatibility read"
);
let request = response_mock
.last_request()
.context("model should receive the exec_command output")?;
let (output, success) = request
.function_call_output_content_and_success(CALL_ID)
.context("exec_command output should be model visible")?;
let output = output.context("exec_command output should contain text")?;
assert_ne!(success, Some(false));
assert!(output.contains("Process exited with code 0"));
assert!(output.contains(COMPLETE_OUTPUT));
Ok(())
}
+37
View File
@@ -1120,6 +1120,43 @@ mod tests {
assert_eq!(child_env(&params), expected);
}
#[cfg(target_os = "windows")]
#[test]
fn child_env_policy_rebuild_coalesces_windows_shell_search_variables() {
let mut params = test_exec_params(HashMap::new());
params.env_policy = Some(ExecEnvPolicy {
inherit: ShellEnvironmentPolicyInherit::None,
ignore_default_excludes: true,
exclude: Vec::new(),
r#set: HashMap::from([
("Path".to_string(), r"C:\configured-alias".to_string()),
("PATH".to_string(), r"C:\configured-canonical".to_string()),
("PathExt".to_string(), ".EXE;.CMD".to_string()),
]),
include_only: Vec::new(),
});
let env = child_env(&params);
assert_eq!(
env.get("PATH").map(String::as_str),
Some(r"C:\configured-canonical")
);
assert_eq!(env.get("PATHEXT").map(String::as_str), Some(".EXE;.CMD"));
assert_eq!(
env.keys()
.filter(|key| key.eq_ignore_ascii_case("PATH"))
.count(),
1
);
assert_eq!(
env.keys()
.filter(|key| key.eq_ignore_ascii_case("PATHEXT"))
.count(),
1
);
}
#[tokio::test]
async fn exit_before_shutdown_records_success() {
let (backend, metrics, exporter) = telemetry_backend();
+116
View File
@@ -40,9 +40,50 @@ where
env_map.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string());
}
}
#[cfg(target_os = "windows")]
normalize_windows_shell_search_environment(&mut env_map, policy);
env_map
}
/// Canonicalize the case-insensitive variables that control Windows
/// executable lookup before the environment is used for resolution or launch.
///
/// The map is case-sensitive even on Windows, so inherited and configured
/// spellings can otherwise coexist. A configured entry wins over an inherited
/// entry, and the canonical spelling wins if the policy itself contains more
/// than one case variant.
#[cfg(any(target_os = "windows", test))]
fn normalize_windows_shell_search_environment(
env: &mut HashMap<String, String>,
policy: &ShellEnvironmentPolicy,
) {
for canonical_key in ["PATH", "PATHEXT"] {
let configured_value = windows_environment_value(&policy.r#set, canonical_key)
.filter(|(key, value)| env.get(*key) == Some(*value))
.map(|(_, value)| value.clone());
let value = configured_value.or_else(|| {
windows_environment_value(env, canonical_key).map(|(_, value)| value.clone())
});
env.retain(|key, _| !key.eq_ignore_ascii_case(canonical_key));
if let Some(value) = value {
env.insert(canonical_key.to_string(), value);
}
}
}
#[cfg(any(target_os = "windows", test))]
fn windows_environment_value<'a>(
env: &'a HashMap<String, String>,
canonical_key: &str,
) -> Option<(&'a String, &'a String)> {
env.get_key_value(canonical_key).or_else(|| {
env.iter()
.filter(|(key, _)| key.eq_ignore_ascii_case(canonical_key))
.min_by(|(left, _), (right, _)| left.cmp(right))
})
}
pub fn populate_env<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
@@ -115,6 +156,39 @@ const UNIX_CORE_ENV_VARS: &[&str] = &[
"USER",
];
#[cfg(test)]
mod normalization_tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn windows_shell_search_normalization_prefers_configured_canonical_keys() {
let mut env = HashMap::from([
("Path".to_string(), r"C:\inherited".to_string()),
("path".to_string(), r"C:\configured-alias".to_string()),
("PATH".to_string(), r"C:\configured-canonical".to_string()),
("PathExt".to_string(), ".COM;.EXE;.CMD".to_string()),
]);
let policy = ShellEnvironmentPolicy {
r#set: HashMap::from([
("path".to_string(), r"C:\configured-alias".to_string()),
("PATH".to_string(), r"C:\configured-canonical".to_string()),
]),
..Default::default()
};
normalize_windows_shell_search_environment(&mut env, &policy);
assert_eq!(
env,
HashMap::from([
("PATH".to_string(), r"C:\configured-canonical".to_string()),
("PATHEXT".to_string(), ".COM;.EXE;.CMD".to_string()),
])
);
}
}
#[cfg(target_os = "windows")]
pub const WINDOWS_CORE_ENV_VARS: &[&str] = &[
// Core path resolution
@@ -209,6 +283,48 @@ mod windows_tests {
assert_eq!(result, expected);
}
#[test]
fn create_env_policy_rebuild_coalesces_windows_path_and_pathext() {
let vars = make_vars(&[
("Path", r"C:\inherited-bin"),
("PATH", r"C:\other-inherited-bin"),
("PathExt", ".COM;.EXE;.BAT;.CMD"),
]);
let mut policy = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::All,
ignore_default_excludes: true,
..Default::default()
};
policy
.r#set
.insert("PATH".to_string(), r"C:\configured-bin".to_string());
policy
.r#set
.insert("pathext".to_string(), ".EXE".to_string());
let result = create_env_from_vars(vars, &policy, /*thread_id*/ None);
assert_eq!(
result.get("PATH").map(String::as_str),
Some(r"C:\configured-bin")
);
assert_eq!(result.get("PATHEXT").map(String::as_str), Some(".EXE"));
assert_eq!(
result
.keys()
.filter(|key| key.eq_ignore_ascii_case("PATH"))
.count(),
1
);
assert_eq!(
result
.keys()
.filter(|key| key.eq_ignore_ascii_case("PATHEXT"))
.count(),
1
);
}
}
#[cfg(all(test, not(target_os = "windows")))]
+754
View File
@@ -1,3 +1,5 @@
use std::ffi::OsStr;
use std::path::Path;
use std::path::PathBuf;
use serde::Deserialize;
@@ -30,6 +32,81 @@ pub struct DetectedShell {
pub shell_path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelShellResolveError {
Unsupported(PathBuf),
MissingBareName(PathBuf),
MissingPath(PathBuf),
NotAFile(PathBuf),
NotExecutable(PathBuf),
UnsupportedWindowsLaunch(PathBuf),
UnsupportedWindowsPathNamespace(PathBuf),
UnresolvedWindowsRelativePath(PathBuf),
NonUtf8ResolvedPath(PathBuf),
RelativeWorkingDirectory(PathBuf),
}
impl std::fmt::Display for ModelShellResolveError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unsupported(path) => {
write!(
formatter,
"unsupported model-provided shell `{}`",
path.display()
)
}
Self::MissingBareName(path) => write!(
formatter,
"model-provided shell `{}` was not found on the invocation PATH",
path.display()
),
Self::MissingPath(path) => write!(
formatter,
"model-provided shell `{}` does not exist",
path.display()
),
Self::NotAFile(path) => write!(
formatter,
"model-provided shell `{}` is not a regular file",
path.display()
),
Self::NotExecutable(path) => write!(
formatter,
"model-provided shell `{}` is not executable",
path.display()
),
Self::UnsupportedWindowsLaunch(path) => write!(
formatter,
"model-provided Windows shell `{}` must resolve to an .exe executable",
path.display()
),
Self::UnsupportedWindowsPathNamespace(path) => write!(
formatter,
"Windows shell resolution refuses remote, device, or verbatim path namespace `{}`",
path.display()
),
Self::UnresolvedWindowsRelativePath(path) => write!(
formatter,
"model-provided Windows shell path `{}` is drive-relative and cannot be resolved against the selected working directory",
path.display()
),
Self::NonUtf8ResolvedPath(path) => write!(
formatter,
"resolved model-provided shell path `{}` is not representable in command argv",
path.display()
),
Self::RelativeWorkingDirectory(path) => write!(
formatter,
"cannot resolve a model-provided shell against relative working directory `{}`",
path.display()
),
}
}
}
impl std::error::Error for ModelShellResolveError {}
impl DetectedShell {
pub fn name(&self) -> &'static str {
self.shell_type.name()
@@ -38,6 +115,14 @@ impl DetectedShell {
pub fn detect_shell_type(shell_path: impl AsRef<std::path::Path>) -> Option<ShellType> {
let shell_path = shell_path.as_ref();
#[cfg(windows)]
{
return shell_path
.as_os_str()
.to_str()
.and_then(detect_shell_type_from_hint);
}
#[cfg(not(windows))]
match shell_path.as_os_str().to_str() {
Some("zsh") => Some(ShellType::Zsh),
Some("sh") => Some(ShellType::Sh),
@@ -58,6 +143,36 @@ pub fn detect_shell_type(shell_path: impl AsRef<std::path::Path>) -> Option<Shel
}
}
/// Detects a requested remote shell type without interpreting the spelling
/// using the controller's native path rules.
///
/// The returned type is only a compatibility hint. Callers must discard the
/// spelling and launch the environment-reported shell executable.
pub fn detect_shell_type_from_hint(shell_hint: &str) -> Option<ShellType> {
let file_name = shell_hint.rsplit(['/', '\\']).next()?;
if file_name.is_empty() {
return None;
}
let stem = file_name
.rsplit_once('.')
.filter(|(_, extension)| extension.eq_ignore_ascii_case("exe"))
.map_or(file_name, |(stem, _)| stem);
if stem.eq_ignore_ascii_case("zsh") {
Some(ShellType::Zsh)
} else if stem.eq_ignore_ascii_case("sh") {
Some(ShellType::Sh)
} else if stem.eq_ignore_ascii_case("cmd") {
Some(ShellType::Cmd)
} else if stem.eq_ignore_ascii_case("bash") {
Some(ShellType::Bash)
} else if stem.eq_ignore_ascii_case("pwsh") || stem.eq_ignore_ascii_case("powershell") {
Some(ShellType::PowerShell)
} else {
None
}
}
#[cfg(unix)]
fn get_user_shell_path() -> Option<PathBuf> {
let uid = unsafe { libc::getuid() };
@@ -252,12 +367,231 @@ pub fn ultimate_fallback_shell() -> DetectedShell {
}
}
/// Legacy configured-shell compatibility helper.
///
/// This may fall back to the user's configured/default shell and therefore
/// must not be used for model-selected executable input. Model-selected shells
/// must go through [`resolve_model_provided_shell_in`].
pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> DetectedShell {
detect_shell_type(shell_path)
.and_then(|shell_type| get_shell(shell_type, Some(shell_path)))
.unwrap_or_else(ultimate_fallback_shell)
}
/// Resolves a model-selected shell exactly once against the invocation's PATH
/// and selected execution cwd.
///
/// Unlike configured-shell detection, this never falls back to another
/// executable. The returned path is the one policy and runtime must both use.
pub fn resolve_model_provided_shell_in(
shell_path: &Path,
search_path: &OsStr,
path_ext: Option<&OsStr>,
cwd: &Path,
) -> Result<DetectedShell, ModelShellResolveError> {
let shell_type = detect_shell_type(shell_path)
.ok_or_else(|| ModelShellResolveError::Unsupported(shell_path.to_path_buf()))?;
if !cwd.is_absolute() {
return Err(ModelShellResolveError::RelativeWorkingDirectory(
cwd.to_path_buf(),
));
}
#[cfg(not(windows))]
let is_bare_name = shell_path.components().count() == 1;
#[cfg(windows)]
let resolved_path = resolve_model_shell_path_windows(
shell_path,
search_path,
path_ext.unwrap_or_else(|| OsStr::new("")),
cwd,
)?;
#[cfg(not(windows))]
let resolved_path = {
let _ = path_ext;
if is_bare_name {
std::env::split_paths(search_path)
.map(|directory| resolve_relative_path(&directory, cwd).join(shell_path))
.find(|candidate| validate_model_shell_path(candidate).is_ok())
.ok_or_else(|| ModelShellResolveError::MissingBareName(shell_path.to_path_buf()))?
} else {
resolve_relative_path(shell_path, cwd)
}
};
if resolved_path.to_str().is_none() {
return Err(ModelShellResolveError::NonUtf8ResolvedPath(resolved_path));
}
validate_model_shell_path(&resolved_path)?;
Ok(DetectedShell {
shell_type,
shell_path: resolved_path,
})
}
fn resolve_relative_path(path: &Path, cwd: &Path) -> PathBuf {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
};
absolute
.components()
.filter(|component| !matches!(component, std::path::Component::CurDir))
.collect()
}
#[cfg(any(windows, test))]
#[cfg_attr(not(windows), allow(dead_code))]
fn resolve_model_shell_path_windows(
shell_path: &Path,
search_path: &OsStr,
path_ext: &OsStr,
cwd: &Path,
) -> Result<PathBuf, ModelShellResolveError> {
validate_windows_local_path_namespace(shell_path)?;
let path_extensions = path_ext
.to_string_lossy()
.split(';')
.filter(|extension| extension.starts_with('.') && extension.len() > 1)
.map(str::to_owned)
.collect::<Vec<_>>();
let candidate_names = windows_executable_candidates(shell_path, &path_extensions);
let has_separator = shell_path.components().count() > 1;
if has_separator {
let exact_path = resolve_windows_path_against_cwd(shell_path, cwd)?;
if !exact_path.is_absolute() {
return Err(ModelShellResolveError::UnresolvedWindowsRelativePath(
shell_path.to_path_buf(),
));
}
let mut first_specific_error = None;
for candidate in candidate_names
.into_iter()
.map(|candidate| resolve_relative_path(&candidate, cwd))
{
match validate_model_shell_path(&candidate) {
Ok(()) => return Ok(candidate),
Err(ModelShellResolveError::MissingPath(_)) => {}
Err(err) if first_specific_error.is_none() => first_specific_error = Some(err),
Err(_) => {}
}
}
if first_specific_error.is_none()
&& shell_path.extension().is_none()
&& std::fs::metadata(&exact_path).is_ok_and(|metadata| metadata.is_file())
{
return Err(ModelShellResolveError::UnsupportedWindowsLaunch(exact_path));
}
return Err(first_specific_error.unwrap_or(ModelShellResolveError::MissingPath(exact_path)));
}
for directory in std::env::split_paths(search_path) {
// Preserve PATH ordering: validate an entry immediately before it
// would be searched, without inspecting later unused entries.
let resolved_directory = resolve_windows_path_against_cwd(&directory, cwd)?;
if !resolved_directory.is_absolute() {
return Err(ModelShellResolveError::UnresolvedWindowsRelativePath(
directory,
));
}
for candidate_name in &candidate_names {
let candidate = resolved_directory.join(candidate_name);
if validate_model_shell_path(&candidate).is_ok() {
return Ok(candidate);
}
}
}
Err(ModelShellResolveError::MissingBareName(
shell_path.to_path_buf(),
))
}
#[cfg(any(windows, test))]
#[cfg_attr(not(windows), allow(dead_code))]
fn validate_windows_local_path_namespace(path: &Path) -> Result<(), ModelShellResolveError> {
let Some(std::path::Component::Prefix(prefix)) = path.components().next() else {
return Ok(());
};
// Only ordinary drive-letter paths are accepted as prefixed syntax because
// they do not directly encode a remote host or device namespace. UNC,
// device, and every verbatim namespace can cause remote or device I/O
// during metadata lookup and must not be probed.
//
// This is not proof that the storage is local: a mapped drive or a reparse
// point can still redirect a normal drive path before approval. Closing
// that residual requires a two-phase or handle-based resolution design.
match prefix.kind() {
std::path::Prefix::Disk(_) => Ok(()),
_ => Err(ModelShellResolveError::UnsupportedWindowsPathNamespace(
path.to_path_buf(),
)),
}
}
#[cfg(any(windows, test))]
#[cfg_attr(not(windows), allow(dead_code))]
fn resolve_windows_path_against_cwd(
path: &Path,
cwd: &Path,
) -> Result<PathBuf, ModelShellResolveError> {
validate_windows_local_path_namespace(path)?;
let has_prefix = matches!(
path.components().next(),
Some(std::path::Component::Prefix(_))
);
if !path.is_absolute() && !has_prefix {
// Relative and root-relative paths inherit directory or drive context
// from cwd, so validate that namespace before joining or probing.
validate_windows_local_path_namespace(cwd)?;
}
Ok(resolve_relative_path(path, cwd))
}
#[cfg(any(windows, test))]
#[cfg_attr(not(windows), allow(dead_code))]
fn windows_executable_candidates(shell_path: &Path, path_extensions: &[String]) -> Vec<PathBuf> {
match shell_path.extension() {
Some(extension) if extension.eq_ignore_ascii_case("exe") => {
vec![shell_path.to_path_buf()]
}
Some(_) => Vec::new(),
None => path_extensions
.iter()
.filter(|extension| extension.eq_ignore_ascii_case(".exe"))
.map(|extension| {
let mut candidate = shell_path.as_os_str().to_os_string();
candidate.push(extension);
PathBuf::from(candidate)
})
.collect(),
}
}
fn validate_model_shell_path(path: &Path) -> Result<(), ModelShellResolveError> {
let metadata = std::fs::metadata(path)
.map_err(|_| ModelShellResolveError::MissingPath(path.to_path_buf()))?;
if !metadata.is_file() {
return Err(ModelShellResolveError::NotAFile(path.to_path_buf()));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if metadata.permissions().mode() & 0o111 == 0 {
return Err(ModelShellResolveError::NotExecutable(path.to_path_buf()));
}
}
Ok(())
}
pub fn get_shell(shell_type: ShellType, path: Option<&PathBuf>) -> Option<DetectedShell> {
match shell_type {
ShellType::Zsh => get_zsh_shell(path),
@@ -365,4 +699,424 @@ mod tests {
Some(ShellType::Cmd)
);
}
#[test]
fn detects_remote_shell_hints_independently_of_controller_path_syntax() {
for (hint, expected) in [
("bash", Some(ShellType::Bash)),
("/attacker/bash", Some(ShellType::Bash)),
(r"C:\attacker\BASH.ExE", Some(ShellType::Bash)),
(r"\\server\share\PwSh.EXE", Some(ShellType::PowerShell)),
("/opt/PowerShell", Some(ShellType::PowerShell)),
(r"C:\Windows\System32\cmd.exe", Some(ShellType::Cmd)),
("/tmp/fish", None),
(r"C:\attacker\powershell.exe:payload", None),
("/tmp/bash/", None),
(r"C:\tmp\bash\", None),
] {
assert_eq!(
detect_shell_type_from_hint(hint),
expected,
"unexpected type for {hint:?}"
);
}
}
#[cfg(unix)]
fn write_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::write(path, "#!/bin/sh\nexit 0\n").expect("write fake shell");
let mut permissions = std::fs::metadata(path)
.expect("fake shell metadata")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(path, permissions).expect("make fake shell executable");
}
#[cfg(unix)]
#[test]
fn model_shell_resolver_uses_only_supplied_path() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let bin = temp_dir.path().join("bin");
std::fs::create_dir(&bin).expect("create bin");
let shell = bin.join("sh");
write_executable(&shell);
assert_eq!(
resolve_model_provided_shell_in(
Path::new("sh"),
bin.as_os_str(),
/*path_ext*/ None,
temp_dir.path(),
),
Ok(DetectedShell {
shell_type: ShellType::Sh,
shell_path: shell,
})
);
assert_eq!(
resolve_model_provided_shell_in(
Path::new("sh"),
OsStr::new(""),
/*path_ext*/ None,
temp_dir.path(),
),
Err(ModelShellResolveError::MissingBareName(PathBuf::from("sh")))
);
}
#[cfg(unix)]
#[test]
fn model_shell_resolver_resolves_relative_input_and_path_against_selected_cwd() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let tools = temp_dir.path().join("tools");
std::fs::create_dir(&tools).expect("create tools");
let shell = tools.join("bash");
write_executable(&shell);
for (requested, search_path) in [
(Path::new("./tools/bash"), OsStr::new("")),
(Path::new("bash"), OsStr::new("tools")),
] {
assert_eq!(
resolve_model_provided_shell_in(
requested,
search_path,
/*path_ext*/ None,
temp_dir.path(),
),
Ok(DetectedShell {
shell_type: ShellType::Bash,
shell_path: shell.clone(),
})
);
}
}
#[cfg(unix)]
#[test]
fn model_shell_resolver_rejects_unsupported_missing_and_non_launchable_inputs() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let non_executable = temp_dir.path().join("bash");
std::fs::write(&non_executable, "#!/bin/sh\n").expect("write non-executable shell");
let directory = temp_dir.path().join("sh");
std::fs::create_dir(&directory).expect("create shell-named directory");
let missing = temp_dir.path().join("zsh");
assert_eq!(
resolve_model_provided_shell_in(
&non_executable,
OsStr::new(""),
/*path_ext*/ None,
temp_dir.path(),
),
Err(ModelShellResolveError::NotExecutable(non_executable))
);
assert_eq!(
resolve_model_provided_shell_in(
&directory,
OsStr::new(""),
/*path_ext*/ None,
temp_dir.path(),
),
Err(ModelShellResolveError::NotAFile(directory))
);
assert_eq!(
resolve_model_provided_shell_in(
&missing,
OsStr::new(""),
/*path_ext*/ None,
temp_dir.path(),
),
Err(ModelShellResolveError::MissingPath(missing))
);
assert_eq!(
resolve_model_provided_shell_in(
Path::new("fish"),
OsStr::new(""),
/*path_ext*/ None,
temp_dir.path(),
),
Err(ModelShellResolveError::Unsupported(PathBuf::from("fish")))
);
assert_eq!(
resolve_model_provided_shell_in(
Path::new("sh"),
OsStr::new(""),
/*path_ext*/ None,
Path::new("."),
),
Err(ModelShellResolveError::RelativeWorkingDirectory(
PathBuf::from(".")
))
);
}
#[cfg(unix)]
#[test]
fn model_shell_resolver_rejects_non_utf8_resolved_path() {
use std::os::unix::ffi::OsStringExt;
let temp_dir = tempfile::tempdir().expect("temp dir");
let non_utf8_cwd = temp_dir
.path()
.join(std::ffi::OsString::from_vec(b"cwd-\xff".to_vec()));
let shell = non_utf8_cwd.join("bash");
assert_eq!(
resolve_model_provided_shell_in(
Path::new("./bash"),
OsStr::new(""),
/*path_ext*/ None,
&non_utf8_cwd,
),
Err(ModelShellResolveError::NonUtf8ResolvedPath(shell))
);
}
#[cfg(windows)]
#[test]
fn windows_model_shell_resolver_uses_supplied_pathext() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let bin = temp_dir.path().join("bin");
std::fs::create_dir(&bin).expect("create bin");
for (requested, file_name) in [("PowerShell.ExE", "PowerShell.ExE"), ("PWSH", "PWSH.EXE")] {
let shell = bin.join(file_name);
std::fs::write(&shell, "fixture").expect("write fake executable");
assert_eq!(
resolve_model_provided_shell_in(
Path::new(requested),
bin.as_os_str(),
Some(OsStr::new(".EXE")),
temp_dir.path(),
),
Ok(DetectedShell {
shell_type: ShellType::PowerShell,
shell_path: shell,
})
);
}
assert_eq!(
resolve_model_provided_shell_in(
Path::new("powershell"),
bin.as_os_str(),
Some(OsStr::new(".CMD")),
temp_dir.path(),
),
Err(ModelShellResolveError::MissingBareName(PathBuf::from(
"powershell"
)))
);
let extensionless = temp_dir.path().join("bash");
std::fs::write(&extensionless, "fixture").expect("write extensionless executable");
assert_eq!(
resolve_model_provided_shell_in(
&extensionless,
OsStr::new(""),
Some(OsStr::new("")),
temp_dir.path(),
),
Err(ModelShellResolveError::UnsupportedWindowsLaunch(
extensionless.clone()
))
);
let sibling_exe = temp_dir.path().join("bash.EXE");
std::fs::write(&sibling_exe, "fixture").expect("write sibling exe");
assert_eq!(
resolve_model_provided_shell_in(
&extensionless,
OsStr::new(""),
Some(OsStr::new(".EXE")),
temp_dir.path(),
),
Ok(DetectedShell {
shell_type: ShellType::Bash,
shell_path: sibling_exe,
})
);
for (requested, file_name, path_ext) in [
("cmd", "cmd.CMD", ".CMD"),
("powershell", "powershell.BAT", ".BAT"),
] {
std::fs::write(bin.join(file_name), "fixture").expect("write script candidate");
assert_eq!(
resolve_model_provided_shell_in(
Path::new(requested),
bin.as_os_str(),
Some(OsStr::new(path_ext)),
temp_dir.path(),
),
Err(ModelShellResolveError::MissingBareName(PathBuf::from(
requested
)))
);
}
let directory = temp_dir.path().join("sh.exe");
std::fs::create_dir(&directory).expect("create shell-named directory");
assert_eq!(
resolve_model_provided_shell_in(
&directory,
OsStr::new(""),
Some(OsStr::new(".EXE")),
temp_dir.path(),
),
Err(ModelShellResolveError::NotAFile(directory))
);
let drive_relative = PathBuf::from(r"C:relative\powershell.exe");
assert_eq!(
resolve_model_provided_shell_in(
&drive_relative,
OsStr::new(""),
Some(OsStr::new(".EXE")),
temp_dir.path(),
),
Err(ModelShellResolveError::UnresolvedWindowsRelativePath(
drive_relative
))
);
let later_shell = bin.join("bash.EXE");
std::fs::write(&later_shell, "fixture").expect("write later PATH shell");
let drive_relative_path = PathBuf::from(r"C:relative-bin");
let search_path = std::env::join_paths([drive_relative_path.as_path(), bin.as_path()])
.expect("join PATH entries");
assert_eq!(
resolve_model_provided_shell_in(
Path::new("bash"),
&search_path,
Some(OsStr::new(".EXE")),
temp_dir.path(),
),
Err(ModelShellResolveError::UnresolvedWindowsRelativePath(
drive_relative_path
))
);
}
#[cfg(windows)]
#[test]
fn windows_model_shell_resolver_rejects_remote_device_and_verbatim_namespaces() {
let temp_dir = tempfile::tempdir().expect("temp dir");
for requested in [
PathBuf::from(r"\\server\share\powershell.exe"),
PathBuf::from(r"//server/share/powershell.exe"),
PathBuf::from(r"\/server\share/powershell.exe"),
PathBuf::from(r"\\.\GLOBALROOT\Device\HarddiskVolume1\powershell.exe"),
PathBuf::from(r"\\?\C:\tools\powershell.exe"),
PathBuf::from(r"\\?\C:/tools/powershell.exe"),
PathBuf::from(r"\\?\UNC\server\share\powershell.exe"),
PathBuf::from(r"\\?\UNC\server/share/powershell.exe"),
PathBuf::from(r"\\?\Volume{12345678-1234-1234-1234-123456789abc}\powershell.exe"),
PathBuf::from(r"\\?\Volume{12345678-1234-1234-1234-123456789abc}/powershell.exe"),
] {
assert_eq!(
resolve_model_provided_shell_in(
&requested,
OsStr::new(""),
Some(OsStr::new(".EXE")),
temp_dir.path(),
),
Err(ModelShellResolveError::UnsupportedWindowsPathNamespace(
requested.clone()
)),
"unsafe namespace should fail before any executable probe: {requested:?}"
);
}
}
#[cfg(windows)]
#[test]
fn windows_model_shell_resolver_validates_path_namespaces_in_lookup_order() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let bin = temp_dir.path().join("bin");
std::fs::create_dir(&bin).expect("create bin");
let shell = bin.join("powershell.EXE");
std::fs::write(&shell, "fixture").expect("write fake executable");
let unsafe_directory = PathBuf::from(r"\\server\share\bin");
let unsafe_first =
std::env::join_paths([unsafe_directory.as_path(), bin.as_path()]).expect("join PATH");
assert_eq!(
resolve_model_provided_shell_in(
Path::new("powershell"),
&unsafe_first,
Some(OsStr::new(".EXE")),
temp_dir.path(),
),
Err(ModelShellResolveError::UnsupportedWindowsPathNamespace(
unsafe_directory.clone()
))
);
let local_first =
std::env::join_paths([bin.as_path(), unsafe_directory.as_path()]).expect("join PATH");
assert_eq!(
resolve_model_provided_shell_in(
Path::new("powershell"),
&local_first,
Some(OsStr::new(".EXE")),
temp_dir.path(),
),
Ok(DetectedShell {
shell_type: ShellType::PowerShell,
shell_path: shell,
}),
"a valid earlier PATH candidate must return before an unused unsafe entry"
);
}
#[cfg(windows)]
#[test]
fn windows_model_shell_resolver_checks_unsafe_cwd_only_when_resolution_uses_it() {
let temp_dir = tempfile::tempdir().expect("temp dir");
let relative_dir = temp_dir.path().join("tools");
std::fs::create_dir(&relative_dir).expect("create tools");
let shell = relative_dir.join("pwsh.exe");
std::fs::write(&shell, "fixture").expect("write fake executable");
assert_eq!(
resolve_model_provided_shell_in(
Path::new(r"tools\pwsh.exe"),
OsStr::new(""),
Some(OsStr::new(".EXE")),
temp_dir.path(),
),
Ok(DetectedShell {
shell_type: ShellType::PowerShell,
shell_path: shell.clone(),
})
);
let unsafe_cwd = PathBuf::from(r"\\server\share\cwd");
assert_eq!(
resolve_model_provided_shell_in(
Path::new(r".\pwsh.exe"),
OsStr::new(""),
Some(OsStr::new(".EXE")),
&unsafe_cwd,
),
Err(ModelShellResolveError::UnsupportedWindowsPathNamespace(
unsafe_cwd.clone()
))
);
assert_eq!(
resolve_model_provided_shell_in(
&shell,
OsStr::new(""),
Some(OsStr::new(".EXE")),
&unsafe_cwd,
),
Ok(DetectedShell {
shell_type: ShellType::PowerShell,
shell_path: shell,
}),
"an absolute local shell does not use cwd during resolution"
);
}
}