這篇是 2026 年 9 月 2 日補寫的(排程斷了幾天,回頭把日期補齊)。文中對 better-rm 的描述以撰寫當日的專案內容為準。

裝了 better-rm,coding agent 亂下 rm -rf 是不是就一起管到了?

答案是不會。而且照這個專案的安裝方式,你很可能裝完之後以為管到了。這是我看完原始碼之後覺得最該先講的一件事,其他優點都排在它後面。

better-rm 是 Will 保哥(doggy8088)寫的 rm 替代方案,MIT 授權,repo description 的定位就一句話:「給你一個更好、更安全的 rm 命令」。刪掉的檔案不會消失,搬進垃圾桶,之後救得回來。聽起來是那種裝了不虧的小工具。

有意思的是,這支叫 better-rm 的程式,主程式從頭到尾一次 rm 都沒呼叫過。817 行的 Bash 裡,真正執行「刪除」的只有一行 mv。唯一出現原生 rm 的地方,是還原時覆蓋既有檔案。

一個 repo,其實是兩個東西

它有兩個安裝腳本,對應兩個獨立的產品。

一個是 better-rm 本身,817 行 Bash,攔的是**你在終端機打的 rm**,做法是刪了還救得回來,機制是 shell alias,依賴 git 跟 bash。另一個是 hooks/protect-important-paths.js,316 行 Node,攔的是 AI agent 要送出的 shell 指令,做法是根本不讓它刪,機制是 PreToolUse hook,依賴 node

這兩個預設就是分開的,README 自己寫得很清楚:「install.shinstall-hooks.sh 是分開的步驟,不會在安裝 rm 別名時自動修改 Agent 設定。」

所以第一個判準很簡單:你想解的是哪一個問題。如果你怕的是自己手滑,裝 install.sh 就夠了。如果你怕的是 agent,那條路完全在另一個腳本裡,而且沒裝就是沒有。

順帶一提,GitHub 把這個 repo 的語言標成 HTML。那是被 3,411 行的官網單頁拉走的,實際核心是 Bash 加 Node.js,沒有任何 Go、Rust 或 Python。

刪除變成搬家

垃圾桶預設在 ~/.Trash,而且在裡面完整重建原始的絕對路徑,再於檔名尾端接上時間戳與雜湊。研究這支工具時在沙箱裡實際跑過一輪:

1
2
3
4
5
$ better-rm -v a.txt
已移除 'a.txt' (moved to trash: .../lab/trash/.../lab/work/a.txt__20260826_193803_541771000__764efa883dda1e11db47671c4a3bbd9e)

$ better-rm -v --restore a.txt
已還原 'a.txt' 到目前資料夾 (restored to: .../lab/work/a.txt)

時間戳帶到奈秒(date +%Y%m%d_%H%M%S_%N),避免同一秒連刪撞名。雜湊一般檔案用 md5sum,目錄則是把整包內容算成一個 hash。還原靠一份刪除日誌,放在 ~/.local/state/better-rm/deletion.log(吃 XDG_STATE_HOME),欄位是 TIMESTAMP | ORIGINAL_PATH | TRASH_PATH | HASH | FILE_TYPE。目錄跟日誌都用 umask 077 建立,垃圾桶裡的東西本來就不該讓別人讀,這個細節做得不錯。

有一個地方名字會騙人。--restore 是還原到你目前所在的資料夾,不是原本的位置;它從日誌尾端往前找,救回來的是最後一次刪掉的那個。README 也是這樣寫的,只是「restore」這個字讓人下意識以為會放回原位。

alias 只擋鍵盤

安裝腳本做的事,本質上就是往你的 rc 檔加一行:

1
alias rm='$HOME/.better-rm/better-rm'

它沒有覆蓋 /bin/rm,也沒有在 PATH 前面塞一支叫 rm 的執行檔。而 alias 只在互動式 shell 展開。bash 的 man page 在 ALIASES 段寫得直白:

Aliases are not expanded when the shell is not interactive, unless the expand_aliases shell option is set using shopt.

zsh 的結果一樣,但原因不同:zsh 非互動時展開 alias,可是它根本不會讀 .zshrc,而 alias 就寫在那裡。

把涵蓋範圍攤開來看,差異很清楚。互動 shell 裡打 rm,alias 攔得到;非互動腳本裡的 rmsh -c 'rm foo'find … -exec rm {} \;xargs rm,這四種 alias 全部穿透,改成 PATH shim 才攔得到。至於 /bin/rm foo 這種寫死絕對路徑的,兩種都穿透。

一句話記住:alias 擋鍵盤,PATH shim 擋腳本,兩者都擋不住寫死 /bin/rm 的程式碼。沒有任何純使用者層的工具攔得住 unlink() 這個系統呼叫本身。

然後你回頭看那份清單,會發現 cron、CI、Makefile、sh -c,以及絕大多數 AI coding agent 的 Bash 工具,全部落在非互動 shell 那一欄。

一個真實的引號事故

上面那件事不是理論推演。anthropics/claude-code 有一則 issue #82165(2026-07-29,當時仍為 open):agent 想清空快取,組出來的指令長這樣。

1
2
find /var/www/pawtucket/app/tmp -maxdepth 1 -type d -name "*ache*" \
-exec sudo -u www-data sh -c "rm -rf \"$1\"/* 2>/dev/null" _ {} \;

回報者的根因分析逐字:

The sh -c body was double-quoted, so the outer shell expanded $1 (unset -> empty) before sh received it. The command that actually executed was rm -rf ""/* — i.e. rm -rf /*

這則值得看,是因為它踩的是 shell 引號展開的經典陷阱,agent 的意圖完全正確、指令乍看也合理,照樣炸。而且它跑在 sh -c 裡,alias 型的保護在這裡一點作用都沒有。

類似的一手回報還有幾則:google-gemini/gemini-cli#1504(2025-06-25,模型自己在對話裡承認要 rm -rf docs/journeys)、claude-code 的 #85530(subagent 產生標示為「no-op placeholder」的刪除指令,通過人工權限審查後刪掉整個 repo)、#88462(auto mode 下對 $HOME 執行破壞性指令,指令藏在 assistant 自己寫的腳本裡)。

這些都是使用者自行回報的 GitHub issue,不是廠商確認的事故報告。它們證明的是「這類事故確實被回報過」,不是「官方承認發生過某件事」。另外我沒有找到任何「better-rm 因為某次 AI 事故而爆紅」的來源,那種敘事不要信,包括我如果這樣寫你也不要信。

hook 那半做得比想像中細

protect-important-paths.js 是一支零依賴的 Node 程式,從 stdin 讀 JSON、往 stdout 吐 JSON。它自己手寫了一個 shell 詞法分析器,把 agent 想跑的整條指令拆開,找出所有 rmrmdir 的目標,逐一比對保護規則,命中就回 deny。

它認得 sudocommandbuiltinnoglobenv 這些包裝器並往後找真正的執行檔;認得 ~~/$HOME${HOME} 四種家目錄寫法;同一支程式同時服務 9 種 agent(Claude Code、Codex、Cursor、Copilot CLI、Antigravity、Qoder、Pi、OpenCode、Grok)的 5 種不同輸出格式,其中 opencode 多一層 TypeScript 插件包裝、底層仍是同一支。JSON 解析失敗一律拒絕,是 fail-closed。連 rm -rf .* 都擋,它會把 glob 轉成正規表示式,測試這個 pattern 有沒有可能配到 .git,會就擋。

但它只認 rmrmdir。實測放行的兩個例子:find / -delete 放行,mv /etc /dev/null 放行。

這點 README 自己也承認:

這些 hooks 是額外防護欄,不是作業系統層級的安全邊界。目前只檢查 coding agent 透過已支援 shell 工具送出的 rmrmdir 命令;無法防止代理未攔截的工具路徑、停用 hooks 後的操作,或使用者在代理外直接執行的命令。

保護清單裡少了什麼

主程式的 PROTECTED_DIRS 只有這些(外加 $HOME 的兩種寫法):

text
1
2
/  /bin  /boot  /dev  /etc  /home  /lib  /lib64  /mnt  /opt
/proc /root /sbin /sys /usr /var

清單裡沒有的:/Applications/Library/System/Users/srv/tmp/media/run。macOS 使用者看到這裡應該要停一下,那幾個 macOS 真正的系統目錄,一個都不在裡面。專案目錄的保護只有 .git 一項(四種 pattern 變體);/mnt 的第一層掛載根(WSL 的 /mnt/c)也受保護,但掛載點裡面的東西可以刪。

兩個容易寫錯的點。-f 繞不過保護清單,沙箱裡實測 better-rm -rf <受保護目錄> 得到「拒絕刪除受保護的目錄」、exit 1,原因是保護檢查被刻意放在「檔案存不存在」之前。另一個是 BETTER_RM_PROTECTED_DIRS 這個環境變數只有 hook 認得,主程式完全不吃(在 better-rm 裡 grep 命中數是 0,hook 裡是 1),所以想自訂主程式的保護清單,目前做不到。

支援的 flag 只有 -r/-R/--recursive-f/--force-i-I-v/--verbose--help--version--restore。不支援 --(end-of-options),實測 better-rm -- a.txt 直接報「無效的選項 – ‘-‘」。也沒有 dry-run、沒有容量門檻、垃圾桶不會自動清。

兩個 bug 的根因都是順序

這個專案最有教育意義的地方,是它修過的兩個漏洞根因一模一樣:程式碼的先後順序。

第一個是 -f 曾經真的可以繞過保護清單(2026-07-21 修)。commit message 逐字:「原有 move_to_trash 流程會先判斷路徑是否存在,再執行受保護路徑檢查。當使用 -f 強制模式且目標路徑不存在時,函式會依照 rm 相容行為提前回傳成功,導致後續的安全規則完全沒有執行。」修法是把保護檢查整塊往上搬,diff 是 +11/−11,同一段程式碼換個位置而已。這個 bug 在 macOS 上才容易顯現,commit message 的原話是「此順序在 Linux 或 WSL 上不容易顯現,因為 /mnt 通常存在;但 macOS 預設沒有 /mnt」。

第二個是換行符號可以繞過 hook(2026-08-01,由外部貢獻者修)。diff 只是把兩個 else if 對調:

1
2
3
4
5
6
7
-    } else if (/\s/.test(char)) {
- if (word) words.push(word), word = '';
} else if (';&|\n'.includes(char)) {
if (word) words.push(word), word = '';
words.push(char);
+ } else if (/\s/.test(char)) {
+ if (word) words.push(word), word = '';

\n 同時符合「空白」和「指令分隔符」,而空白那條分支排在前面先命中,於是換行被當成一般空白吃掉、沒有被切成分隔 token。後果照 CHANGELOG 逐字:「導致多行指令(如 echo ok\nrm -rf /usr)繞過受保護路徑檢查的安全漏洞」。四行位置對調,等於一個真實的防護繞過。

什麼情況下它反而是壞選擇

磁碟空間吃緊的機器不要裝。搬到垃圾桶不釋放空間,管理員想 rm 大檔救空間,結果空間沒回來、服務照樣掛,而且他以為已經處理了,排錯方向直接被誤導。容器與 CI runner 是同一個問題的另一面:生命週期本來就短,垃圾桶只是佔 layer 空間,還會讓「清空間」那個步驟靜默失效。

alias 寫在個人 rc 檔,所以保護是 per-user、不是 per-machine,團隊共用的機器要先想清楚這一點。更危險的是反向:你養成「反正救得回來」的習慣之後,換到沒裝的正式機打同一個指令,那就是真的永久刪除。

已經有檔案系統快照的話,價值也不高。ZFS、Btrfs、Time Machine 的還原保證更強,連覆寫和 truncate 都救得回,垃圾桶只救刪除。

有法遵刪除要求的場景則是明確不該裝。垃圾桶會留存本該被刪除的資料,GDPR 刪除權、保存期限到期這些情境,留一份在垃圾桶等於沒刪。

最後一種是「只想防呆、不想改 rm 語義」。更保守的路是不要碰 rm,改用一個獨立指令養成習慣:macOS 從 14.0(Sonoma)起內建 /usr/bin/trash,零安裝零相容性風險,缺點是它沒有還原用的子命令,要還原得開 Finder。

還有一條限制值得單獨拉出來。better-rm 的安全性建立在「mv 是原子的」這個前提上,而這只在同一個檔案系統內成立。垃圾桶在 ~/.Trash,被刪的檔案若在外接碟、Docker volume、NFS 上,mv 就退化成「完整複製再刪來源」(這是 POSIX mv 的標準語義,研究時手上沒有第二個可寫的檔案系統可以實跑驗證這條路徑)。原始碼對此完全沒有處理:沒有邊界偵測、沒有大小上限、沒有進度顯示。用假 mv 模擬複製到一半失敗,結果是垃圾桶留下一個不完整的殘檔,程式不會回頭刪它、日誌也不會記它,既救不回也不會被清掉,只會安靜佔磁碟。

另外 --restore 在搬回檔案之前,會先對目的地執行 /bin/rm -rf 真刪既有檔案;若接著的 mv 失敗,舊檔已經沒了、新檔也沒到位。這是整份程式碼裡唯一可能真的弄丟資料的地方。(這條是讀碼推得的,我沒有實跑重現這個組合。)

裝法與版本

沒有 npm、沒有 Homebrew、沒有 apt,repo 裡連 package.json 都沒有。

1
2
3
4
5
6
7
# 產品 A:rm 替身
curl -sSL https://raw.githubusercontent.com/doggy8088/better-rm/main/install.sh | bash
source ~/.zshrc # 或 ~/.bashrc
rm --version # 應該印 better-rm 1.6.0

# 產品 B:agent hook,-a 換成其他 agent 名稱即可
curl -sSL https://github.com/doggy8088/better-rm/releases/latest/download/install-hooks.sh | bash -s -- -a claude

系統需求 README 寫 Bash 4.0+,但用 macOS 內建的 bash 3.2.57 跑完整測試套件是 41/41 全過,實務上 3.2 可用。hook 端需要 node,安裝器會先擋:9 條安裝路徑裡有 8 條(claude、cursor、codex、copilot、antigravity、qoder、pi、grok)第一件事就是 if ! command_exists node,找不到就報「找不到 node 命令,hooks 需要 Node.js」並中止;只有 opencode 那條例外,因為它裝的是 opencode 自己 runtime 的 TypeScript 插件。要繞回原生 rm:/bin/rm file\rm file

同類工具的維護狀態(2026-08-26 用 gh api 實查):andreafrancia/trash-cli(Python,4,556 stars,四天前還有 push,最活躍)、sindresorhus/trash-cli(Node,跨平台)、rip2(Rust,原版 nivekuil/rip 已停滯兩年多,要裝就裝 fork)、safe-rm(Rust,走拒絕而非垃圾桶,master 停在 2022-06)。better-rm 相對這些工具的差異點,就是 hook 那一層,其他工具都沒有。

我的建議

裝 hook,不要裝 alias。

如果你每天在跟 coding agent 工作,install-hooks.sh 那半解的是一個你真的擋不住的問題,成本是一支零依賴的 Node 腳本,值得。而 alias 那半解的是「你自己手滑」,它擋不到腳本、擋不到 agent,卻會在你腦裡種下「反正救得回來」的習慣,那個習慣哪天跟你一起走進正式機,代價比它省下來的多。

會讓我改變這個結論的條件有兩個。一個是你的機器上完全沒有 agent、也沒有快照機制,那 alias 那半是你唯一的安全網,裝了比沒裝好。另一個是保護清單如果哪天把 macOS 那幾個目錄補進去、或是 BETTER_RM_PROTECTED_DIRS 主程式也開始吃,alias 那半的實用性會整個改觀。

順便講個有點反諷的數字。這個 repo 82 個 commit、4 位 contributor(3 個人加 1 個 bot),其中 27 個 commit 的作者是 copilot-swe-agent[bot]。一個防 AI 誤刪的工具,三分之一是 AI 寫的。開發節奏也很有戲:2025 年 12 月衝了 35 個 commit,接著 2026 年 1 到 6 月整整半年 0 commit,7 月才復活,而復活那波做的正是 coding agent hooks。


相關連結

本文事實以 2026-08-26 的 main 分支(HEAD 40e1e2f)原始碼、git 歷史與 gh api 機器可讀元資料為準,版本 v1.6.0。文中的實測輸出來自研究當時在沙箱環境實際執行,其中測試套件(41/41、hook 158 項)、-- 不支援、-f 無法繞過保護、刪除與還原來回,另外複跑過一次確認。