use std::path::{Path, PathBuf};
use super::super::command_line_prepare::{
arg_has_parent_dir_ref, is_arg_safe, merge_dot_slash_with_single_relative_path,
peel_workspace_cd_prefix, split_command_prefix_if_embedded,
};
use super::{
PreparedRunCommand, RunCommandError, check_shell_variable_references,
command_shell_script::{
argv_needs_posix_shell_wrap, is_shell_dash_c_invocation, maybe_wrap_argv_with_posix_shell,
posix_shell_on_allowlist,
},
};
fn normalize_workspace_absolute_arg(arg: &str, working_dir: &Path) -> String {
let a = arg.trim();
if !a.starts_with('/') {
return arg.to_string();
}
let Ok(rel) = Path::new(a).strip_prefix(working_dir) else {
return arg.to_string();
};
if rel.as_os_str().is_empty() {
".".to_string()
} else {
rel.to_string_lossy().to_string()
}
}
fn extract_run_command_name_and_args(
args: &serde_json::Value,
working_dir: &Path,
) -> Result<(String, Vec<String>), RunCommandError> {
let mut cmd_raw = match args.get("command").and_then(|c| c.as_str()) {
Some(s) => s.trim().to_string(),
None => return Err(RunCommandError::MissingCommand),
};
let mut cmd_args: Vec<String> = match args.get("args") {
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect(),
Some(_) => return Err(RunCommandError::ArgsNotArray),
None => vec![],
};
cmd_args = cmd_args
.into_iter()
.map(|a| normalize_workspace_absolute_arg(&a, working_dir))
.collect();
merge_dot_slash_with_single_relative_path(&mut cmd_raw, &mut cmd_args);
split_command_prefix_if_embedded(&mut cmd_raw, &mut cmd_args);
Ok((cmd_raw, cmd_args))
}
fn resolve_run_command_exec_path(
cmd_raw: &str,
effective_working_dir: &Path,
allowed_commands: &[String],
) -> Result<(String, Option<PathBuf>), RunCommandError> {
let cmd_name = cmd_raw.to_lowercase();
let is_workspace_executable = cmd_raw.starts_with("./") || cmd_raw.contains('/');
let exec_path = if is_workspace_executable {
crate::cm_tools::tools::resolve_workspace_executable(effective_working_dir, cmd_raw).ok()
} else {
None
};
if exec_path.is_none()
&& !allowed_commands
.iter()
.any(|c| c.eq_ignore_ascii_case(&cmd_name))
{
return Err(RunCommandError::DisallowedCommand {
attempted: cmd_name,
allowed: allowed_commands.join(", "),
});
}
Ok((cmd_name, exec_path))
}
fn arg_is_unsafe_for_cmd(cmd_name: &str, arg: &str, has_workspace_exec: bool) -> bool {
if has_workspace_exec {
arg_has_parent_dir_ref(arg) || arg.trim_start().starts_with('/')
} else {
!is_arg_safe(cmd_name, arg)
}
}
fn validate_run_command_args_safety(
cmd_name: &str,
cmd_args: &[String],
has_workspace_exec: bool,
) -> Result<(), RunCommandError> {
for a in cmd_args {
if arg_is_unsafe_for_cmd(cmd_name, a, has_workspace_exec) {
return Err(RunCommandError::UnsafeArg);
}
}
Ok(())
}
fn collect_unsafe_cmd_args(
cmd_name: &str,
cmd_args: &[String],
has_workspace_exec: bool,
) -> Vec<String> {
cmd_args
.iter()
.filter(|a| arg_is_unsafe_for_cmd(cmd_name, a, has_workspace_exec))
.cloned()
.collect()
}
fn advance_cd_prefixes_collecting_unsafe(
workspace_root: &Path,
effective_working_dir: &mut PathBuf,
cmd_raw: &mut String,
cmd_args: &mut Vec<String>,
unsafe_out: &mut Vec<String>,
) -> Result<(), RunCommandError> {
let anchor = workspace_root
.canonicalize()
.map_err(|e| RunCommandError::SpawnOther {
cmd: "canonicalize(workspace)".to_string(),
source: e,
})?;
loop {
if !cmd_raw.eq_ignore_ascii_case("cd") {
break;
}
if cmd_args.len() < 3 || cmd_args[1] != "&&" {
break;
}
let dir = cmd_args[0].trim().to_string();
let pattern_unsafe = !is_arg_safe("cd", &dir);
let candidate = effective_working_dir.join(&dir);
let mut escape_unsafe = false;
if candidate.is_dir() {
match candidate.canonicalize() {
Ok(canon_cand) => {
if !canon_cand.starts_with(&anchor) {
escape_unsafe = true;
} else if !pattern_unsafe {
*effective_working_dir = canon_cand;
}
}
Err(e) => {
return Err(RunCommandError::SpawnOther {
cmd: format!("canonicalize({})", candidate.display()),
source: e,
});
}
}
}
if (pattern_unsafe || escape_unsafe) && !unsafe_out.iter().any(|u| u == &dir) {
unsafe_out.push(dir);
}
*cmd_args = cmd_args[2..].to_vec();
if cmd_args.is_empty() {
return Err(RunCommandError::MissingCommand);
}
*cmd_raw = cmd_args[0].clone();
let rest: Vec<String> = cmd_args[1..].to_vec();
*cmd_args = rest;
split_command_prefix_if_embedded(cmd_raw, cmd_args);
}
Ok(())
}
pub fn scan_run_command_unsafe_args(
args: &serde_json::Value,
working_dir: &Path,
allowed_commands: &[String],
) -> Result<Vec<String>, RunCommandError> {
let (mut cmd_raw, mut cmd_args) = extract_run_command_name_and_args(args, working_dir)?;
let mut effective_working_dir = working_dir.to_path_buf();
let mut unsafe_args = Vec::new();
advance_cd_prefixes_collecting_unsafe(
working_dir,
&mut effective_working_dir,
&mut cmd_raw,
&mut cmd_args,
&mut unsafe_args,
)?;
let cmd_name = cmd_raw.to_lowercase();
let is_workspace_executable = cmd_raw.starts_with("./") || cmd_raw.contains('/');
let has_workspace_exec = if is_workspace_executable {
crate::cm_tools::tools::resolve_workspace_executable(effective_working_dir.as_path(), &cmd_raw)
.is_ok()
} else {
false
};
let (cmd_name, has_exec) =
match resolve_run_command_exec_path(&cmd_raw, &effective_working_dir, allowed_commands) {
Ok((name, exec)) => (name, exec.is_some()),
Err(RunCommandError::DisallowedCommand { .. }) => (cmd_name, has_workspace_exec),
Err(e) => return Err(e),
};
let more = collect_unsafe_cmd_args(&cmd_name, &cmd_args, has_exec);
for a in more {
if !unsafe_args.iter().any(|u| u == &a) {
unsafe_args.push(a);
}
}
Ok(unsafe_args)
}
fn skip_shell_variable_check(cmd_raw: &str, cmd_args: &[String], allowed: &[String]) -> bool {
is_shell_dash_c_invocation(cmd_raw, cmd_args)
|| (posix_shell_on_allowlist(allowed).is_some()
&& argv_needs_posix_shell_wrap(cmd_raw, cmd_args))
}
fn wrap_posix_shell_reresolve(
cmd_raw: &mut String,
cmd_args: &mut Vec<String>,
cmd_name: &mut String,
exec_path: &mut Option<PathBuf>,
working_dir: &Path,
allowed_commands: &[String],
) -> Result<(), RunCommandError> {
if maybe_wrap_argv_with_posix_shell(cmd_raw, cmd_args, allowed_commands) {
let wrapped = resolve_run_command_exec_path(cmd_raw, working_dir, allowed_commands)?;
*cmd_name = wrapped.0;
*exec_path = wrapped.1;
}
Ok(())
}
pub(super) fn prepare_run_command_invocation(
args: &serde_json::Value,
working_dir: &Path,
allowed_commands: &[String],
skip_arg_safety: bool,
) -> Result<PreparedRunCommand, RunCommandError> {
let (mut cmd_raw, mut cmd_args) = extract_run_command_name_and_args(args, working_dir)?;
let mut effective_working_dir = working_dir.to_path_buf();
peel_workspace_cd_prefix(
working_dir,
&mut effective_working_dir,
&mut cmd_raw,
&mut cmd_args,
skip_arg_safety,
)?;
let inject_gh_token = crate::cm_tools::github_token::command_basename_is_gh(&cmd_raw);
if !skip_shell_variable_check(&cmd_raw, &cmd_args, allowed_commands) {
check_shell_variable_references(&cmd_raw, &cmd_args)?;
}
let (mut cmd_name, mut exec_path) =
resolve_run_command_exec_path(&cmd_raw, &effective_working_dir, allowed_commands)?;
if !skip_arg_safety {
validate_run_command_args_safety(&cmd_name, &cmd_args, exec_path.is_some())?;
}
wrap_posix_shell_reresolve(
&mut cmd_raw,
&mut cmd_args,
&mut cmd_name,
&mut exec_path,
&effective_working_dir,
allowed_commands,
)?;
Ok(PreparedRunCommand {
cmd_raw,
cmd_name,
exec_path,
cmd_args,
effective_working_dir,
inject_gh_token,
})
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::scan_run_command_unsafe_args;
fn test_allowed() -> Vec<String> {
vec![
"cat".into(),
"cmake".into(),
"git".into(),
"cd".into(),
"ls".into(),
]
}
#[test]
fn scan_lists_external_absolute_path_not_cmake_dotdot() {
let abs: serde_json::Value =
serde_json::from_str(r#"{"command":"cat","args":["/etc/passwd"]}"#).expect("json");
let u = scan_run_command_unsafe_args(&abs, Path::new("."), &test_allowed()).expect("scan");
assert_eq!(u, vec!["/etc/passwd".to_string()]);
let cmake: serde_json::Value =
serde_json::from_str(r#"{"command":"cmake","args":[".."]}"#).expect("json");
let u =
scan_run_command_unsafe_args(&cmake, Path::new("."), &test_allowed()).expect("scan");
assert!(u.is_empty(), "{u:?}");
let git_range: serde_json::Value =
serde_json::from_str(r#"{"command":"git","args":["log","main..HEAD"]}"#).expect("json");
let u = scan_run_command_unsafe_args(&git_range, Path::new("."), &test_allowed())
.expect("scan");
assert!(
u.is_empty(),
"git A..B must not look like path traversal: {u:?}"
);
}
#[test]
fn scan_lists_cd_parent_dir_prefix() {
let v: serde_json::Value =
serde_json::from_str(r#"{"command":"cd","args":["..","&&","ls"]}"#).expect("json");
let u = scan_run_command_unsafe_args(&v, Path::new("."), &test_allowed()).expect("scan");
assert!(u.iter().any(|a| a == ".."), "{u:?}");
}
}