Safer rm on Linux: a Practical Ring-Fence that Doesn’t Break Automation

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 execs /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 under sudo 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 immediately execs /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).
  • 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 under sudo as well.

Usage discipline

  • Normal delete: rm -rf <path> → prompt → type YES.
  • Bypass deliberately: /bin/rm -rf <path> or RMBYPASS=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
    Fix secure_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 use emptydir_safe. Check for unexpected TTY allocation in the job.
  • Audit logging?
    Add before the final exec:

    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.

© Kevin Wells. You are free to adapt this for your environment; test before production rollout.