use std::cell::Cell;
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
use trusty_mpm::core::project_aliases::main_checkout_root;
use super::continuation;
use super::heredoc::{HeredocBodies, blank_spans, data_bodies};
use super::substitutions::{
HEREDOC_RUNNERS, Substitution, blank_inert_heredocs, segment_substitutions,
shell_run_heredoc_bodies,
};
use super::{
MAX_WRAPPER_DEPTH, PathEnv, resolve_target_path, shell_lex, split_shell_segments,
split_shell_segments_raw,
};
use crate::commands::hook_rewrite::first_command_token;
use crate::commands::program_word::resolve_program_word;
pub(crate) const DESTRUCTIVE_DELETE_REASON: &str = "`rm`/`rmdir`/`unlink`/`find -delete` must \
not target a filesystem root or bare container (`/`, `/root`, `/Users`, `/Users/<name>`, \
`/home`, `/home/<name>`, `/Volumes`, `/private`, `/var`, `/etc`, `/usr`, `/opt`, `/Library`, \
`/System`, `/Applications`, `$HOME`, or `$HOME`'s parent directory), a repository root, a `.git` \
directory, or a `.claude/worktrees`/`.worktrees` entry (issue #4031) — each is either \
unrecoverable data loss or another session's or workstream's uncommitted work. Ordinary file \
and directory cleanup elsewhere (build artifacts, stale files, `git clean -fd`) is unaffected. \
To remove a worktree, hand it back: report its path to the PM (or to `version-control`) \
and stop. `rm -rf` on a worktree directory is never the workaround, and neither is a sweep \
over other worktrees.";
pub(crate) const DESTRUCTIVE_DELETE_UNRESOLVED_REASON: &str = "A Bash segment names \
`rm`/`rmdir`/`unlink`/`find` but this guard could not resolve what it targets (issue #4031) \
— either the segment's quoting could not be parsed, or the verb carried no positional \
argument. Denying rather than guessing is this guard's fail-closed rule. Rewrite the command \
with an unambiguous, directly-quoted target.";
const DELETE_VERBS: &[&str] = &["rm", "rmdir", "unlink", "find"];
pub(crate) fn evaluate_destructive_delete_command(
command: &str,
cwd: &Path,
) -> Option<DeleteTarget> {
classify_destructive_delete_in(command, cwd, &PathEnv::from_process())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum DeleteTarget {
Worktree,
Repository,
Root,
Unresolved,
}
impl DeleteTarget {
pub(crate) fn is_floor(self) -> bool {
self >= Self::Root
}
pub(crate) fn reason(self) -> &'static str {
match self {
Self::Unresolved => DESTRUCTIVE_DELETE_UNRESOLVED_REASON,
_ => DESTRUCTIVE_DELETE_REASON,
}
}
}
#[cfg(test)]
fn evaluate_destructive_delete_command_in(
command: &str,
cwd: &Path,
env: &PathEnv,
) -> Option<&'static str> {
classify_destructive_delete_in(command, cwd, env).map(DeleteTarget::reason)
}
fn classify_destructive_delete_in(
command: &str,
cwd: &Path,
env: &PathEnv,
) -> Option<DeleteTarget> {
let masked = lone_inert_heredoc(command);
let belt = data_body_root_delete(command, &[cwd.to_path_buf()], env);
let command = masked.as_deref().unwrap_or(command);
classify_at_depth(command, cwd, env, 0, &Cell::new(0)).max(belt)
}
fn data_body_root_delete(command: &str, cwds: &[PathBuf], env: &PathEnv) -> Option<DeleteTarget> {
let heredocs = HeredocBodies::scan(command);
heredocs
.bodies()
.iter()
.flat_map(|body| command[body.span.0..body.span.1].lines())
.flat_map(split_shell_segments_raw)
.filter_map(|segment| {
let trimmed = segment.trim();
let argv = shlex::split(trimmed).unwrap_or_else(|| {
let words = trimmed.split_whitespace();
words.map(|w| w.replace(['\'', '"'], "")).collect()
});
let Ok(word) = resolve_program_word(&argv) else {
return segment_mentions_a_delete_verb(trimmed).then_some(DeleteTarget::Unresolved);
};
let verb = argv.get(word.index).map_or("", |w| verb_name(w));
let root = !word.lookup
&& DELETE_VERBS.contains(&verb)
&& delete_targets(verb, &argv[word.index + 1..])
.iter()
.any(|target| {
cwds.iter().any(|dir| {
let path = resolve_target_path(target, dir, env);
is_root_class(glob_parent(&path), env)
})
});
root.then_some(DeleteTarget::Root)
})
.max()
}
const STDIN_DATA_CONSUMERS: &[&str] = &["python3", "python", "node", "ruby", "cat"];
pub(crate) fn lone_inert_heredoc(command: &str) -> Option<String> {
let bodies = data_bodies(command);
let [body] = bodies.as_slice() else {
return None;
};
let (line_start, line_end) = body.operator_line;
if body.expands || !command[..line_start].trim().is_empty() {
return None;
}
let line = &command[line_start..line_end];
if line.contains(['|', ';', '&', '$', '`', '(', ')', '\\']) {
return None;
}
let argv = shlex::split(line)?;
let (program, rest) = argv.split_first()?;
if !STDIN_DATA_CONSUMERS.contains(&program.as_str()) {
return None;
}
let (heredocs, others): (Vec<&String>, Vec<&String>) =
rest.iter().partition(|t| t.starts_with("<<"));
if heredocs.len() != 1 || heredocs[0].starts_with("<<<") {
return None;
}
let word = &heredocs[0][2..];
let word = word.strip_prefix('-').unwrap_or(word);
if command[body.span.1..].trim() != word {
return None;
}
let plain =
|path: &str| !path.is_empty() && !path.contains(['*', '?', '[', ']', '{', '}', '~']);
let consumes = match (program.as_str(), others.as_slice()) {
(_, []) => true,
("cat", [op, path]) => matches!(op.as_str(), ">" | ">>") && plain(path),
("cat", [glued]) => glued
.strip_prefix(">>")
.or_else(|| glued.strip_prefix('>'))
.is_some_and(plain),
("cat", _) => false,
(_, [dash]) => *dash == "-",
_ => false,
};
consumes.then(|| blank_spans(command, &[body.span]))
}
const MAX_DELETE_WORK: usize = 4096;
fn classify_at_depth(
command: &str,
cwd: &Path,
env: &PathEnv,
depth: usize,
work: &Cell<usize>,
) -> Option<DeleteTarget> {
work.set(work.get() + 1);
if work.get() > MAX_DELETE_WORK {
return Some(DeleteTarget::Unresolved);
}
if depth > MAX_WRAPPER_DEPTH {
return segment_mentions_a_delete_verb(command).then_some(DeleteTarget::Unresolved);
}
if HeredocBodies::scan(command).lost_confidence() && segment_mentions_a_delete_verb(command) {
return Some(DeleteTarget::Unresolved);
}
let mut worst = continuation::joined(command)
.and_then(|joined| classify_at_depth(&joined, cwd, env, depth, work));
let mut effective_cwd = cwd.to_path_buf();
let mut cwds_seen: Vec<PathBuf> = vec![effective_cwd.clone()];
let mut judged: HashSet<String> = HashSet::new();
for segment in split_shell_segments(command) {
let trimmed = segment.trim();
if trimmed.is_empty() {
continue;
}
for body in substitution_bodies(trimmed) {
let inner = classify_at_depth(body.text(), &effective_cwd, env, depth + 1, work);
worst = worst.max(inner);
judged.insert(body.text().to_string());
}
if first_command_token(trimmed).as_deref() == Some("cd") {
if let Some(argv) = shlex::split(trimmed)
&& let Some(dest) = argv.get(1)
{
effective_cwd = resolve_target_path(dest, &effective_cwd, env);
if !cwds_seen.contains(&effective_cwd) {
cwds_seen.push(effective_cwd.clone());
}
}
continue;
}
let Some(argv) = shlex::split(trimmed) else {
if segment_mentions_a_delete_verb(trimmed) {
return Some(DeleteTarget::Unresolved);
}
continue;
};
let resolved = resolve_program_word(&argv);
if resolved.is_err() && segment_mentions_a_delete_verb(trimmed) {
return Some(DeleteTarget::Unresolved);
}
if resolved.is_ok_and(|w| w.lookup) {
continue;
}
let program_at = resolved.ok().map(|w| w.index).filter(|&at| {
argv.get(at)
.is_some_and(|w| DELETE_VERBS.contains(&verb_name(w)))
});
let Some(verb_idx) = program_at.or_else(|| {
argv.iter()
.position(|tok| DELETE_VERBS.contains(&tok.strip_prefix('\\').unwrap_or(tok)))
}) else {
continue;
};
let verb = verb_name(&argv[verb_idx]);
let tail = &argv[verb_idx + 1..];
let targets = delete_targets(verb, tail);
if verb == "find" && targets.is_empty() {
continue;
}
if targets.is_empty() {
return Some(DeleteTarget::Unresolved);
}
let repo_root = main_checkout_root(&effective_cwd);
for target in targets {
let resolved = resolve_target_path(&target, &effective_cwd, env);
worst = worst.max(denylisted_delete_class(
&resolved,
repo_root.as_deref(),
env,
));
}
}
for body in substitution_bodies(command) {
if !judged.insert(body.text().to_string()) {
continue;
}
for dir in &cwds_seen {
worst = worst.max(classify_at_depth(body.text(), dir, env, depth + 1, work));
}
}
worst = worst.max(data_body_root_delete(command, &cwds_seen, env));
for inner in nested_commands(command) {
if !judged.insert(inner.clone()) {
continue;
}
for dir in &cwds_seen {
worst = worst.max(classify_at_depth(&inner, dir, env, depth + 1, work));
}
}
worst
}
fn nested_commands(command: &str) -> Vec<String> {
let mut found = shell_run_heredoc_bodies(command);
for raw in split_shell_segments_raw(command) {
if let shell_lex::WrappedCommand::Inner(inner) = shell_lex::wrapped_command(raw.trim()) {
found.push(inner);
}
}
found
}
fn substitution_bodies(text: &str) -> Vec<Substitution> {
let (argv_text, expanded) = blank_inert_heredocs(text, HEREDOC_RUNNERS);
let mut bodies = segment_substitutions(&argv_text);
for body in expanded {
if !bodies.contains(&body) {
bodies.push(body);
}
}
bodies
}
fn verb_name(word: &str) -> &str {
let word = word.strip_prefix('\\').unwrap_or(word);
word.rsplit('/').next().unwrap_or(word)
}
fn segment_mentions_a_delete_verb(text: &str) -> bool {
text.split(|c: char| !c.is_alphanumeric() && c != '_')
.any(|word| DELETE_VERBS.contains(&word))
}
fn delete_targets(program: &str, tail: &[String]) -> Vec<String> {
if program == "find" {
if !tail.iter().any(|t| t == "-delete") {
return Vec::new();
}
let paths: Vec<String> = tail
.iter()
.take_while(|t| !t.starts_with('-'))
.cloned()
.collect();
return if paths.is_empty() {
vec![".".to_string()]
} else {
paths
};
}
let mut out = Vec::new();
let mut positional_only = false;
for tok in tail {
if !positional_only && tok == "--" {
positional_only = true;
continue;
}
if !positional_only && tok.starts_with('-') && tok.len() > 1 {
continue;
}
out.push(tok.clone());
}
out
}
fn denylisted_delete_class(
path: &Path,
repo_root: Option<&Path>,
env: &PathEnv,
) -> Option<DeleteTarget> {
let path = glob_parent(path);
if is_root_class(path, env) {
return Some(DeleteTarget::Root);
}
if path.file_name().and_then(|f| f.to_str()) == Some(".git")
|| repo_root.is_some_and(|root| path == root)
{
return Some(DeleteTarget::Repository);
}
is_worktree_root_or_container(path).then_some(DeleteTarget::Worktree)
}
fn is_root_class(path: &Path, env: &PathEnv) -> bool {
if path == Path::new("/") || path == Path::new("/root") {
return true;
}
if BARE_CONTAINER_ROOTS
.iter()
.any(|root| path == Path::new(root))
{
return true;
}
if let Some(home) = env.home.as_deref() {
if path == Path::new(home) {
return true;
}
if let Some(parent) = Path::new(home).parent()
&& path == parent
{
return true;
}
}
is_user_home_root(path)
}
const BARE_CONTAINER_ROOTS: &[&str] = &[
"/Users",
"/home",
"/Volumes",
"/private",
"/var",
"/etc",
"/usr",
"/opt",
"/Library",
"/System",
"/Applications",
];
fn glob_parent(path: &Path) -> &Path {
let has_glob = path
.file_name()
.and_then(|f| f.to_str())
.is_some_and(|f| f.contains(['*', '?', '[']));
if has_glob {
path.parent().unwrap_or(path)
} else {
path
}
}
fn is_user_home_root(path: &Path) -> bool {
let mut comps = path.components();
matches!(comps.next(), Some(Component::RootDir))
&& matches!(comps.next(), Some(Component::Normal(n)) if matches!(n.to_str(), Some("Users" | "home")))
&& matches!(comps.next(), Some(Component::Normal(_)))
&& comps.next().is_none()
}
fn is_worktree_root_or_container(path: &Path) -> bool {
let comps: Vec<Component<'_>> = path.components().collect();
for (i, component) in comps.iter().enumerate() {
let Component::Normal(name) = component else {
continue;
};
let container_end = if name.to_str() == Some(".worktrees") {
i + 1
} else if name.to_str() == Some(".claude")
&& matches!(comps.get(i + 1), Some(Component::Normal(n)) if n.to_str() == Some("worktrees"))
{
i + 2
} else {
continue;
};
return comps.len() - container_end <= 1;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn env_with_home(home: &str) -> PathEnv {
PathEnv {
tmpdir: None,
tmp: None,
home: Some(home.to_string()),
}
}
#[test]
fn denies_filesystem_root_deletion() {
let env = env_with_home("/Users/agent");
for command in [
"rm -rf /",
"rm -rf /root",
"rm -rf $HOME",
"rm -rf /Users/someone",
] {
assert_eq!(
evaluate_destructive_delete_command_in(command, Path::new("/work"), &env),
Some(DESTRUCTIVE_DELETE_REASON),
"expected deny for: {command}"
);
}
}
#[test]
fn the_most_severe_delete_class_wins() {
let env = env_with_home("/Users/agent");
let class = |command: &str| classify_destructive_delete_in(command, Path::new("/w"), &env);
let wt = "/w/.claude/worktrees/a";
assert_eq!(class(&format!("rm -rf {wt}")), Some(DeleteTarget::Worktree));
assert_eq!(
class(&format!("rm -rf {wt} && rm -rf $HOME")),
Some(DeleteTarget::Root)
);
assert_eq!(class(&format!("rm -rf {wt} /")), Some(DeleteTarget::Root));
assert_eq!(class("rm -rf /w/x/.git"), Some(DeleteTarget::Repository));
assert_eq!(class("rm -f"), Some(DeleteTarget::Unresolved));
assert!(DeleteTarget::Root.is_floor() && DeleteTarget::Unresolved.is_floor());
assert!(!DeleteTarget::Worktree.is_floor() && !DeleteTarget::Repository.is_floor());
}
#[test]
fn denies_home_expanded_from_a_literal_dollar_home() {
let env = env_with_home("/Users/agent");
assert_eq!(
evaluate_destructive_delete_command_in("rm -rf ${HOME}", Path::new("/work"), &env),
Some(DESTRUCTIVE_DELETE_REASON)
);
}
#[test]
fn denies_repo_root_deletion() {
let repo = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(repo.path().join(".git")).expect(".git dir");
let env = env_with_home("/Users/agent");
let command = format!("rm -rf {}", repo.path().display());
assert_eq!(
evaluate_destructive_delete_command_in(&command, repo.path(), &env),
Some(DESTRUCTIVE_DELETE_REASON)
);
}
#[test]
fn denies_dot_git_deletion() {
let env = env_with_home("/Users/agent");
assert_eq!(
evaluate_destructive_delete_command_in("rm -rf .git", Path::new("/repo"), &env),
Some(DESTRUCTIVE_DELETE_REASON)
);
}
#[test]
fn denies_worktree_root_deletion() {
let env = env_with_home("/Users/agent");
for command in [
"rm -rf /repo/.claude/worktrees/agent-x",
"rm -rf /repo/.claude/worktrees",
"rm -rf /repo/.worktrees/agent-x",
] {
assert_eq!(
evaluate_destructive_delete_command_in(command, Path::new("/repo"), &env),
Some(DESTRUCTIVE_DELETE_REASON),
"expected deny for: {command}"
);
}
}
#[test]
fn allows_worktree_interior_paths() {
let env = env_with_home("/Users/agent");
assert_eq!(
evaluate_destructive_delete_command_in(
"rm -rf /repo/.claude/worktrees/agent-x/target",
Path::new("/repo"),
&env
),
None
);
}
#[test]
fn allows_ordinary_cleanup() {
let env = env_with_home("/Users/agent");
for command in [
"rm stale.txt",
"rm -rf crates/x/target",
"cargo clean",
"git clean -fd",
"rmdir empty-dir",
"",
] {
assert_eq!(
evaluate_destructive_delete_command_in(command, Path::new("/repo"), &env),
None,
"expected allow for: {command}"
);
}
}
#[test]
fn denies_rmdir_of_a_denylisted_root() {
let env = env_with_home("/Users/agent");
assert_eq!(
evaluate_destructive_delete_command_in(
"rmdir /repo/.claude/worktrees/agent-x",
Path::new("/repo"),
&env
),
Some(DESTRUCTIVE_DELETE_REASON)
);
}
#[test]
fn denies_unlink_of_a_denylisted_root() {
let env = env_with_home("/Users/agent");
assert_eq!(
evaluate_destructive_delete_command_in("unlink /root", Path::new("/repo"), &env),
Some(DESTRUCTIVE_DELETE_REASON)
);
}
#[test]
fn denies_find_delete_of_a_denylisted_root() {
let env = env_with_home("/Users/agent");
assert_eq!(
evaluate_destructive_delete_command_in(
"find /repo/.git -delete",
Path::new("/repo"),
&env
),
Some(DESTRUCTIVE_DELETE_REASON)
);
}
#[test]
fn allows_find_without_delete_action() {
let env = env_with_home("/Users/agent");
assert_eq!(
evaluate_destructive_delete_command_in(
"find /repo/.git -name '*.pack'",
Path::new("/repo"),
&env
),
None
);
}
#[test]
fn denies_a_delete_hidden_in_a_composed_command() {
let env = env_with_home("/Users/agent");
for command in [
"cargo test -p trusty-mpm && rm -rf /root",
"true; rm -rf .git",
"cd /repo && rm -rf .claude/worktrees/agent-x",
] {
assert_eq!(
evaluate_destructive_delete_command_in(command, Path::new("/repo"), &env),
Some(DESTRUCTIVE_DELETE_REASON),
"expected deny for: {command}"
);
}
}
#[test]
fn denies_pwd_expansion_of_the_current_worktree_root() {
let env = env_with_home("/Users/agent");
let worktree = Path::new("/repo/.claude/worktrees/agent-x");
for command in ["rm -rf $PWD", "rm -rf ${PWD}"] {
assert_eq!(
evaluate_destructive_delete_command_in(command, worktree, &env),
Some(DESTRUCTIVE_DELETE_REASON),
"expected deny for: {command}"
);
}
}
#[test]
fn allows_pwd_expansion_of_a_subdirectory() {
let env = env_with_home("/Users/agent");
let worktree = Path::new("/repo/.claude/worktrees/agent-x");
assert_eq!(
evaluate_destructive_delete_command_in("rm -rf $PWD/target", worktree, &env),
None
);
}
#[test]
fn denies_glob_suffixed_deletes_of_a_denylisted_parent() {
let env = env_with_home("/Users/agent");
let home = Path::new("/Users/agent");
for (command, cwd) in [
("rm -rf /Users/someone/*", home),
("rm -rf ./*", home),
("rm -rf ~/*", home),
("rm -rf .[!.]*", home),
] {
assert_eq!(
evaluate_destructive_delete_command_in(command, cwd, &env),
Some(DESTRUCTIVE_DELETE_REASON),
"expected deny for: {command}"
);
}
}
#[test]
fn allows_a_glob_inside_a_worktree_subdirectory() {
let env = env_with_home("/Users/agent");
let worktree = Path::new("/repo/.claude/worktrees/agent-x");
assert_eq!(
evaluate_destructive_delete_command_in("rm -rf ./target/*", worktree, &env),
None
);
}
#[test]
fn denies_backslash_and_command_wrapper_bypasses() {
let env = env_with_home("/Users/agent");
for command in ["\\rm -rf /root", "command rm -rf /root"] {
assert_eq!(
evaluate_destructive_delete_command_in(command, Path::new("/repo"), &env),
Some(DESTRUCTIVE_DELETE_REASON),
"expected deny for: {command}"
);
}
}
#[test]
fn denies_wrapper_words_regardless_of_enumeration() {
let env = env_with_home("/Users/agent");
for command in [
"env -i rm -rf /root",
"command -p rm -rf /root",
"nice rm -rf /root",
"exec rm -rf /root",
] {
assert_eq!(
evaluate_destructive_delete_command_in(command, Path::new("/repo"), &env),
Some(DESTRUCTIVE_DELETE_REASON),
"expected deny for: {command}"
);
}
}
#[test]
fn denies_bare_container_roots() {
let env = env_with_home("/Users/agent");
for command in [
"rm -rf /Users",
"rm -rf /Users/*",
"rm -rf /home",
"rm -rf /Volumes",
"rm -rf /etc",
"rm -rf /var",
] {
assert_eq!(
evaluate_destructive_delete_command_in(command, Path::new("/repo"), &env),
Some(DESTRUCTIVE_DELETE_REASON),
"expected deny for: {command}"
);
}
}
#[test]
fn denies_a_linux_users_home_root_like_its_macos_equivalent() {
let env = env_with_home("/Users/agent");
for command in ["rm -rf /home/someoneelse", "rm -rf /home/someoneelse/*"] {
assert_eq!(
evaluate_destructive_delete_command_in(command, Path::new("/repo"), &env),
Some(DESTRUCTIVE_DELETE_REASON),
"expected deny for: {command}"
);
}
}
#[test]
fn allows_an_ordinary_project_under_a_linux_user_home() {
let env = env_with_home("/Users/agent");
assert_eq!(
evaluate_destructive_delete_command_in(
"rm -rf /home/x/project/target",
Path::new("/repo"),
&env
),
None
);
}
#[test]
fn denies_unresolvable_delete_targets() {
let env = env_with_home("/Users/agent");
assert_eq!(
evaluate_destructive_delete_command_in("echo 'rm -rf /root", Path::new("/repo"), &env),
Some(DESTRUCTIVE_DELETE_UNRESOLVED_REASON)
);
assert_eq!(
evaluate_destructive_delete_command_in("rm --", Path::new("/repo"), &env),
Some(DESTRUCTIVE_DELETE_UNRESOLVED_REASON)
);
}
#[test]
fn allows_over_matched_non_verb_mentions_that_resolve_to_no_target() {
let env = env_with_home("/Users/agent");
assert_eq!(
evaluate_destructive_delete_command_in(
"echo 'unrelated unterminated",
Path::new("/repo"),
&env
),
None
);
assert_eq!(
evaluate_destructive_delete_command_in(
"find /repo -name '*.rs'",
Path::new("/repo"),
&env
),
None
);
}
#[test]
fn allows_wrapped_non_destructive_commands() {
let env = env_with_home("/Users/agent");
for command in [
"nice cargo clean",
"time rm -rf ./target",
"env FOO=1 rm stale.txt",
] {
assert_eq!(
evaluate_destructive_delete_command_in(command, Path::new("/repo"), &env),
None,
"expected allow for: {command}"
);
}
}
#[test]
fn glob_parent_resolves_the_directory_a_glob_would_clear() {
assert_eq!(glob_parent(Path::new("/repo/*")), Path::new("/repo"));
assert_eq!(glob_parent(Path::new("/repo/a*b")), Path::new("/repo"));
assert_eq!(
glob_parent(Path::new("/repo/dir/*")),
Path::new("/repo/dir")
);
assert_eq!(glob_parent(Path::new("/repo/.[!.]*")), Path::new("/repo"));
assert_eq!(glob_parent(Path::new("*")), Path::new(""));
assert_eq!(
glob_parent(Path::new("/repo/file.txt")),
Path::new("/repo/file.txt")
);
}
fn class_of(command: &str) -> Option<DeleteTarget> {
classify_destructive_delete_in(command, Path::new("/repo"), &env_with_home("/Users/agent"))
}
#[test]
fn denies_a_delete_inside_a_substitution_body() {
for command in [
"echo \"$(rm -rf /)\"",
"echo `rm -rf ~`",
"x=$(rm -rf \"$HOME\")",
"echo \"$(echo $(rm -rf /))\"",
"cat <(rm -rf /root)",
"echo x > >(rm -rf /)",
"cd /tmp && echo \"$(rm -rf /Users/agent)\"",
"x=$(true; rm -rf /)",
"echo \"$(true && rm -rf ~)\"",
] {
assert!(
class_of(command).is_some_and(DeleteTarget::is_floor),
"{command}"
);
}
}
#[test]
fn allows_a_scratch_delete_inside_a_substitution() {
for command in [
"x=$(rm -f /tmp/scratch/file)",
"echo \"$(git rev-parse HEAD)\"",
"echo `date` \"$(rm -f stale.txt)\"",
] {
assert_eq!(class_of(command), None, "{command}");
}
}
#[test]
fn denies_a_delete_nested_past_the_depth_cap() {
let nest = |levels: usize| {
let mut text = "rm -f /tmp/scratch/file".to_string();
for _ in 0..levels {
text = format!("echo $({text})");
}
text
};
assert_eq!(class_of(&nest(8)), None);
assert_eq!(class_of(&nest(9)), Some(DeleteTarget::Unresolved));
}
#[test]
fn resolves_the_delete_verb_past_wrapper_options() {
assert_eq!(
class_of("nice -n 5 /bin/rm -rf /"),
Some(DeleteTarget::Root)
);
assert_eq!(
class_of("timeout 5 /bin/rm -rf /root"),
Some(DeleteTarget::Root)
);
assert_eq!(
class_of("sudo --bogus rm -f /tmp/x"),
Some(DeleteTarget::Unresolved)
);
assert_eq!(class_of("timeout 30 cargo test -p x"), None);
assert_eq!(class_of("nice -n 10 cargo build"), None);
}
#[test]
fn denies_a_delete_in_a_command_string_behind_a_wrapper() {
for command in [
"timeout 60 bash -c 'rm -rf ~'",
"nice -n 5 sh -c 'rm -rf /'",
"timeout 5 env -S 'rm -rf /'",
"flock /tmp/lock -c 'rm -rf ~'",
] {
assert!(
class_of(command).is_some_and(DeleteTarget::is_floor),
"{command}"
);
}
}
#[test]
fn judges_a_split_body_from_every_directory_seen() {
for command in [
"cd / && x=$(true; rm -rf Users)",
"cd /; x=$(true; rm -rf Users)",
"cd /Users && y=$(true; rm -rf agent)",
] {
assert_eq!(class_of(command), Some(DeleteTarget::Root), "{command}");
}
assert_eq!(class_of("cd /tmp && x=$(true; rm -rf scratch)"), None);
}
#[test]
fn denies_when_the_split_body_work_runs_out() {
let nest = |levels: usize| {
let cds: String = (0..9).map(|n| format!("cd /tmp/d{n}; ")).collect();
let mut text = "rm -f scratch".to_string();
for _ in 0..levels {
text = format!("{cds}x=$(true; {text})");
}
text
};
assert_eq!(class_of(&nest(2)), None);
assert_eq!(class_of(&nest(5)), Some(DeleteTarget::Unresolved));
}
#[test]
fn allows_a_backtick_in_a_quoted_heredoc_body() {
for command in [
"git commit -F - <<'EOF'\nfloor denies `rm -rf ~`\nEOF",
"gh pr create --title t --body \"$(cat <<'EOF'\nthe floor denies `rm -rf ~`\nEOF\n)\"",
] {
assert_eq!(class_of(command), None, "{command}");
}
for command in [
"cat <<EOF\n$(rm -rf /)\nEOF",
"cat <<EOF\n`rm -rf ~`\nEOF",
"bash <<'EOF'\nrm -rf ~\nEOF",
"sudo -s <<'EOF'\nrm -rf ~\nEOF",
"xargs rm -rf <<'EOF'\n/\nEOF",
] {
assert!(
class_of(command).is_some_and(DeleteTarget::is_floor),
"{command}"
);
}
}
#[test]
fn denies_a_single_quoted_substitution_in_an_expanding_body_9155() {
for command in [
"python3 - <<PY\nprint('$(rm -rf /)')\nPY",
"bash <<X\necho '$(rm -rf /)'\nX",
"python3 - <<PY\nprint('`rm -rf /`')\nPY",
"node - <<JS\nconsole.log('$(rm -rf /)')\nJS",
"bash <<X\ncat <<'Y'\n'$(rm -rf ~)'\nY\nX",
] {
assert!(
class_of(command).is_some_and(DeleteTarget::is_floor),
"{command}"
);
}
for command in [
"python3 - <<'PY'\nprint('$(rm -rf /)')\nPY",
"bash <<'X'\necho '$(rm -rf /)'\nX",
"python3 - <<PY\nprint('it is rm -rf / in prose')\nPY",
"python3 - <<PY\nprint('hello')\nPY",
"python3 - <<PY\nprint('\\$(rm -rf /)')\nPY",
] {
assert_eq!(class_of(command), None, "{command}");
}
}
#[test]
fn denies_a_heredoc_nested_in_a_shell_run_body_or_wrapper_9155() {
for command in [
"bash <<'O'\nbash <<I\necho '$(rm -rf /)'\nI\nO",
"cat <<'O' | bash\nbash <<I\necho '$(rm -rf /)'\nI\nO",
"sudo -s <<'O'\ncat <<I\n'$(rm -rf /)'\nI\nO",
"bash -c \"bash <<I\necho '\\$(rm -rf /)'\nI\"",
"bash -c \"cat <<I\n'\\$(rm -rf /)'\nI\"",
"bash <<O\nbash <<I\necho '\\$(rm -rf /)'\nI\nO",
] {
assert!(
class_of(command).is_some_and(DeleteTarget::is_floor),
"{command}"
);
}
for command in [
"bash <<'O'\nbash <<I\necho '$(date)'\nI\nO",
"bash -c \"cat <<I\nhello\nI\"",
"bash <<'O'\nbash <<'I'\necho '$(rm -rf /)'\nI\nO",
] {
assert_eq!(class_of(command), None, "{command}");
}
let nest = |levels: usize| {
let mut text = "cat <<I\n'$(rm -rf /)'\nI".to_string();
for level in 0..levels {
text = format!("bash <<'L{level}'\n{text}\nL{level}");
}
text
};
assert_eq!(class_of(&nest(3)), Some(DeleteTarget::Root));
assert_eq!(class_of(&nest(12)), Some(DeleteTarget::Unresolved));
}
#[test]
fn allows_a_lookup_and_the_bsd_xargs_options() {
for command in [
"command -v rm",
"sudo -l rm -rf /",
"sudo -k rm -f x",
"xargs -J % rm %",
] {
assert_eq!(class_of(command), None, "{command}");
}
assert_eq!(class_of("sudo -k rm -rf /"), Some(DeleteTarget::Root));
}
#[test]
fn denies_a_delete_in_a_command_string_behind_an_unknown_option() {
for command in [
"sudo --bogus bash -c 'r\"\"m -rf ~'",
"timeout 5 env -S 'r\"\"m -rf /'",
] {
assert_eq!(class_of(command), Some(DeleteTarget::Root), "{command}");
}
}
#[test]
fn allows_a_quoted_heredoc_body_fed_to_an_interpreter_7190() {
for command in [
"python3 - <<'PY'\nprint('It\\'s time to find it')\nPY",
"python3 <<\"PY\"\nprint('it's time to find it')\nPY",
"node - <<'JS'\nconsole.log('can't find rm')\nJS\n",
"cat > /tmp/s/x.py <<'EOF'\nassert x, 'it's not found: find it'\nEOF",
"cat >>notes.py <<-'EOF'\n\tprint('won't unlink it')\n\tEOF",
] {
assert_eq!(class_of(command), None, "{command}");
}
}
#[test]
fn keeps_every_captured_or_shell_run_heredoc_body_live_7190() {
for command in [
"eval $(cat <<'PY'\nrm -rf /\nPY\n)",
"eval \"$(cat <<'PY'\nrm -rf /\nPY\n)\"",
"bash <<'EOF'\nrm -rf /\nEOF",
"read x <<'PY'\nrm -rf /\nPY\neval \"$x\"",
"source /dev/stdin <<'PY'\nrm -rf /\nPY",
"cat <<'PY' | \"bash\"\nrm -rf /\nPY",
"cat <<'PY' > x.sh\nrm -rf /\nPY\nsh x.sh",
"mapfile -t a <<'PY'\nrm -rf /\nPY\n\"${a[@]}\"",
"python3 - <<PY\n$(rm -rf /)\nPY",
"X=1 python3 - <<'PY'\nprint('it's find')\nPY",
"python3 -c x <<'PY'\nprint('it's find')\nPY",
"python3 - <<'PY' 2>/dev/null\nprint('it's find')\nPY",
"sudo python3 - <<'PY'\nprint('it's find')\nPY",
"cat > $OUT <<'PY'\nprint('it's find')\nPY",
"cat <<'A' <<'B'\nprint('it's find')\nA\nx\nB",
"rm -rf / ; python3 - <<'PY'\nprint('it's find')\nPY",
] {
assert!(class_of(command).is_some(), "{command}");
}
for command in [
"cat <<'A B'\nhello\nA B\nrm -rf /\nA",
"python3 - <<\"E>F\"\nprint(1)\nE>F\nrm -rf ~\nE",
"cat > x.py <<'E<F'\nx\nE<F\nrm -rf $HOME\nE",
] {
assert_eq!(class_of(command), Some(DeleteTarget::Root), "{command}");
}
}
}