Author: Kevin Wells – Tested on: Ubuntu 24.04 LTS (Debian/Ubuntu family)
Applies to human interactive shells (user, sudo
, root). Automation is intentionally left untouched.
Executive summary
If you manage production systems, you need two conflicting things from rm
:
- Safety for humans: protect against accidental
rm -rf <wrong-path>
. - Predictability for machines: cron, systemd, logrotate, backup pruning, CI jobs must remain unchanged.
This guide implements a PATH-level wrapper at /usr/local/bin/rm
that:
- Enforces a single “YES” gate on any recursive delete in interactive shells—
-f
is stripped;--no-preserve-root
is refused. - Falls through to
/bin/rm
automatically whenever there is no TTY (automation, pipelines, remote non-interactive runs). - Works with
sudo
and root without per-user dotfile hacks. - Optionally honors a denylist via
safe-rm
(e.g., protect/
).
This method does not use aliases, nor any shell functions, and it doesn’t damage automation.
Design goals
- Human-safe by default: Any
-r
/-R
delete in an interactive terminal requires typing YES (uppercase). - Automation-neutral: If stdin or stdout is not a TTY, the wrapper immediately
exec
s/bin/rm
with original arguments. - No aliasing: PATH-level wrapper wins across shells; distro aliases are neutralised.
- Sudo-aware:
secure_path
typically places/usr/local/bin
first; the guard applies undersudo
and root. - Minimal friction: One prompt, no spam. Bypass available for deliberate operations.
In scope: fat-fingered paths, glob disasters, “rm -rf
in the wrong pane”, muscle-memory deletes.
Out of scope: code that calls unlink(2)
directly, find … -delete
, someone choosing /bin/rm
or setting RMBYPASS=1
.
Quick start (copy/paste)
Tested on Ubuntu 24.04; adapt paths for other distros as needed.
1) (Optional) Install conveniences
sudo apt-get update
sudo apt-get install -y safe-rm trash-cli
2) (Optional) Tight denylist for safe-rm
Do not add /var/log
or /media
—you will break rotations and backup pruning.
sudo tee /etc/safe-rm.conf >/dev/null <<'EOF'
/
#/bin
#/boot
#/dev
#/etc
#/lib
#/lib64
#/proc
#/root
#/run
#/sbin
#/srv
#/sys
#/usr
#/var
EOF
3) Ensure /usr/local/bin
wins for humans and kill aliases
# Login shells
sudo tee /etc/profile.d/99-rm-wrapper-path.sh >/dev/null <<'EOF'
# Put /usr/local/bin first; remove distro safe-rm shim dir if present
case ":$PATH:" in
*:/usr/share/safe-rm/bin:*)
PATH="${PATH//\/usr\/share\/safe-rm\/bin:/}"
PATH="${PATH//:\/usr\/share\/safe-rm\/bin/}"
;;
esac
case ":$PATH:" in
*:/usr/local/bin:*) : ;;
*) PATH="/usr/local/bin:$PATH" ;;
esac
export PATH
unalias rm 2>/dev/null || true
EOF
sudo chmod 0644 /etc/profile.d/99-rm-wrapper-path.sh
# Non-login interactive shells
sudo tee -a /etc/bash.bashrc >/dev/null <<'EOF'
# --- rm wrapper path discipline ---
case ":$PATH:" in
*:/usr/share/safe-rm/bin:*)
PATH="${PATH//\/usr\/share\/safe-rm\/bin:/}"
PATH="${PATH//:\/usr\/share\/safe-rm\/bin/}"
;;
esac
case ":$PATH:" in
*:/usr/local/bin:*) ;;
*) PATH="/usr/local/bin:$PATH" ;;
esac
export PATH
unalias rm 2>/dev/null || true
# --- end rm wrapper path discipline ---
EOF
4) Install the wrapper at /usr/local/bin/rm
sudo tee /usr/local/bin/rm >/dev/null <<'EOF'
#!/usr/bin/env bash
# Human-safe rm wrapper for interactive shells.
# YES gate for recursive deletes; automation (no TTY) falls through.
set -Eeuo pipefail
REAL_RM="/bin/rm"
# Non-interactive: do not interfere
if [[ ! -t 0 || ! -t 1 ]]; then
exec "$REAL_RM" "$@"
fi
# Explicit bypass
if [[ -n "${RMBYPASS:-}" ]]; then
exec "$REAL_RM" "$@"
fi
has_recursive=0
end=0
filtered=()
paths=()
for arg in "$@"; do
if [[ "$end" -eq 1 ]]; then
paths+=("$arg"); filtered+=("$arg"); continue
fi
case "$arg" in
--) end=1; filtered+=("--");;
-r|-R|--recursive) has_recursive=1; filtered+=("$arg");;
--no-preserve-root) echo "Refusing --no-preserve-root in interactive shells"; exit 2;;
--force) : ;; # drop --force in human shells
-*) [[ "$arg" == *r* || "$arg" == *R* ]] && has_recursive=1
bundle="${arg//f/}" # strip ALL 'f' from short bundles
[[ -n "$bundle" && "$bundle" != "-" ]] && filtered+=("$bundle");;
*) paths+=("$arg"); filtered+=("$arg");;
esac
done
if (( has_recursive )); then
echo "About to recursively delete:"
for p in "${paths[@]}"; do
canon="$(readlink -f -- "$p" 2>/dev/null || echo "$p")"
echo " $canon"
done
read -r -p "Type YES to proceed: " ans
[[ "$ans" == "YES" ]] || { echo "Aborted."; exit 2; }
fi
# Prefer safe-rm denylist if available; else real rm with root protection
if command -v safe-rm >/dev/null 2>&1; then
exec safe-rm --preserve-root "${filtered[@]}"
else
exec "$REAL_RM" --preserve-root "${filtered[@]}"
fi
EOF
sudo chmod 0755 /usr/local/bin/rm
sudo bash -n /usr/local/bin/rm
hash -r; exec bash -l
5) Verify resolution and behaviour
type -a rm
# Expect: /usr/local/bin/rm first
# Interactive (must prompt once)
mkdir -p ~/tmp/x/{a,b}; touch ~/tmp/x/{1,2}
rm -rf ~/tmp/x # prints "Type YES to proceed:" ; type YES
# Sudo (same gate)
sudo mkdir -p /root/tmp/x
sudo rm -rf /root/tmp/x
# Non-interactive (no prompt)
d=$(mktemp -d); touch "$d/f"; rm -rf "$d" </dev/null
[ -d "$d" ] && echo "FAIL" || echo "OK"
Tip: lock the wrapper against accidental edits:
sudo chattr +i /usr/local/bin/rm # remove with: sudo chattr -i /usr/local/bin/rm
How it works (deep dive)
- Interactive detection:
[[ ! -t 0 || ! -t 1 ]]
→ if stdin or stdout is not a TTY, you’re in automation/piped mode; wrapper immediatelyexec
s/bin/rm
. - Argument normalisation:
- Detect recursion:
-r
/-R
/--recursive
. - Strip all
-f
from short bundles (e.g.,-rf
,-frvx
). - Refuse
--no-preserve-root
in interactive contexts. - Keep the rest intact (
--
, long options, paths).
- Detect recursion:
- YES gate: for recursive deletes only. Prompts once, prints canonical targets via
readlink -f
, proceeds only on exact YES. Anything else aborts. - Denylist: if
safe-rm
exists, the wrapper uses it so catastrophic anchors (like/
) are blocked early. We do not block/var/log
or/media
to keep rotations and pruning working. - Sudo pathing: On Ubuntu,
secure_path
is typically/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
, so the wrapper applies undersudo
as well.
Usage discipline
- Normal delete:
rm -rf <path>
→ prompt → type YES. - Bypass deliberately:
/bin/rm -rf <path>
orRMBYPASS=1 rm -rf <path>
. - Scripts/cron/systemd: use
/bin/rm
explicitly or the helper below.
Helper for scripts that “empty” a directory
Replace dangerous patterns like rm -rf "$DIR"/*
with a guarded helper:
sudo tee /usr/local/sbin/emptydir_safe >/dev/null <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
die(){ echo "ERROR: $*" >&2; exit 2; }
DIR="${1:-}"; [[ -n "$DIR" ]] || die "No directory given"
[[ -d "$DIR" ]] || die "Not a directory: $DIR"
# Refuse obviously dangerous targets
case "$(readlink -f -- "$DIR")" in
/|/proc|/sys|/dev|/boot|/etc|/usr|/var|/bin|/sbin|/lib|/lib64|/root)
die "Refusing to operate on $DIR";;
esac
# Delete contents only; stay on the same filesystem
find -P "$DIR" -mindepth 1 -maxdepth 1 -xdev -depth -exec /bin/rm -rf -- {} +
EOF
sudo chmod 0755 /usr/local/sbin/emptydir_safe
Pattern changes:
rm -rf "$TARGET"/*
→emptydir_safe "$TARGET"
rm -rf "$DIR"/{tmp,cache}/*
→emptydir_safe "$DIR/tmp"; emptydir_safe "$DIR/cache"
Operational testing checklist
# Resolution (user, sudo non-login, sudo login)
type -a rm
sudo -n bash -lc 'type -a rm'
sudo -i bash -lc 'type -a rm'
# Expect /usr/local/bin/rm first in all cases
# Interactive rm -rf (user and via sudo) prompts once and requires YES.
# Non-interactive rm -rf ... </dev/null executes without prompting.
Optional: record a fingerprint to detect drift:
sha256sum /usr/local/bin/rm
Troubleshooting
- I still see “rm is aliased to …”
A distro hook re-enabled an alias. Re-apply the PATH/unalias snippet and start a new login shell. sudo
doesn’t prompt
Fixsecure_path
:sudo visudo -f /etc/sudoers.d/10-secure-path # Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
- Cron job broke
Cron is non-interactive; it shouldn’t hit the wrapper. Ensure scripts call/bin/rm
explicitly or useemptydir_safe
. Check for unexpected TTY allocation in the job. - Audit logging?
Add before the finalexec
:logger -t saferm "user=$USER euid=$(id -u) paths=${paths[*]}"
View with:
journalctl -t saferm -n 50
Known limitations: find … -delete
and tools calling unlink(2)
bypass rm
entirely. An operator can always run /bin/rm
on purpose (that is the explicit bypass).
Uninstall / rollback
# Remove immutability if set
sudo chattr -i /usr/local/bin/rm 2>/dev/null || true
# Remove wrapper and PATH tweaks
sudo rm -f /usr/local/bin/rm
sudo rm -f /etc/profile.d/99-rm-wrapper-path.sh
sudo sed -i '/rm wrapper path discipline/,+13d' /etc/bash.bashrc
hash -r
# Optional: remove helper and packages
sudo rm -f /usr/local/sbin/emptydir_safe
sudo apt-get remove -y safe-rm trash-cli # if no longer desired
Change history
- v1.1 (this article): PATH-level wrapper; single YES gate on recursive deletes; strip
-f
; refuse--no-preserve-root
; automation bypass by TTY detection; sudo-aware; helper for scripts; comprehensive rollback. - v1.0: Alias-based approach (+
safe-rm
), initial denylist,emptydir_safe
concept.