Claude never deletes for good: rm, git clean and find -delete are refused outside temp folders, and Claude uses the Trash instead

A Claude Code mod from Encore Mods. Claude never deletes anything for good.
rm, unlink, rmdir, git clean and find -delete are refused outside temporary folders (/tmp, /var/folders).trash on macOS 14+, Finder with Put Back, or gio trash on Linux), so you can always undo.echo or a file written through a heredoc that mentions rm goes through.rm hidden in $( … ), after &&, ; or sudo./plugin marketplace add benjaminb10/encore-mods
/plugin install trash-guard@encore-mods
Then /reload-plugins or restart Claude Code. Needs Claude Code 2.1.287 or later.
allowed_paths: comma-separated absolute folders where rm stays allowed, such as a build cache. Set it in /plugin, or with /plugin configure trash-guard@encore-mods.
git clone https://github.com/benjaminb10/encore-mods
claude --plugin-dir encore-mods/plugins/trash-guardhooks/register.js 83 lines1// trash-guard: Claude never deletes for good. `rm`, `unlink`, `rmdir`, `git clean` and `find -delete`
2// are refused outside temporary folders, and Claude is told to move things to the Trash instead,
3// so anything can be put back. Text inside quotes and heredocs is ignored: a commit message or a
4// README that mentions `rm` is not an `rm`.
5
6const TEMP = ['/tmp/', '/private/tmp/', '/var/folders/', '/private/var/folders/']
7
8let allowed = [...TEMP]
9
10// Splits a shell command into simple commands (lists of words), without looking inside quotes or
11// heredoc bodies.
12export function splitShell(command) {
13 const src = command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2[ \t]*(?=\n|$)/g, '')
14 const cmds = []
15 let words = [], word = '', quote = null, has = false
16 const endWord = () => { if (has) words.push(word); word = ''; has = false }
17 const endCmd = () => { endWord(); if (words.length) cmds.push(words); words = [] }
18 for (let i = 0; i < src.length; i++) {
19 const c = src[i]
20 if (quote) {
21 if (c === quote) quote = null
22 else if (c === '\\' && quote === '"' && i + 1 < src.length) word += src[++i]
23 else word += c
24 continue
25 }
26 if (c === "'" || c === '"') { quote = c; has = true }
27 else if (c === '\\' && i + 1 < src.length) { word += src[++i]; has = true }
28 else if (c === '#' && !has) { while (i < src.length && src[i] !== '\n') i++; endCmd() }
29 else if (/[;&|\n()`]/.test(c) || (c === '$' && src[i + 1] === '(')) endCmd()
30 else if (/\s/.test(c)) endWord()
31 else { word += c; has = true }
32 }
33 endCmd()
34 return cmds
35}
36
37// Returns what would be deleted for good, or null when the command may run.
38export function checkDelete(command, cwd, roots = allowed) {
39 const inAllowed = (abs) => {
40 const p = abs.endsWith('/') ? abs : abs + '/'
41 return !/\/\.\.(\/|$)/.test(abs) && roots.some((r) => p.startsWith(r))
42 }
43 for (const words of splitShell(command)) {
44 while (words.length && /^(sudo|command|xargs|exec|nohup|time|env|-\S*|\w+=\S*)$/.test(words[0])) words.shift()
45 if (!words.length) continue
46 const cmd = words[0].replace(/^.*\//, '')
47 if (cmd === 'git' && words[1] === 'clean') return 'git clean'
48 if (cmd === 'find' && words.includes('-delete')) return 'find -delete'
49 if (cmd !== 'rm' && cmd !== 'unlink' && cmd !== 'rmdir') continue
50 const targets = words.slice(1).filter((w) => !w.startsWith('-'))
51 if (!targets.length) continue
52 const ok = targets.every((t) => {
53 if (/[$*?{]/.test(t) && !t.startsWith('/')) return false
54 if (t.startsWith('~')) return false
55 return inAllowed(t.startsWith('/') ? t : cwd.replace(/\/$/, '') + '/' + t)
56 })
57 if (!ok) return cmd + ' ' + targets.join(' ')
58 }
59 return null
60}
61
62export function register(on, options = {}) {
63 const extra = String(options.allowed_paths || '')
64 .split(',')
65 .map((p) => p.trim())
66 .filter((p) => p.startsWith('/'))
67 .map((p) => (p.endsWith('/') ? p : p + '/'))
68 allowed = [...TEMP, ...extra]
69
70 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
71 if (typeof e.command !== 'string') return next(e)
72 const bad = checkDelete(e.command, String((await $.session.cwd()) || "/"))
73 if (!bad) return next(e)
74 return {
75 deny:
76 'Refused by trash-guard: nothing is deleted for good here (' + bad + '). ' +
77 'Move the files to the Trash instead, with `trash <paths>` on macOS 14 or later, ' +
78 'or `osascript -e \'tell application "Finder" to delete POSIX file "/absolute/path"\'` (keeps "Put Back"), ' +
79 'or `gio trash <paths>` on Linux. `rm` stays allowed on absolute paths under: ' + allowed.join(', '),
80 }
81 }).catch(() => undefined)
82}
83