use std::path::{Component, Path};
use trusty_mpm::core::project_aliases::main_checkout_root;
use super::{PathEnv, resolve_target_path, split_shell_segments};
use crate::commands::hook_rewrite::first_command_token;
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, ask the PM to run `tm session prune-worktrees --merged-prs --force` — \
`rm -rf` on a worktree directory is never the workaround.";
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<&'static str> {
evaluate_destructive_delete_command_in(command, cwd, &PathEnv::from_process())
}
fn evaluate_destructive_delete_command_in(
command: &str,
cwd: &Path,
env: &PathEnv,
) -> Option<&'static str> {
let mut effective_cwd = cwd.to_path_buf();
for segment in split_shell_segments(command) {
let trimmed = segment.trim();
if trimmed.is_empty() {
continue;
}
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);
}
continue;
}
let Some(argv) = shlex::split(trimmed) else {
if segment_mentions_a_delete_verb(trimmed) {
return Some(DESTRUCTIVE_DELETE_UNRESOLVED_REASON);
}
continue;
};
let Some(verb_idx) = argv
.iter()
.position(|tok| DELETE_VERBS.contains(&tok.strip_prefix('\\').unwrap_or(tok)))
else {
continue;
};
let verb = argv[verb_idx].strip_prefix('\\').unwrap_or(&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(DESTRUCTIVE_DELETE_UNRESOLVED_REASON);
}
let repo_root = main_checkout_root(&effective_cwd);
for target in targets {
let resolved = resolve_target_path(&target, &effective_cwd, env);
if is_denylisted_delete_target(&resolved, repo_root.as_deref(), env) {
return Some(DESTRUCTIVE_DELETE_REASON);
}
}
}
None
}
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 is_denylisted_delete_target(path: &Path, repo_root: Option<&Path>, env: &PathEnv) -> bool {
let path = glob_parent(path);
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;
}
}
if is_user_home_root(path) {
return true;
}
if path.file_name().and_then(|f| f.to_str()) == Some(".git") {
return true;
}
if repo_root.is_some_and(|root| path == root) {
return true;
}
is_worktree_root_or_container(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 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")
);
}
}