better-rm 該裝哪一半:alias 擋得住你的手,擋不住 agent
補寫於 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,其實是兩個東西
它有兩個安裝腳本,對應兩個獨立的產品。
| 產品 A | 產品 B | |
|---|---|---|
| 是什麼 | better-rm 本身 |
hooks/protect-important-paths.js |
| 規模與語言 | 817 行 Bash | 316 行 Node |
| 攔的是 | 你在終端機打的 rm |
AI agent 要送出的 shell 指令 |
| 做法 | 刪了還救得回來 | 根本不讓它刪 |
| 機制 | shell alias | PreToolUse hook |
| 依賴 | git 跟 bash |
node |
這兩個預設就是分開的,README 自己寫得很清楚:「install.sh 與 install-hooks.sh 是分開的步驟,不會在安裝 rm 別名時自動修改 Agent 設定。」
所以第一個判準很簡單:你想解的是哪一個問題。如果你怕的是自己手滑,裝 install.sh 就夠了。如果你怕的是 agent,那條路完全在另一個腳本裡,而且沒裝就是沒有。
順帶一提,GitHub 把這個 repo 的語言標成 HTML。那是被 3,411 行的官網單頁拉走的,實際核心是 Bash 加 Node.js,沒有任何 Go、Rust 或 Python。
刪除變成搬家
垃圾桶預設在 ~/.Trash,而且在裡面完整重建原始的絕對路徑,再於檔名尾端接上時間戳與雜湊。研究這支工具時在沙箱裡實際跑過一輪:
1 | $ better-rm -v 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_aliasesshell option is set using shopt.
zsh 的結果一樣,但原因不同:zsh 非互動時會展開 alias,可是它根本不會讀 .zshrc,而 alias 就寫在那裡。
把涵蓋範圍攤開來看,差異很清楚。中間那四種,改成 PATH shim 才攔得到;最後那種寫死絕對路徑的,兩種都穿透。
怎麼呼叫到 rm |
alias |
|---|---|
互動 shell 裡打 rm |
攔得到 |
非互動腳本裡的 rm |
穿透 |
sh -c 'rm foo' |
穿透 |
find … -exec rm {} \; |
穿透 |
xargs rm |
穿透 |
/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 | find /var/www/pawtucket/app/tmp -maxdepth 1 -type d -name "*ache*" \ |
回報者的根因分析逐字:
The
sh -cbody was double-quoted, so the outer shell expanded$1(unset -> empty) beforeshreceived it. The command that actually executed wasrm -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 想跑的整條指令拆開,找出所有 rm 與 rmdir 的目標,逐一比對保護規則,命中就回 deny。
它認得 sudo、command、builtin、noglob、env 這些包裝器並往後找真正的執行檔;認得 ~、~/、$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,會就擋。
但它只認 rm 和 rmdir。實測放行的兩個例子:find / -delete 放行,mv /etc /dev/null 放行。
這點 README 自己也承認:
這些 hooks 是額外防護欄,不是作業系統層級的安全邊界。目前只檢查 coding agent 透過已支援 shell 工具送出的
rm與rmdir命令;無法防止代理未攔截的工具路徑、停用 hooks 後的操作,或使用者在代理外直接執行的命令。
保護清單裡少了什麼
主程式的 PROTECTED_DIRS 只有這些(外加 $HOME 的兩種寫法):
1 | / /bin /boot /dev /etc /home /lib /lib64 /mnt /opt |
清單裡沒有的:/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 | - } else if (/\s/.test(char)) { |
\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 | # 產品 A:rm 替身 |
系統需求 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。
相關連結
- GitHub:doggy8088/better-rm
- 官網:better-rm.gh.miniasp.com
本文事實以 2026-08-26 的
main分支(HEAD40e1e2f)原始碼、git 歷史與gh api機器可讀元資料為準,版本 v1.6.0。文中的實測輸出來自研究當時在沙箱環境實際執行,其中測試套件(41/41、hook 158 項)、--不支援、-f無法繞過保護、刪除與還原來回,另外複跑過一次確認。




























































































































































































