Stops Claude before it deletes or overwrites files: a side panel lists every folder and file at stake, with Cancel and Allow buttons

[!IMPORTANT] delete-guard does not stop scripts that delete files. It only recognises delete commands that Claude types directly into its shell (
rm,git clean,find -delete, …). If Claude writes a script (Python, shell, Node, …) that deletes files, or runs an existing one, the panel does not open and the files are deleted without asking. Treat the mod as a safety net for everyday commands, not as protection against data loss.
A Claude Code mod that stops Claude before it deletes files, or overwrites or empties files that hold something. A side panel lists every folder and file the command would remove or change, with Cancel and Allow buttons underneath. Nothing happens until you press one of them.
Delete Guard
Claude wants to delete 1 folder (2 files inside), 1 file
in ~/projects/analysis
$ rm -rf junk keep.txt nothere.log
Folders (1)
▸ junk/
2 files, 1 folder, 4 KB
a.txt
sub/
sub/b.txt
Files (1)
• keep.txt 5 B
Not there (nothing to delete) (1)
nothere.log does not exist
[ Cancel ] [ Allow ]
In a Claude Code terminal session:
/plugin install delete-guard --marketplace vienna-bio-center/claude-vbc-mods
Answer y to add the marketplace, then pick the user scope. The mod is active right away and in every session you start afterwards — including the desktop app's Code tab. (The install command itself only works in the terminal.)
Nothing to start. Whenever Claude runs a shell command that deletes or overwrites something, the panel opens on its own and the command waits for your answer, however long that takes.
In the terminal: Tab moves between the buttons, Enter presses one, n is Cancel. A mouse click works too.
If the panel is closed while a delete waits, /delete-guard opens it again.
Deleting — always asked about:
| Command | What the panel lists |
|---|---|
rm, rmdir, unlink, shred, trash, gio trash, rimraf | the named paths, with wildcards (*.log), {a,b}, {01..10} and ~ expanded; for a folder its size, file count and the first few entries |
truncate (not when it only grows a file: -s +N) | the named files, each with what happens to it (→ emptied, the file stays) |
git rm (not --cached) | the named paths |
git clean | exactly what git clean -n (dry run) reports |
find … -delete, find … -exec rm … | the matches of the same find, with the delete swapped for -print |
xargs rm, rsync --delete/--del, gio trash --empty, trash-empty, dd of=/dev/…, git stash drop/clear, git worktree remove --force | a note that the targets can't be known beforehand |
Overwriting and throwing away changes — asked about only when something is at stake:
| Command | Asked when … | |
|---|---|---|
> file, `>\ | file, &> file (not >>`) | the file exists and is not empty |
cp, mv, install (not with -n, -i, -b), ln -f | the file they write to exists | |
tee (not -a), dd of=file | the file exists (for tee: and is not empty) | |
git reset --hard, git restore, git checkout <file>, git checkout -- …, git checkout -f, git switch --discard-changes | there are uncommitted changes in the files it touches; git reset --hard <commit> always |
Files in /tmp, $TMPDIR and /dev, and targets named by a variable (> $LOG), are not asked about.
It also looks inside cmd1 && cmd2, ;, pipes, sudo …, env …, command …, bash -c "…", npx …/npm exec …, $( … ), find -exec … and xargs …, and follows a cd (or env -C) earlier in the same command. Paths that depend on a variable (rm $OUT/x) are listed as known only when it runs.
rm -r build && npm install), overwrites rather than deletes (cp, >, git restore), or might run something other than the plain command: a variable set in front (PATH=… rm), a program from a folder (./rm), a package runner (npx rimraf), git options such as git -c …, or a login shell (bash -lc). Deny rules in your settings always win.find … -print, git clean -n, git status) leave out the command's -c … and other global git flags, switch off core.fsmonitor, and run only in this session's own repository. For another repository the panel says the targets can't be listed beforehand.claude -p and other runs where nobody can answer: deletes are refused.sleep process (PowerShell Start-Sleep on Windows) that ends as soon as you answer. This is how a mod can wait longer than Claude Code's 10-second limit for mods.claude plugin validate <path-to-this-folder>
claude plugin test <path-to-this-folder>
To run a working copy instead of the installed version, start Claude Code with claude --plugin-dir <path-to-this-folder>.
MIT © 2026 Vienna BioCenter
hooks/register.tsx 541 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry, Request } from '../types'
5import {
6 bytes,
7 expandBraces,
8 findDeletes,
9 hasGlob,
10 joinPath,
11 segmentRegex,
12 showPath,
13 tilde,
14 unescape,
15} from './parse'
16import type { Found } from './parse'
17
18type $ = EngineInterface
19type Answer = 'allow' | 'cancel'
20
21const PANE = 'delete-guard'
22const TITLE = 'Delete Guard'
23const queue = atom({ plugin: 'delete-guard', key: 'queue' } as const, [])
24
25/** Entries listed per command, folder entries counted per folder, paths shown inside a folder. */
26const MAX_ENTRIES = 300
27const WALK_LIMIT = 5000
28const SAMPLE = 5
29
30// The hooks waiting for an answer, by request id. A reload starts it empty and
31// `session.start` clears the queue to match.
32const waiting = new Map<string, (answer: Answer) => void>()
33// Calls the person allowed that delete and nothing else: the engine's own
34// "allow this command?" question is answered for them (see `tool.check`).
35const approved = new Set<string>()
36
37const blank = (path: string): Entry => ({
38 path,
39 kind: 'missing',
40 size: 0,
41 files: 0,
42 dirs: 0,
43 isPartial: false,
44 sample: [],
45 note: '',
46})
47
48/** Counts what lies under a folder, breadth first, so the sample shows its top level. */
49const walk = async ($: $, root: string) => {
50 let files = 0
51 let dirs = 0
52 let size = 0
53 let seen = 0
54 const sample: string[] = []
55 const todo = ['']
56 while (todo.length > 0) {
57 const rel = todo.shift()!
58 const list = await $.fs.list(rel === '' ? root : `${root}/${rel}`).catch(() => [])
59 for (const entry of [...list].sort((a, b) => a.name.localeCompare(b.name))) {
60 const path = rel === '' ? entry.name : `${rel}/${entry.name}`
61 if (++seen > WALK_LIMIT) return { files, dirs, size, sample, isPartial: true }
62 if (entry.kind === 'dir') {
63 dirs++
64 todo.push(path)
65 } else {
66 files++
67 size += entry.size
68 }
69 if (sample.length < SAMPLE) sample.push(entry.kind === 'dir' ? `${path}/` : path)
70 }
71 }
72 return { files, dirs, size, sample, isPartial: false }
73}
74
75const describe = async ($: $, abs: string, cwd: string, home: string, isDeep: boolean): Promise<Entry> => {
76 const entry = blank(showPath(abs, cwd, home))
77 const stat = await $.fs.stat(abs).catch(() => undefined)
78 if (stat === undefined) return { ...entry, note: 'does not exist' }
79 if (stat.isLink) return { ...entry, kind: 'link', note: 'a link: only the link goes, not what it points to' }
80 if (stat.kind === 'dir') return { ...entry, kind: 'dir', ...(isDeep ? await walk($, abs) : {}) }
81 return { ...entry, kind: 'file', size: stat.size }
82}
83
84/** The paths a shell word names: `~`, braces and globs expanded as bash would; `null` for none, `too-many` past the limit. */
85const expand = async ($: $, pattern: string, base: string, home: string): Promise<string[] | null | 'too-many'> => {
86 const out: string[] = []
87 const names = expandBraces(pattern)
88 if (names === null) return 'too-many'
89 for (let p of names) {
90 if (p === '~' || p.startsWith('~/')) p = home.replace(/[\\*?[\]{}~]/g, '\\$&') + p.slice(1)
91 if (!hasGlob(p)) {
92 out.push(joinPath(base, unescape(p)))
93 continue
94 }
95 const segments = p.split('/').filter(s => s !== '')
96 let found = [p.startsWith('/') ? '/' : base]
97 for (const segment of segments) {
98 if (!hasGlob(segment)) {
99 found = found.map(d => joinPath(d, unescape(segment)))
100 continue
101 }
102 const re = segmentRegex(segment)
103 const next: string[] = []
104 for (const dir of found) {
105 const list = await $.fs.list(dir).catch(() => [])
106 for (const entry of list) if (re.test(entry.name)) next.push(joinPath(dir, entry.name))
107 }
108 found = next.sort()
109 }
110 const exists = await Promise.all(found.map(f => $.fs.exists(f).catch(() => false)))
111 out.push(...found.filter((_, i) => exists[i]))
112 }
113 return out.length > 0 ? out : null
114}
115
116/** Folders where overwriting a file is never asked about: scratch space and devices. */
117const scratch = async ($: $) => {
118 const tmp = ((await $.env.get('TMPDIR')) ?? '').replace(/\/+$/, '')
119 return ['/tmp', '/private/tmp', '/dev', ...(tmp === '' ? [] : [tmp, `/private${tmp}`])]
120}
121
122/** Parts that are asked about even when nothing they name exists right now. */
123export const isAlwaysAsked = (f: Found) => (f.kind === 'overwrite' ? false : f.kind === 'git-discard' ? f.note !== undefined : true)
124
125const baseName = (s: string) => s.slice(s.lastIndexOf('/') + 1)
126
127/** Lists everything the found parts of a command would delete, cut, overwrite or throw away. */
128export const resolve = async ($: $, found: Found[], cwd: string, home: string) => {
129 const entries: Entry[] = []
130 const notes: string[] = []
131 const seen = new Set<string>()
132 const skip = await scratch($)
133 const isScratch = (abs: string) => skip.some(d => abs === d || abs.startsWith(`${d}/`))
134 let skipped = 0
135 const add = async (abs: string, isDeep: boolean, extra: Pick<Entry, 'action' | 'change'> = {}) => {
136 if (seen.has(abs)) return
137 seen.add(abs)
138 if (entries.length >= MAX_ENTRIES) skipped++
139 else entries.push({ ...(await describe($, abs, cwd, home, isDeep)), ...extra })
140 }
141 /** The folder a part runs in; `null` with the reason when that can't be known beforehand. */
142 const dirOf = async (f: { cwd: string[]; isCwdKnown: boolean }): Promise<{ dir: string | null; why: string }> => {
143 if (!f.isCwdKnown) return { dir: null, why: 'folder unknown: the command changes folder first' }
144 const dir = f.cwd.reduce((d, seg) => joinPath(d, seg === '~' || seg.startsWith('~/') ? home + seg.slice(1) : seg), cwd)
145 if (f.cwd.length > 0 && !(await $.fs.exists(dir).catch(() => false)))
146 return { dir: null, why: `folder unknown: ${tilde(dir, home)} does not exist now` }
147 return { dir, why: '' }
148 }
149 const isRelative = (w: { text: string; pattern: string }) => !w.text.startsWith('/') && !w.pattern.startsWith('~')
150 // a git dry run only in the session's own repository: another one's config (a planted filter) would run before anyone answered
151 const tops = new Map<string, Promise<string | null>>()
152 const topOf = (dir: string, argv: string[]) => {
153 if (!tops.has(dir))
154 tops.set(
155 dir,
156 $.process.run(argv, { cwd: dir, timeoutMs: 15_000 }).then(
157 r => (r.exitCode === 0 ? r.stdout.trim() : null),
158 () => null,
159 ),
160 )
161 return tops.get(dir)!
162 }
163 const isOwnRepo = async (dir: string, argv: string[]) => {
164 const [top, own] = await Promise.all([topOf(dir, argv), topOf(cwd, argv)])
165 return top !== null && top === own
166 }
167
168 for (const f of found) {
169 if (f.kind === 'opaque') {
170 notes.push(`${f.text}: ${f.why}, so it can't be listed beforehand`)
171 continue
172 }
173 const { dir, why } = await dirOf(f)
174 if (f.kind === 'paths') {
175 const extra: Pick<Entry, 'action' | 'change'> = f.change === undefined ? {} : { action: 'truncate', change: f.change }
176 for (const word of f.words) {
177 if (word.isDynamic || (dir === null && isRelative(word))) {
178 entries.push({ ...blank(word.text), ...extra, kind: 'unknown', note: word.isDynamic ? 'decided only when the command runs' : why })
179 continue
180 }
181 const paths = await expand($, word.pattern, dir ?? cwd, home)
182 if (paths === 'too-many') entries.push({ ...blank(word.text), ...extra, kind: 'unknown', note: 'too many names to list' })
183 else if (paths === null) entries.push({ ...blank(word.text), ...extra, note: 'matches nothing' })
184 else for (const p of paths) await add(p, f.change === undefined, extra)
185 }
186 continue
187 }
188 if (f.kind === 'overwrite') {
189 // only what exists now is at stake; a name known only when it runs is not asked about
190 if (f.dest.isDynamic || (dir === null && isRelative(f.dest))) continue
191 const dests = await expand($, f.dest.pattern, dir ?? cwd, home)
192 const change = { action: 'overwrite' as const, change: `overwritten by ${f.tool === '>' ? 'a > redirect' : f.tool}` }
193 for (const dest of Array.isArray(dests) ? dests : []) {
194 if (isScratch(dest)) continue
195 const stat = await $.fs.stat(dest).catch(() => undefined)
196 if (stat === undefined) continue
197 const isInto = f.intoDir ?? stat.kind === 'dir'
198 if (!isInto) {
199 if (stat.kind === 'file' && !(f.isOnlyNonEmpty && stat.size === 0)) await add(dest, false, change)
200 continue
201 }
202 if (stat.kind !== 'dir') continue
203 for (const source of f.sources) {
204 if (source.isDynamic || (dir === null && isRelative(source))) continue
205 const names = await expand($, source.pattern, dir ?? cwd, home)
206 for (const name of Array.isArray(names) ? names : []) {
207 // `cp -r src/ dest` copies what is inside src on macOS, src itself on Linux: check both
208 const inside = /\/\.?$/.test(source.text) ? (await $.fs.list(name).catch(() => [])).map(e => joinPath(dest, e.name)) : []
209 for (const target of [joinPath(dest, baseName(name)), ...inside]) {
210 if (isScratch(target) || !(await $.fs.exists(target).catch(() => false))) continue
211 await add(target, false, change)
212 }
213 }
214 }
215 }
216 continue
217 }
218 if (f.kind === 'git-discard') {
219 if (f.note !== undefined) notes.push(`${f.text}: ${f.note}`)
220 if (f.argv === null || dir === null) {
221 notes.push(`${f.text}: its changed files can't be listed beforehand`)
222 continue
223 }
224 if (!(await isOwnRepo(dir, f.rootArgv))) {
225 notes.push(`${f.text}: works outside this session's repository, so its changed files are not listed beforehand`)
226 continue
227 }
228 const top = (await topOf(dir, f.rootArgv))!
229 const ran = await $.process.run(f.argv, { cwd: dir, timeoutMs: 15_000 }).catch(() => undefined)
230 if (ran === undefined || ran.exitCode !== 0) {
231 notes.push(`${f.text}: listing its changed files beforehand failed`)
232 continue
233 }
234 const fields = ran.stdout.split('\0')
235 for (let k = 0; k < fields.length; k++) {
236 const field = fields[k]!
237 if (field.length < 4) continue
238 const [x, y] = [field[0]!, field[1]!]
239 if (x === 'R' || x === 'C') k++
240 // a deleted file comes back: nothing is lost there
241 if (f.scope === 'worktree' ? y === ' ' || y === 'D' : /^[ D]{2}$/.test(x + y)) continue
242 await add(joinPath(top, field.slice(3)), false, { action: 'discard', change: 'uncommitted changes discarded' })
243 }
244 continue
245 }
246 if (f.argv === null || dir === null) {
247 notes.push(`${f.text}: can't be listed beforehand`)
248 continue
249 }
250 if (f.kind === 'git-clean' && !(await isOwnRepo(dir, f.rootArgv))) {
251 notes.push(`${f.text}: works outside this session's repository, so it is not listed beforehand`)
252 continue
253 }
254 const ran = await $.process.run(f.argv, { cwd: dir, timeoutMs: 15_000 }).catch(() => undefined)
255 if (ran === undefined || ran.exitCode !== 0) {
256 notes.push(`${f.text}: listing its targets beforehand failed`)
257 continue
258 }
259 const lines = ran.stdout.split('\n').filter(l => l !== '')
260 if (f.kind === 'git-clean') {
261 for (const line of lines) {
262 const path = line.replace(/^Would remove /, '')
263 if (path !== line) await add(joinPath(dir, path), path.endsWith('/'))
264 }
265 } else for (const line of lines) await add(joinPath(dir, line), false)
266 if (lines.length === 0) notes.push(`${f.text}: matches nothing right now`)
267 }
268 if (skipped > 0) notes.push(`and ${skipped} more not listed here`)
269 return { entries, notes }
270}
271
272/** `1 file`, `3 files`, or `5000+ files` when counting stopped early. */
273const plural = (n: number, word: string, isMore = false) =>
274 `${n}${isMore ? '+' : ''} ${word}${n === 1 && !isMore ? '' : 's'}`
275
276/** One line for the dialog and the toast: `2 folders (130 files) and 1 file`. */
277export const summary = (entries: Entry[], notes: string[] = []) => {
278 const dirs = entries.filter(e => e.kind === 'dir')
279 const files = entries.filter(e => e.kind === 'file' || e.kind === 'link')
280 const unknown = entries.filter(e => e.kind === 'unknown')
281 const inside = dirs.reduce((n, d) => n + d.files, 0)
282 const parts = [
283 dirs.length > 0 &&
284 `${plural(dirs.length, 'folder')}${inside > 0 ? ` (${plural(inside, 'file', dirs.some(d => d.isPartial))} inside)` : ''}`,
285 files.length > 0 && plural(files.length, 'file'),
286 unknown.length > 0 && `${plural(unknown.length, 'path')} known only when it runs`,
287 ].filter((p): p is string => p !== false)
288 if (parts.length > 0) return parts.join(', ')
289 return notes.length > 0 ? "files that can't be listed beforehand" : 'nothing that exists right now'
290}
291
292const VERBS = { delete: 'delete', truncate: 'truncate', overwrite: 'overwrite', discard: 'discard changes to' } as const
293
294/** What Claude wants to do, as the dialog says it: `truncate 1 file`, `delete 2 folders`. */
295export const headline = (entries: Entry[], notes: string[] = []) => {
296 const actions = new Set(entries.map(e => e.action ?? 'delete'))
297 const verb = actions.size > 1 ? 'delete or change' : VERBS[[...actions][0] ?? 'delete']
298 return `${verb} ${summary(entries, notes)}`
299}
300
301type Line = { text: string; color?: string; isDim?: boolean; isBold?: boolean }
302
303/** The panel's list, folders first; each folder with its size and a few paths inside. */
304export const lines = (req: Request): Line[] => {
305 const out: Line[] = []
306 const group = (title: string, kinds: Entry['kind'][], each: (e: Entry) => Line[]) => {
307 const list = req.entries.filter(e => kinds.includes(e.kind))
308 if (list.length === 0) return
309 out.push({ text: `${title} (${list.length})`, isBold: true })
310 for (const e of list) out.push(...each(e))
311 }
312 group('Folders', ['dir'], d => {
313 const counts = `${plural(d.files, 'file', d.isPartial)}, ${plural(d.dirs, 'folder', d.isPartial)}, ${bytes(d.size)}`
314 const inside = d.sample.map(s => ({ text: ` ${s}`, isDim: true }))
315 const more = d.files + d.dirs - d.sample.length
316 return [
317 { text: ` ▸ ${d.path}/`, color: 'error' },
318 { text: ` ${d.files + d.dirs === 0 && !d.isPartial ? 'empty' : counts}`, isDim: true },
319 ...inside,
320 ...(more > 0 && d.sample.length > 0 ? [{ text: ` … ${more}${d.isPartial ? '+' : ''} more`, isDim: true }] : []),
321 ]
322 })
323 group('Files', ['file', 'link'], f => [
324 {
325 text: ` • ${f.path} ${f.kind === 'link' ? '(link)' : bytes(f.size)}${f.change === undefined ? '' : ` → ${f.change}`}`,
326 color: 'error',
327 },
328 ])
329 group('Known only when it runs', ['unknown'], u => [{ text: ` ? ${u.path} ${u.note}`, color: 'warning' }])
330 group('Not there (nothing to delete)', ['missing'], m => [{ text: ` ${m.path} ${m.note}`, isDim: true }])
331 if (req.notes.length > 0) {
332 out.push({ text: 'Also', isBold: true })
333 for (const n of req.notes) out.push({ text: ` ? ${n}`, color: 'warning' })
334 }
335 return out
336}
337
338/** Keeps a list to `room` rows, its last row saying how many it left out. */
339export const fit = (all: Line[], room: number): Line[] =>
340 all.length <= room ? all : [...all.slice(0, Math.max(1, room - 1)), { text: `… ${all.length - Math.max(1, room - 1)} more lines`, isDim: true }]
341
342/** Answers the request `id`: takes it off the queue and wakes its hook. */
343const decide = async ($: $, id: string, answer: Answer) => {
344 await update($, queue, list => list.filter(r => r.id !== id))
345 waiting.get(id)?.(answer)
346 waiting.delete(id)
347}
348
349/**
350 * Waits for the answer. A hook has 10 seconds of its own time, but time spent
351 * inside a `$` call does not count, so a sleeping child process stands in as
352 * that call until the person answers. `unheld` when no child can be started.
353 */
354const hold = async ($: $, decided: Promise<Answer>, signal: AbortSignal): Promise<Answer | 'unheld'> => {
355 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
356 const argv = isWindows ? ['powershell', '-NoProfile', '-Command', 'Start-Sleep -Seconds 86400'] : ['sleep', '86400']
357 const stopped = new Promise<Answer>(done => {
358 if (signal.aborted) done('cancel')
359 else signal.addEventListener('abort', () => done('cancel'), { once: true })
360 })
361 const wanted = Promise.race([decided, stopped])
362 for (;;) {
363 const startedAt = Date.now()
364 const child = $.process.spawn({ argv })
365 const ended = child.next().then(
366 () => 'ended' as const,
367 () => 'failed' as const,
368 )
369 const first = await Promise.race([wanted, ended])
370 void child.return(undefined as never).catch(() => undefined)
371 if (first === 'failed' || (first === 'ended' && Date.now() - startedAt < 1000)) return 'unheld'
372 if (first !== 'ended') return first
373 }
374}
375
376/** The question in the engine's dialog: what, the command itself, and what can't be listed. */
377export const question = (req: Request) => {
378 const command = req.command.length > 200 ? `${req.command.slice(0, 200)}…` : req.command
379 const also = req.notes
380 .slice(0, 3)
381 .map(n => ` Note: ${n}.`)
382 .join('')
383 return `Claude wants to ${headline(req.entries, req.notes)} with \`${command}\`.${also} Allow it?`
384}
385
386/** The engine's own question, for when the panel can't be shown or held open. */
387const askInstead = async ($: $, req: Request, decided: Promise<Answer>): Promise<Answer> => {
388 const asked = $.ui
389 .ask(question(req), {
390 options: ['Cancel', 'Allow'],
391 header: 'Delete Guard',
392 })
393 .then(
394 (a): Answer => (a === 'Allow' ? 'allow' : 'cancel'),
395 (): Answer => 'cancel',
396 )
397 return Promise.race([decided, asked])
398}
399
400/** Status line and panel follow the queue. Only for show: a failure here never decides a delete. */
401const refresh = async ($: $) => {
402 try {
403 const list = await read($, queue)
404 if (list.length === 0) {
405 $.ui.status(undefined)
406 await $.ui.close({ id: PANE })
407 } else $.ui.status(`${plural(list.length, 'delete')} waiting for your answer`)
408 } catch {
409 // the answer stands either way
410 }
411}
412
413export const register: Register = on => {
414 on('session.start', async ($, e, next) => {
415 await $.state.set({ plugin: 'delete-guard', key: 'queue' } as const, [])
416 await $.command.register({
417 name: 'delete-guard',
418 description: 'Show the panel of deletes waiting for your answer',
419 })
420 return next(e)
421 })
422
423 on('command.run', { command: 'delete-guard' }, async $ => {
424 const list = await read($, queue)
425 if (list.length === 0) return { text: 'No delete is waiting for an answer.' }
426 await $.ui.open({ id: PANE, title: TITLE, focus: true })
427 return { text: 'Opened the delete panel.' }
428 })
429
430 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
431 const { found, isOnlyDeletes } = findDeletes(e.command)
432 if (found.length === 0) return next(e)
433
434 const cwd = await $.session.cwd()
435 const home = (await $.env.get('HOME')) ?? ''
436 const { entries, notes } = await resolve($, found, cwd, home)
437 // only overwrites of files that don't exist (or are empty): nothing to lose
438 if (!found.some(isAlwaysAsked) && entries.length === 0 && notes.length === 0) return next(e)
439
440 // Nobody to ask (a `claude -p` run): refuse rather than delete unseen.
441 if ((await $.session.surfaces()).length === 0)
442 return {
443 deny: 'delete-guard: deleting or overwriting files needs a person to confirm it, and nobody can be asked in this session.',
444 }
445 const req: Request = { id: e.tool_use_id, command: e.command, cwd, entries, notes }
446
447 const decided = new Promise<Answer>(done => waiting.set(req.id, done))
448 await update($, queue, list => [...list.filter(r => r.id !== req.id), req])
449 await refresh($)
450 const opened = await $.ui
451 .open({ id: PANE, title: TITLE, focus: true })
452 .catch(() => ({ isPlaced: false as const, reason: 'the panel could not be opened' }))
453 if (opened.isPlaced) $.ui.toast(`Claude wants to ${headline(entries, notes)}: confirm or cancel in the "${TITLE}" panel`)
454
455 let answer: Answer
456 try {
457 const held = opened.isPlaced ? await hold($, decided, next.signal) : 'unheld'
458 answer = held === 'unheld' ? await askInstead($, req, decided) : held
459 } finally {
460 waiting.delete(req.id)
461 await update($, queue, list => list.filter(r => r.id !== req.id))
462 await refresh($)
463 }
464
465 if (answer === 'cancel')
466 return {
467 deny:
468 'delete-guard: the user cancelled this command, nothing was deleted or overwritten. Do not retry it or change these files another way unless the user asks for it.',
469 }
470 if (isOnlyDeletes) approved.add(req.id)
471 try {
472 return await next(e)
473 } finally {
474 approved.delete(req.id)
475 }
476 }).catch(($, e, next) =>
477 next.called ? next(e) : { deny: 'delete-guard: this command deletes files and could not be checked, so it was blocked.' },
478 )
479
480 // Allowed in the panel: the engine's own "run this command?" would ask the
481 // same again. Only for a command that does nothing but delete; a settings
482 // rule that denies it still wins.
483 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
484 const verdict = await next(e)
485 return e.tool_use_id !== undefined && approved.has(e.tool_use_id) && verdict.decision === 'ask'
486 ? { ...verdict, decision: 'allow' as const, reason: 'confirmed in the delete-guard panel' }
487 : verdict
488 })
489
490 // Closing the panel by hand cancels everything it was asking about.
491 on('ui.close', { id: PANE }, async ($, e, next) => {
492 if (e.origin.kind === 'person') for (const r of await read($, queue)) await decide($, r.id, 'cancel')
493 return next(e)
494 })
495
496 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
497 const { Box, Text, Button } = $.ui.resolve(e)
498 const list = await read($, queue)
499 const req = list[0]
500 if (req === undefined) return <Text dimColor>No delete is waiting for an answer.</Text>
501
502 const home = (await $.env.get('HOME')) ?? ''
503 const command = req.command.length > 300 ? `${req.command.slice(0, 300)}…` : req.command
504 const room = Math.max(6, e.props.scroll.bodyRows - 10)
505
506 return (
507 <Box flexDirection="column">
508 <Box flexDirection="column">
509 <Text bold color="error">
510 {TITLE}
511 </Text>
512 <Text bold color="error">
513 Claude wants to {headline(req.entries, req.notes)}
514 </Text>
515 </Box>
516 {list.length > 1 && <Text color="warning">1 of {list.length} waiting</Text>}
517 <Text dimColor wrap="truncate-middle">
518 in {tilde(req.cwd, home)}
519 </Text>
520 <Text dimColor>$ {command}</Text>
521 <Text> </Text>
522 {fit(lines(req), room).map(line => (
523 <Text color={line.color} dimColor={line.isDim} bold={line.isBold} wrap="truncate-middle">
524 {line.text}
525 </Text>
526 ))}
527 <Text> </Text>
528 <Box flexDirection="row">
529 <Button key="cancel" hotkey="n" autoFocus onPress={() => decide($, req.id, 'cancel')}>
530 Cancel
531 </Button>
532 <Text> </Text>
533 <Button key="allow" variant="primary" onPress={() => decide($, req.id, 'allow')}>
534 Allow
535 </Button>
536 </Box>
537 </Box>
538 )
539 })
540}
541hooks/parse.ts 1041 lines1// Reads a shell command the way bash would split it and finds the parts that delete files.
2// Pure: no `$`, so it is tested on its own. What it can't read safely it reports as `opaque`.
3
4/** One shell word: `text` as bash would pass it, `pattern` with quoted glob/brace/tilde characters escaped. */
5export type Word = { text: string; pattern: string; isDynamic: boolean }
6
7export type Found =
8 | { kind: 'paths'; tool: string; cwd: string[]; isCwdKnown: boolean; words: Word[]; change?: string }
9 | { kind: 'find'; cwd: string[]; isCwdKnown: boolean; argv: string[] | null; text: string }
10 | { kind: 'git-clean'; cwd: string[]; isCwdKnown: boolean; argv: string[] | null; rootArgv: string[]; text: string }
11 /**
12 * A file written from the start (`>`, `cp`, `tee`, ...): asked about only when
13 * it exists (and, with `isOnlyNonEmpty`, holds something). `intoDir`: `dest`
14 * is a folder the `sources` go into; `null` when that depends on what `dest` is.
15 */
16 | {
17 kind: 'overwrite'
18 tool: string
19 cwd: string[]
20 isCwdKnown: boolean
21 dest: Word
22 sources: Word[]
23 intoDir: boolean | null
24 isOnlyNonEmpty: boolean
25 }
26 /** `git reset --hard`, `restore`, `checkout -- ...`: `argv` lists the changed files (repo-relative, `rootArgv` finds the repo). */
27 | {
28 kind: 'git-discard'
29 cwd: string[]
30 isCwdKnown: boolean
31 scope: 'worktree' | 'all'
32 argv: string[] | null
33 rootArgv: string[]
34 text: string
35 note?: string
36 }
37 | { kind: 'opaque'; text: string; why: string }
38
39export type Analysis = {
40 found: Found[]
41 /** True when every part of the command is a delete (or a `cd`): nothing else would run. */
42 isOnlyDeletes: boolean
43}
44
45/** `write` empties the file (`>`, `>|`, `&>`), `dup` is `>&` (a file, or a descriptor like `2>&1`). */
46type Redirect = 'write' | 'append' | 'dup' | 'read' | 'heredoc'
47
48type Token = { type: 'word'; word: Word } | { type: 'op'; op: string } | { type: 'redirect'; mode: Redirect }
49
50const SPECIAL = /[\\*?[\]{}~]/g
51const isGlobPattern = (pattern: string) => /(^|[^\\])(\\\\)*[*?[]/.test(pattern)
52
53/** Index of the `)` that closes the `(` at `open`, skipping quotes; -1 when unclosed. */
54const closing = (src: string, open: number) => {
55 let depth = 0
56 for (let i = open; i < src.length; i++) {
57 const c = src[i]
58 if (c === '\\') i++
59 else if (c === "'") {
60 const end = src.indexOf("'", i + 1)
61 if (end < 0) return -1
62 i = end
63 } else if (c === '"') {
64 i++
65 while (i < src.length && src[i] !== '"') i += src[i] === '\\' ? 2 : 1
66 } else if (c === '(') depth++
67 else if (c === ')' && --depth === 0) return i
68 }
69 return -1
70}
71
72/** The `$(...)` and backtick commands in a text that is not split into words (an unquoted here-doc body). */
73const substitutions = (body: string): string[] => {
74 const out: string[] = []
75 for (let j = 0; j < body.length; j++) {
76 if (body[j] === '\\') j++
77 else if (body[j] === '$' && body[j + 1] === '(') {
78 const end = closing(body, j + 1)
79 out.push(body.slice(j + 2, end < 0 ? body.length : end))
80 j = end < 0 ? body.length : end
81 } else if (body[j] === '`') {
82 const end = body.indexOf('`', j + 1)
83 out.push(body.slice(j + 1, end < 0 ? body.length : end))
84 j = end < 0 ? body.length : end
85 }
86 }
87 return out
88}
89
90/** Splits `src` into words and operators; command substitutions go to `inner` as well. Here-doc bodies are text, not commands. */
91export const lex = (src: string): { tokens: Token[]; inner: string[] } => {
92 const tokens: Token[] = []
93 const inner: string[] = []
94 const heredocs: { delim: string; isStripped: boolean; isQuoted: boolean }[] = []
95 let cur: Word | null = null
96 const begin = (): Word => (cur ??= { text: '', pattern: '', isDynamic: false })
97 const flush = () => {
98 if (cur !== null) tokens.push({ type: 'word', word: cur })
99 cur = null
100 }
101 const lit = (s: string) => {
102 const w = begin()
103 w.text += s
104 w.pattern += s.replace(SPECIAL, '\\$&')
105 }
106 const dynamic = (raw: string) => {
107 const w = begin()
108 w.text += raw
109 w.pattern += raw.replace(SPECIAL, '\\$&')
110 w.isDynamic = true
111 }
112
113 // `$...` or a backtick at i: reads to its end, returns the index after it
114 const dollar = (i: number): number => {
115 if (src[i] === '`') {
116 let end = i + 1
117 while (end < src.length && src[end] !== '`') end += src[end] === '\\' ? 2 : 1
118 inner.push(src.slice(i + 1, end))
119 dynamic(src.slice(i, end + 1))
120 return end + 1
121 }
122 const n = src[i + 1]
123 if (n === '(') {
124 const end = closing(src, i + 1)
125 const stop = end < 0 ? src.length : end + 1
126 if (src[i + 2] !== '(') inner.push(src.slice(i + 2, stop - 1))
127 // `$(( $(cmd) ))` runs cmd too
128 else inner.push(...substitutions(src.slice(i + 3, stop - 2)))
129 dynamic(src.slice(i, stop))
130 return stop
131 }
132 if (n === '{') {
133 const end = closing(src.replace(/[{}]/g, c => (c === '{' ? '(' : ')')), i + 1)
134 const stop = end < 0 ? src.length : end + 1
135 // `${X:-$(cmd)}` runs cmd when X is unset
136 inner.push(...substitutions(src.slice(i + 2, stop - 1)))
137 dynamic(src.slice(i, stop))
138 return stop
139 }
140 if (n === "'") {
141 let end = i + 2
142 let text = ''
143 while (end < src.length && src[end] !== "'") {
144 if (src[end] === '\\' && end + 1 < src.length) {
145 const e = src[end + 1]
146 text += e === 'n' ? '\n' : e === 't' ? '\t' : e
147 end += 2
148 } else text += src[end++]
149 }
150 lit(text)
151 return end + 1
152 }
153 const name = /^(?:[A-Za-z_][A-Za-z0-9_]*|[0-9@*#?$!-])/.exec(src.slice(i + 1))
154 if (name === null) {
155 lit('$')
156 return i + 1
157 }
158 dynamic(src.slice(i, i + 1 + name[0].length))
159 return i + 1 + name[0].length
160 }
161
162 let i = 0
163 while (i < src.length) {
164 const c = src[i]!
165 if (c === ' ' || c === '\t' || c === '\r') {
166 flush()
167 i++
168 } else if (c === '\\') {
169 if (src[i + 1] !== '\n') lit(src[i + 1] ?? '')
170 i += 2
171 } else if (c === '#' && cur === null) {
172 while (i < src.length && src[i] !== '\n') i++
173 } else if (c === "'") {
174 const end = src.indexOf("'", i + 1)
175 lit(end < 0 ? src.slice(i + 1) : src.slice(i + 1, end))
176 i = end < 0 ? src.length : end + 1
177 } else if (c === '"') {
178 begin()
179 i++
180 while (i < src.length && src[i] !== '"') {
181 if (src[i] === '\\' && '"\\$`\n'.includes(src[i + 1] ?? '')) {
182 if (src[i + 1] !== '\n') lit(src[i + 1]!)
183 i += 2
184 } else if (src[i] === '$' || src[i] === '`') i = dollar(i)
185 else lit(src[i++]!)
186 }
187 i++
188 } else if (c === '$' || c === '`') {
189 i = dollar(i)
190 } else if (c === '>' || c === '<') {
191 const w = cur as Word | null
192 if (w !== null && /^\d+$/.test(w.text) && w.text === w.pattern) cur = null
193 flush()
194 const op = /^(<<<|<<-?|<>|>>|>\||>&|<&|>|<)/.exec(src.slice(i))![0]
195 if ((op === '<' || op === '>') && src[i + 1] === '(') {
196 tokens.push({ type: 'op', op: '(' })
197 i += 2
198 } else {
199 const mode: Redirect =
200 op === '>' || op === '>|' ? 'write' : op === '>&' ? 'dup' : op === '>>' ? 'append' : op === '<<' || op === '<<-' ? 'heredoc' : 'read'
201 tokens.push({ type: 'redirect', mode })
202 i += op.length
203 if (mode === 'heredoc') {
204 // the delimiter is still lexed as the next word; its body starts after the line ends
205 const d = /^[ \t]*(?:'([^']*)'|"([^"]*)"|(\\?)([^\s;&|<>()]+))/.exec(src.slice(i))
206 if (d !== null)
207 heredocs.push({
208 delim: d[1] ?? d[2] ?? d[4]!.replace(/\\/g, ''),
209 isStripped: op === '<<-',
210 isQuoted: d[1] !== undefined || d[2] !== undefined || d[3] === '\\' || d[4]!.includes('\\'),
211 })
212 }
213 }
214 } else if (c === '&' && src[i + 1] === '>') {
215 flush()
216 tokens.push({ type: 'redirect', mode: src[i + 2] === '>' ? 'append' : 'write' })
217 i += src[i + 2] === '>' ? 3 : 2
218 } else if (';&|()\n'.includes(c)) {
219 flush()
220 const op = /^(;;|&&|\|\||\|&|[;&|()\n])/.exec(src.slice(i))![0]
221 tokens.push({ type: 'op', op })
222 i += op.length
223 // skip the bodies of the here-docs opened on the line that just ended
224 if (op === '\n')
225 for (const h of heredocs.splice(0)) {
226 let body = ''
227 while (i < src.length) {
228 const eol = src.indexOf('\n', i)
229 const line = src.slice(i, eol < 0 ? src.length : eol)
230 i = eol < 0 ? src.length : eol + 1
231 if ((h.isStripped ? line.replace(/^\t+/, '') : line) === h.delim) break
232 body += `${line}\n`
233 }
234 if (!h.isQuoted) inner.push(...substitutions(body))
235 }
236 } else {
237 const w = begin()
238 w.text += c
239 w.pattern += c
240 i++
241 }
242 }
243 flush()
244 return { tokens, inner }
245}
246
247const KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', '!', '{', '}', 'time'])
248const DELETERS = new Set(['rm', 'unlink', 'rmdir', 'shred', 'srm', 'trash', 'trash-put', 'rimraf'])
249const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh'])
250const SAFE_EXTRA = new Set(['cd', 'pushd', 'popd', 'true', ':'])
251const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
252
253const baseName = (s: string) => s.slice(s.lastIndexOf('/') + 1)
254const isFlag = (w: Word) => w.text.length > 1 && w.text.startsWith('-')
255const literal = (text: string): Word => ({ text, pattern: text.replace(SPECIAL, '\\$&'), isDynamic: false })
256const splitWords = (src: string) => lex(src).tokens.flatMap(t => (t.type === 'word' ? [t.word] : []))
257
258/** A command named bare (found on PATH) or by a system path; `./rm` or `/tmp/x/git` are something else. */
259const isSystemPath = (s: string) => !s.includes('/') || /^\/(usr\/)?s?bin\/[^/]+$/.test(s)
260
261/**
262 * The flag a word sets and its glued value, if any: `--name=value`, or in a
263 * group of short flags (`-nu root`, `-uroot`, `-S'rm x'`) the first one that
264 * takes a value, with the rest of the group as that value.
265 */
266const flagValue = (w: Word, withArg: string[]): [string, Word | undefined] => {
267 if (w.text.startsWith('--')) return splitFlag(w)
268 for (let k = 1; k < w.text.length; k++) {
269 const flag = `-${w.text[k]}`
270 if (!withArg.includes(flag)) continue
271 const rest = w.text.slice(k + 1)
272 return [flag, rest === '' ? undefined : { text: rest, pattern: rest.replace(SPECIAL, '\\$&'), isDynamic: w.isDynamic }]
273 }
274 return [w.text, undefined]
275}
276
277/** Splits `--name=value` into the flag and its value (a word of its own). */
278const splitFlag = (w: Word): [string, Word | undefined] => {
279 const at = w.text.indexOf('=')
280 if (!w.text.startsWith('--') || at < 0) return [w.text, undefined]
281 return [w.text.slice(0, at), { ...w, text: w.text.slice(at + 1), pattern: w.pattern.slice(w.pattern.indexOf('=') + 1) }]
282}
283
284/** Commands that run the command after them, with their flags that take a value. */
285const WRAPPERS: Record<string, string[]> = {
286 sudo: ['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-U', '-T', '--user', '--group', '--close-from', '--chdir', '--host', '--prompt', '--role', '--type', '--other-user', '--command-timeout'],
287 doas: ['-u', '-C'],
288 env: ['-u', '-C', '-S', '--unset', '--chdir', '--split-string'],
289 nice: ['-n', '--adjustment'],
290 ionice: ['-c', '-n', '-p', '--class', '--classdata', '--pid'],
291 timeout: ['-s', '-k', '--signal', '--kill-after'],
292 stdbuf: ['-i', '-o', '-e', '--input', '--output', '--error'],
293 exec: ['-a'],
294 command: [],
295 builtin: [],
296 nohup: [],
297 caffeinate: ['-t', '-w'],
298 npx: ['-p', '--package', '-c', '--call', '-w', '--workspace'],
299 bunx: ['-p', '--package'],
300 pnpx: ['-p', '--package'],
301}
302/** `npm exec rimraf x` and the like run a package's command, as `npx` does. */
303const RUNNERS: Record<string, string[]> = { npm: ['exec', 'x'], pnpm: ['exec', 'dlx'], yarn: ['exec', 'dlx'], bun: ['x'] }
304
305/**
306 * Drops wrappers that run the command after them (`sudo`, `env`, `npx`, ...).
307 * `chdir` holds the folders a wrapper moves that one command into (`env -C`).
308 */
309const unwrap = (raw: Word[]): { words: Word[]; chdir: Word[]; isPlain: boolean } => {
310 let rest = raw
311 const chdir: Word[] = []
312 let isPlain = true
313 for (;;) {
314 while (rest.length > 0 && (KEYWORDS.has(rest[0]!.text) || ASSIGNMENT.test(rest[0]!.text))) {
315 // `PATH=… rm`, `LD_PRELOAD=… rm`: what runs is not the plain command
316 if (ASSIGNMENT.test(rest[0]!.text)) isPlain = false
317 rest = rest.slice(1)
318 }
319 if (rest.length === 0) return { words: rest, chdir, isPlain }
320 let name = baseName(rest[0]!.text)
321 if (RUNNERS[name]?.includes(rest[1]?.text ?? '')) {
322 rest = rest.slice(1)
323 name = 'npx'
324 }
325 const withArg = WRAPPERS[name]
326 if (withArg === undefined) return { words: rest, chdir, isPlain }
327 // a package runner fetches and runs code; `./sudo` is not sudo
328 if (name === 'npx' || name === 'bunx' || name === 'pnpx' || !isSystemPath(rest[0]!.text)) isPlain = false
329 rest = rest.slice(1)
330 while (rest.length > 0 && (isFlag(rest[0]!) || (name === 'env' && rest[0]!.text.includes('=')))) {
331 const w = rest[0]!
332 rest = rest.slice(1)
333 if (w.text === '--') break
334 if (!isFlag(w)) {
335 isPlain = false
336 continue
337 }
338 // `command -v rm` only looks the command up
339 if (name === 'command' && /^-[a-zA-Z]*[vV]/.test(w.text)) return { words: [], chdir, isPlain }
340 const [flag, glued] = flagValue(w, withArg)
341 if (!withArg.includes(flag)) continue
342 const value = glued ?? rest[0]
343 if (glued === undefined) rest = rest.slice(1)
344 if (value === undefined) continue
345 if (flag === '--chdir' || (name === 'env' && flag === '-C') || (name === 'sudo' && flag === '-D')) chdir.push(value)
346 else if (name === 'env' && (flag === '-S' || flag === '--split-string')) rest = [...splitWords(value.text), ...rest]
347 else if (name === 'npx' && (flag === '-c' || flag === '--call')) rest = [literal('sh'), literal('-c'), value]
348 }
349 if (name === 'timeout') rest = rest.slice(1)
350 }
351}
352
353/** The operands after the flags; `--` ends the flags. `withArg` names flags that take the next word. */
354const operands = (args: Word[], withArg: string[] = []) => {
355 const out: Word[] = []
356 let isFlagsDone = false
357 for (let i = 0; i < args.length; i++) {
358 const w = args[i]!
359 if (!isFlagsDone && w.text === '--') isFlagsDone = true
360 else if (!isFlagsDone && isFlag(w)) i += withArg.includes(w.text) ? 1 : 0
361 else out.push(w)
362 }
363 return out
364}
365
366/** What a found part does: only these count as a delete. */
367const isDelete = (f: Found) => f.kind === 'paths' || f.kind === 'find' || f.kind === 'git-clean' || f.kind === 'opaque'
368
369/** Reads a command that another one runs (`find -exec`, `xargs`) on its own. */
370const subCommand = (words: Word[], ctx: Ctx, isCwdKnown = ctx.isCwdKnown): Ctx => {
371 const sub: Ctx = { cwd: [...ctx.cwd], isCwdKnown, found: [], others: 0, depth: ctx.depth + 1 }
372 if (ctx.depth < 4) readCommand(words, sub)
373 else sub.others++
374 return sub
375}
376
377/** Takes the stand-in for `{}` or xargs' input back out of what a sub-command found. */
378const strip = (found: Found[], stand: Word): Found[] =>
379 found.flatMap((f): Found[] => {
380 if (f.kind === 'paths') {
381 const words = f.words.filter(w => w !== stand)
382 return words.length > 0 ? [{ ...f, words }] : []
383 }
384 if (f.kind === 'overwrite') return f.dest === stand ? [] : [{ ...f, sources: f.sources.filter(w => w !== stand) }]
385 return [f]
386 })
387
388const FIND_ACTIONS = ['-exec', '-execdir', '-ok', '-okdir']
389const FIND_WRITES = /^-(fprint0?|fprintf|fls)$/
390
391/**
392 * `find` with `-delete` or `-exec rm`: the same `find` with each delete swapped
393 * in place for `-print` lists what it would delete, in the same order of tests.
394 */
395const readFind = (args: Word[], ctx: Ctx): { found: Found[]; isOnlyDeletes: boolean } | null => {
396 let i = 0
397 const opts: string[] = []
398 while (i < args.length && /^-[HLP]$|^-D$|^-O\d$/.test(args[i]!.text)) {
399 opts.push(args[i]!.text)
400 if (args[i]!.text === '-D') opts.push(args[++i]?.text ?? '')
401 i++
402 }
403 const roots: Word[] = []
404 while (i < args.length && !/^[-(!]/.test(args[i]!.text)) roots.push(args[i++]!)
405 const expr = args.slice(i)
406 const preview: string[] = []
407 const extra: Found[] = []
408 let deletes = false
409 let hasDelete = false
410 let isOther = false
411 let isUnlistable = roots.some(w => w.isDynamic)
412 for (let j = 0; j < expr.length; j++) {
413 const w = expr[j]!
414 if (w.text === '-delete') {
415 deletes = hasDelete = true
416 preview.push('-print')
417 continue
418 }
419 if (FIND_ACTIONS.includes(w.text)) {
420 const cmd: Word[] = []
421 while (++j < expr.length && expr[j]!.text !== ';' && !(expr[j]!.text === '+' && expr[j - 1]?.text === '{}')) cmd.push(expr[j]!)
422 const stand: Word = { text: '{}', pattern: '{}', isDynamic: true }
423 const sub = subCommand(
424 cmd.map(c => (c.text === '{}' ? stand : c)),
425 ctx,
426 ctx.isCwdKnown && !w.text.endsWith('dir'),
427 )
428 if (sub.found.some(isDelete)) {
429 deletes = true
430 preview.push('-print')
431 extra.push(...strip(sub.found, stand))
432 if (sub.others > 0) isOther = true
433 } else isOther = isUnlistable = true
434 continue
435 }
436 if (FIND_WRITES.test(w.text)) isOther = isUnlistable = true
437 if (/^-(ls|printf|print0)$/.test(w.text) || w.isDynamic) isUnlistable = true
438 // the command's own -print would list what is not deleted too; -true keeps the logic
439 preview.push(w.text === '-print' ? '-true' : w.text)
440 }
441 if (!deletes) return null
442 const text = ['find', ...args.map(w => w.text)].join(' ')
443 const argv = isUnlistable
444 ? null
445 : ['find', ...opts, ...(roots.length > 0 ? roots.map(w => w.text) : ['.']), ...(hasDelete ? ['-depth'] : []), ...preview]
446 return { found: [{ kind: 'find', cwd: [...ctx.cwd], isCwdKnown: ctx.isCwdKnown, argv, text }, ...extra], isOnlyDeletes: !isOther }
447}
448
449/** Global git flags that take the next word. */
450const GIT_WITH_ARG = ['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--super-prefix', '--exec-path']
451
452/** A subcommand's options as git reads them: values taken (`-e -n` is an exclude, not a dry run), long names abbreviated. */
453type GitOptions = { short: Set<string>; long: string[]; values: [string, string][]; operands: Word[]; afterDashes: Word[] | null }
454
455const gitOptions = (rest: Word[], shortWithValue = '', longWithValue: string[] = []): GitOptions => {
456 const o: GitOptions = { short: new Set(), long: [], values: [], operands: [], afterDashes: null }
457 const longs = [...longWithValue, '--pathspec-from-file']
458 for (let i = 0; i < rest.length; i++) {
459 const w = rest[i]!
460 if (w.text === '--') {
461 o.afterDashes = rest.slice(i + 1)
462 break
463 }
464 if (w.text.startsWith('--')) {
465 const [flag, glued] = splitFlag(w)
466 const full = longs.find(l => flag.length >= 4 && l.startsWith(flag))
467 if (full === undefined) o.long.push(flag)
468 else o.values.push([full, glued?.text ?? rest[++i]?.text ?? ''])
469 continue
470 }
471 if (/^-[a-zA-Z]/.test(w.text)) {
472 for (let k = 1; k < w.text.length; k++) {
473 const c = w.text[k]!
474 if (!shortWithValue.includes(c)) {
475 o.short.add(c)
476 continue
477 }
478 o.values.push([`-${c}`, k + 1 < w.text.length ? w.text.slice(k + 1) : (rest[++i]?.text ?? '')])
479 break
480 }
481 continue
482 }
483 o.operands.push(w)
484 }
485 return o
486}
487
488/** True when the options name `full`, written out or abbreviated as git allows (`--dry` for `--dry-run`). */
489const hasLong = (o: GitOptions, full: string) => o.long.some(l => l.length >= 4 && full.startsWith(l))
490
491/**
492 * `git rm`, `git clean` and the commands that throw away uncommitted changes.
493 * A dry run never takes the command's own `-c` or other global flags (a config
494 * value such as `core.fsmonitor` would run code before anyone answered), and
495 * never runs against a repository named by `--git-dir`/`--work-tree`.
496 */
497const readGit = (args: Word[], ctx: Ctx, hasGitEnv: boolean): Found | null => {
498 const cwd = [...ctx.cwd]
499 let isCwdKnown = ctx.isCwdKnown
500 let isUnlistable = hasGitEnv
501 let i = 0
502 while (i < args.length && isFlag(args[i]!)) {
503 const [flag, glued] = splitFlag(args[i]!)
504 const value = GIT_WITH_ARG.includes(flag) && flag !== '--exec-path' ? (glued ?? args[++i]) : undefined
505 if (flag === '-C') {
506 if (value === undefined || value.isDynamic) isCwdKnown = false
507 else cwd.push(value.text)
508 } else if (flag === '--git-dir' || flag === '--work-tree') isUnlistable = true
509 i++
510 }
511 const sub = args[i]?.text
512 const rest = args.slice(i + 1)
513 const text = ['git', ...args.map(w => w.text)].join(' ')
514 const git = ['git', '-c', 'core.fsmonitor=false', '--no-optional-locks']
515 const rootArgv = [...git, 'rev-parse', '--show-toplevel']
516 const opaque = (why: string): Found => ({ kind: 'opaque', text, why })
517
518 if (sub === 'rm') {
519 const o = gitOptions(rest)
520 if (o.values.some(([name]) => name === '--pathspec-from-file')) return opaque('removes the paths listed in a file')
521 if (o.short.has('n') || hasLong(o, '--dry-run') || hasLong(o, '--cached')) return null
522 const words = [...o.operands, ...(o.afterDashes ?? [])]
523 return words.length === 0 ? null : { kind: 'paths', tool: 'git rm', cwd, isCwdKnown, words }
524 }
525 if (sub === 'clean') {
526 const o = gitOptions(rest, 'e', ['--exclude'])
527 if (o.short.has('n') || hasLong(o, '--dry-run')) return null
528 const paths = [...o.operands, ...(o.afterDashes ?? [])]
529 if (isUnlistable || args.some(w => w.isDynamic)) return { kind: 'git-clean', cwd, isCwdKnown, argv: null, rootArgv, text }
530 // only the flags that change what is listed; anything else (`--interactive`, abbreviated or not) stays out
531 const flags = [
532 ...['d', 'x', 'X'].filter(c => o.short.has(c)).map(c => `-${c}`),
533 ...o.values.filter(([name]) => name === '-e' || name === '--exclude').map(([, v]) => `--exclude=${v}`),
534 ]
535 return { kind: 'git-clean', cwd, isCwdKnown, argv: [...git, 'clean', '-n', ...flags, '--', ...paths.map(w => w.text)], rootArgv, text }
536 }
537 if (sub === 'stash' && (rest[0]?.text === 'drop' || rest[0]?.text === 'clear')) return opaque('throws away stashed changes')
538 if (sub === 'worktree' && rest[0]?.text === 'remove' && rest.some(w => w.text === '-f' || w.text === '--force'))
539 return opaque('removes a worktree together with its uncommitted changes')
540
541 const discard = (paths: Word[], scope: 'worktree' | 'all', note?: string): Found => ({
542 kind: 'git-discard',
543 cwd,
544 isCwdKnown,
545 scope,
546 text,
547 ...(note === undefined ? {} : { note }),
548 argv:
549 isUnlistable || paths.some(w => w.isDynamic)
550 ? null
551 : [...git, 'status', '--porcelain=v1', '-z', '--untracked-files=no', '--', ...paths.map(w => w.text)],
552 rootArgv,
553 })
554 if (sub === 'reset') {
555 const o = gitOptions(rest)
556 if (!hasLong(o, '--hard')) return null
557 const rev = o.operands[0]
558 return discard(
559 [],
560 'all',
561 rev === undefined || rev.text === 'HEAD' ? undefined : `moves the branch to ${rev.text}: commits after it stay reachable only through the reflog`,
562 )
563 }
564 if (sub === 'restore') {
565 const o = gitOptions(rest, 's', ['--source'])
566 if (o.values.some(([name]) => name === '--pathspec-from-file')) return opaque('restores the paths listed in a file')
567 const isStaged = o.short.has('S') || hasLong(o, '--staged')
568 const isWorktree = o.short.has('W') || hasLong(o, '--worktree')
569 if (isStaged && !isWorktree) return null
570 const hasSource = o.values.some(([name]) => name === '-s' || name === '--source')
571 const paths = [...o.operands, ...(o.afterDashes ?? [])]
572 return paths.length === 0 ? null : discard(paths, isStaged || hasSource ? 'all' : 'worktree')
573 }
574 if (sub === 'checkout') {
575 const o = gitOptions(rest, 'bB', ['--orphan', '--conflict'])
576 if (o.values.some(([name]) => name === '--pathspec-from-file')) return opaque('restores the paths listed in a file')
577 const isForce = o.short.has('f') || hasLong(o, '--force')
578 if (o.afterDashes !== null) return o.afterDashes.length === 0 ? null : discard(o.afterDashes, o.operands.length > 0 ? 'all' : 'worktree')
579 if (isForce) return discard([], 'all')
580 // `git checkout name`: a file of that name loses its changes; a branch name lists nothing and is not asked about
581 if (o.operands.length > 0 && !o.values.some(([name]) => /^-[bB]$|^--orphan$/.test(name)))
582 return discard(o.operands, o.operands.length > 1 ? 'all' : 'worktree')
583 return null
584 }
585 if (sub === 'switch') {
586 const o = gitOptions(rest, 'cC', ['--create', '--force-create', '--orphan'])
587 return o.short.has('f') || hasLong(o, '--force') || hasLong(o, '--discard-changes') ? discard([], 'all') : null
588 }
589 return null
590}
591
592/**
593 * `truncate` keeps the file but cuts its content: what it does to each file, as
594 * `change`. Only growing it (`+N`, `>N`, `%N`) loses nothing and is left alone.
595 */
596const readTruncate = (args: Word[], ctx: Ctx): Found | null => {
597 let size: string | undefined
598 let ref: string | undefined
599 const words: Word[] = []
600 let isFlagsDone = false
601 for (let i = 0; i < args.length; i++) {
602 const w = args[i]!
603 const t = w.text
604 if (isFlagsDone || !isFlag(w)) words.push(w)
605 else if (t === '--') isFlagsDone = true
606 else if (t === '--size' || t === '--reference' || /^-[a-z]*[sr]$/.test(t)) {
607 const value = args[++i]?.text ?? ''
608 if (t === '--size' || t.endsWith('s')) size = value
609 else ref = value
610 } else if (t.startsWith('--size=')) size = t.slice(7)
611 else if (t.startsWith('--reference=')) ref = t.slice(12)
612 else {
613 const glued = /^-[a-z]*?([sr])(.+)$/.exec(t)
614 if (glued?.[1] === 's') size = glued[2]
615 else if (glued?.[1] === 'r') ref = glued[2]
616 }
617 }
618 if (words.length === 0 || (size === undefined && ref === undefined)) return null
619 if (size !== undefined && /^[+>%]/.test(size)) return null
620 const change =
621 size === undefined
622 ? `size set to that of ${ref}`
623 : /^0+$/.test(size)
624 ? 'emptied, the file stays'
625 : size.startsWith('-')
626 ? `shortened by ${size.slice(1)}`
627 : size.startsWith('<')
628 ? `cut to at most ${size.slice(1)}`
629 : size.startsWith('/')
630 ? `cut to a multiple of ${size.slice(1)}`
631 : `size set to ${size}`
632 return { kind: 'paths', tool: 'truncate', cwd: [...ctx.cwd], isCwdKnown: ctx.isCwdKnown, words, change }
633}
634
635/** Flags of `cp`, `mv` and `install` that take a value; the short ones may end a group (`-rt dir`). */
636const COPY_WITH_ARG: Record<string, string[]> = {
637 cp: ['-t', '--target-directory', '-S', '--suffix'],
638 mv: ['-t', '--target-directory', '-S', '--suffix'],
639 install: ['-t', '--target-directory', '-S', '--suffix', '-m', '--mode', '-o', '--owner', '-g', '--group', '--strip-program'],
640 ln: ['-t', '--target-directory', '-S', '--suffix'],
641}
642
643/** `cp`, `mv`, `install`, `ln -f`: the file or folder they write to. Not when they keep what is there (`-n`, `-i`, `-b`). */
644const readCopy = (name: string, args: Word[], ctx: Ctx): Found | null => {
645 const withArg = COPY_WITH_ARG[name]!
646 const keeps = name === 'install' ? /[bd]/ : name === 'ln' ? /[ib]/ : /[nib]/
647 // `ln` replaces an existing name only with -f
648 if (name === 'ln' && !args.some(w => w.text === '--force' || /^-[a-zA-Z]*f/.test(w.text))) return null
649 const files: Word[] = []
650 let target: Word | undefined
651 let isNoTarget = false
652 let isFlagsDone = false
653 for (let i = 0; i < args.length; i++) {
654 const w = args[i]!
655 if (isFlagsDone || !isFlag(w)) {
656 files.push(w)
657 continue
658 }
659 if (w.text === '--') {
660 isFlagsDone = true
661 continue
662 }
663 const [flag, glued] = splitFlag(w)
664 if (['--no-clobber', '--interactive', '--backup', '--directory'].includes(flag) || (flag === '--update' && glued?.text.startsWith('none')))
665 return null
666 if (flag === '--no-target-directory') isNoTarget = true
667 if (flag.startsWith('--')) {
668 if (withArg.includes(flag)) {
669 const value = glued ?? args[++i]
670 if (flag === '--target-directory') target = value
671 }
672 continue
673 }
674 const letters = flag.slice(1)
675 if (keeps.test(letters)) return null
676 if (letters.includes('T')) isNoTarget = true
677 const last = `-${letters.at(-1)}`
678 if (withArg.includes(last)) {
679 const value = args[++i]
680 if (last === '-t') target = value
681 }
682 }
683 const dest = target ?? (files.length >= 2 ? files.pop() : undefined)
684 if (dest === undefined) return null
685 return {
686 kind: 'overwrite',
687 tool: name,
688 cwd: [...ctx.cwd],
689 isCwdKnown: ctx.isCwdKnown,
690 dest,
691 sources: files,
692 intoDir: isNoTarget ? false : target !== undefined || files.length > 1 ? true : null,
693 isOnlyNonEmpty: false,
694 }
695}
696
697type Ctx = { cwd: string[]; isCwdKnown: boolean; found: Found[]; others: number; depth: number }
698
699/** A file the command writes from the start: asked about only if it holds something now. */
700const overwrite = (tool: string, dest: Word, ctx: Ctx, isOnlyNonEmpty = true): Found => ({
701 kind: 'overwrite',
702 tool,
703 cwd: [...ctx.cwd],
704 isCwdKnown: ctx.isCwdKnown,
705 dest,
706 sources: [],
707 intoDir: false,
708 isOnlyNonEmpty,
709})
710
711/** One simple command: what it deletes goes to `ctx.found`, a `cd` moves `ctx.cwd`. */
712const readCommand = (raw: Word[], ctx: Ctx) => {
713 const { words, chdir, isPlain } = unwrap(raw)
714 if (!isPlain) ctx.others++
715 if (words.length === 0) {
716 if (raw.some(w => !KEYWORDS.has(w.text))) ctx.others++
717 return
718 }
719 if (chdir.length === 0) return readSimple(words, ctx, raw)
720 const saved = { cwd: ctx.cwd, isCwdKnown: ctx.isCwdKnown }
721 for (const dir of chdir) {
722 if (dir.isDynamic) ctx.isCwdKnown = false
723 else ctx.cwd = [...ctx.cwd, dir.text]
724 }
725 try {
726 readSimple(words, ctx, raw)
727 } finally {
728 ctx.cwd = saved.cwd
729 ctx.isCwdKnown = saved.isCwdKnown
730 }
731}
732
733const readSimple = (words: Word[], ctx: Ctx, raw: Word[]) => {
734 const head = words[0]!
735 const name = baseName(head.text)
736 const args = words.slice(1)
737 const text = words.map(w => w.text).join(' ')
738 if (head.isDynamic) {
739 ctx.others++
740 return
741 }
742 // `./rm` is read as rm, but it is some other program: never only a delete
743 if (!isSystemPath(head.text)) ctx.others++
744
745 if (name === 'cd' || name === 'pushd') {
746 const dir = operands(args)[0]
747 if (dir === undefined) ctx.cwd.push('~')
748 else if (dir.isDynamic || dir.text === '-') ctx.isCwdKnown = false
749 else ctx.cwd.push(dir.text)
750 return
751 }
752 if (name === 'popd') {
753 ctx.isCwdKnown = false
754 return
755 }
756 if (DELETERS.has(name)) {
757 const words = operands(args, name === 'shred' ? ['-n', '-s', '--iterations', '--size', '--random-source'] : [])
758 if (words.length > 0) ctx.found.push({ kind: 'paths', tool: name, cwd: [...ctx.cwd], isCwdKnown: ctx.isCwdKnown, words })
759 return
760 }
761 if (name === 'trash-empty' || name === 'trash-rm' || (name === 'gio' && args[0]?.text === 'trash' && args.some(w => w.text === '--empty'))) {
762 ctx.found.push({ kind: 'opaque', text, why: name === 'trash-rm' ? 'removes matching files from the trash for good' : 'empties the trash for good' })
763 return
764 }
765 if (name === 'gio' && (args[0]?.text === 'trash' || args[0]?.text === 'remove')) {
766 const words = operands(args.slice(1))
767 if (words.length > 0) ctx.found.push({ kind: 'paths', tool: `gio ${args[0]!.text}`, cwd: [...ctx.cwd], isCwdKnown: ctx.isCwdKnown, words })
768 return
769 }
770 if (name === 'truncate') {
771 const found = readTruncate(args, ctx)
772 if (found !== null) ctx.found.push(found)
773 else ctx.others++
774 return
775 }
776 if (name === 'find') {
777 const found = readFind(args, ctx)
778 if (found !== null) ctx.found.push(...found.found)
779 if (found === null || !found.isOnlyDeletes) ctx.others++
780 return
781 }
782 if (name === 'git') {
783 const found = readGit(args, ctx, raw.some(w => /^GIT_[A-Z_]*=/.test(w.text)))
784 if (found !== null) ctx.found.push(found)
785 // `git -c core.fsmonitor=… clean` runs that code: only `-C dir` keeps it a plain delete
786 const isPlainGit = (() => {
787 for (let i = 0; i < args.length && isFlag(args[i]!); i++) if (args[i]!.text !== '-C' || ++i >= args.length) return false
788 return true
789 })()
790 if (found === null || !isDelete(found) || !isPlainGit) ctx.others++
791 return
792 }
793 if (name === 'cp' || name === 'mv' || name === 'install' || name === 'ln') {
794 const found = readCopy(name, args, ctx)
795 if (found !== null) ctx.found.push(found)
796 ctx.others++
797 return
798 }
799 if (name === 'tee') {
800 if (!args.some(w => w.text === '--append' || /^-[a-zA-Z]*a/.test(w.text)))
801 for (const w of operands(args)) ctx.found.push(overwrite('tee', w, ctx))
802 ctx.others++
803 return
804 }
805 if (name === 'dd') {
806 const of = args.find(w => w.text.startsWith('of='))
807 if (of !== undefined) {
808 const dest: Word = { ...of, text: of.text.slice(3), pattern: of.pattern.slice(3) }
809 if (dest.text.startsWith('/dev/') && !/^\/dev\/(null|zero|stdout|stderr|fd\/)/.test(dest.text))
810 ctx.found.push({ kind: 'opaque', text, why: `writes straight to the device ${dest.text}` })
811 else ctx.found.push(overwrite('dd', dest, ctx, false))
812 }
813 ctx.others++
814 return
815 }
816 if (name === 'xargs') {
817 let i = 0
818 const withArg = ['-I', '-n', '-P', '-L', '-d', '-s', '-a', '-E', '-J', '-R', '-S']
819 const longWithArg = ['--max-args', '--max-procs', '--max-lines', '--delimiter', '--max-chars', '--arg-file', '--eof', '--process-slot-var']
820 while (i < args.length && isFlag(args[i]!)) {
821 const t = args[i]!.text
822 if (t === '--') {
823 i++
824 break
825 }
826 i += withArg.includes(t) || longWithArg.includes(t) ? 2 : 1
827 }
828 const stand: Word = { text: '{}', pattern: '{}', isDynamic: true }
829 const sub = subCommand([...args.slice(i), stand], ctx)
830 if (sub.found.some(isDelete)) {
831 ctx.found.push({ kind: 'opaque', text, why: 'deletes whatever the command before it lists' }, ...strip(sub.found, stand))
832 } else ctx.others++
833 return
834 }
835 if (name === 'rsync') {
836 if (args.some(w => /^--(del|delete(-[a-z]+)?|remove-(source|sent)-files)(=|$)/.test(w.text)))
837 ctx.found.push({ kind: 'opaque', text, why: 'removes files at the destination that the source lacks' })
838 ctx.others++
839 return
840 }
841 if ((SHELLS.has(name) || name === 'eval') && ctx.depth < 4) {
842 const { script, isPlain } = name === 'eval' ? { script: args.map(w => w.text).join(' '), isPlain: true } : shellScript(args)
843 if (script !== undefined) {
844 const sub = analyze(script, { ...ctx, cwd: [...ctx.cwd], found: [], others: 0, depth: ctx.depth + 1 })
845 ctx.found.push(...sub.found)
846 if (!sub.isOnlyDeletes || !isPlain) ctx.others++
847 return
848 }
849 }
850 if (!SAFE_EXTRA.has(name)) ctx.others++
851}
852
853/** The script of `bash -c '...'`: `c` may sit in any flag group (`-lc`, `-cf`), `--` may come first. */
854const shellScript = (args: Word[]): { script: string | undefined; isPlain: boolean } => {
855 let hasC = false
856 // a login or interactive shell, or another rc file, runs more than the script
857 let isPlain = true
858 let i = 0
859 for (; i < args.length; i++) {
860 const t = args[i]!.text
861 if (t === '--' || t === '-') {
862 i++
863 break
864 }
865 if (/^[-+][a-zA-Z]+$/.test(t)) {
866 if (t.startsWith('-') && t.includes('c')) hasC = true
867 if (!/^[-+][ceuxvo]+$/.test(t)) isPlain = false
868 if (/[oO]$/.test(t) && !['pipefail', 'errexit', 'nounset', 'xtrace'].includes(args[++i]?.text ?? '')) isPlain = false
869 } else if (t.startsWith('--')) {
870 isPlain = false
871 if (t === '--rcfile' || t === '--init-file') i++
872 } else break
873 }
874 return { script: hasC ? args[i]?.text : undefined, isPlain }
875}
876
877const analyze = (command: string, ctx: Ctx): Analysis => {
878 const { tokens, inner } = lex(command)
879 const stack: { cwd: string[]; isCwdKnown: boolean }[] = []
880 let words: Word[] = []
881 let redirect: Redirect | null = null
882 let hasRedirect = false
883 const end = () => {
884 readCommand(words, ctx)
885 words = []
886 }
887 for (const t of tokens) {
888 if (t.type === 'word') {
889 // `> file` (and `>& file`, not `2>&1`) empties the file before anything runs
890 if (redirect === 'write' || (redirect === 'dup' && !/^(\d+|-)$/.test(t.word.text))) ctx.found.push(overwrite('>', t.word, ctx))
891 if (redirect !== null) redirect = null
892 else words.push(t.word)
893 continue
894 }
895 if (t.type === 'redirect') {
896 redirect = t.mode
897 hasRedirect = true
898 continue
899 }
900 end()
901 if (t.op === '(') stack.push({ cwd: [...ctx.cwd], isCwdKnown: ctx.isCwdKnown })
902 if (t.op === ')') Object.assign(ctx, stack.pop() ?? {})
903 if (t.op === '|' || t.op === '|&') ctx.others++
904 }
905 end()
906 for (const sub of inner) {
907 if (ctx.depth >= 4) break
908 const nested = analyze(sub, { ...ctx, cwd: [...ctx.cwd], found: [], others: 0, depth: ctx.depth + 1 })
909 ctx.found.push(...nested.found)
910 ctx.others++
911 }
912 return { found: ctx.found, isOnlyDeletes: ctx.others === 0 && !hasRedirect && ctx.found.every(f => f.kind !== 'opaque' && isDelete(f)) }
913}
914
915/** What the command would delete or overwrite, and whether deleting is all it does. */
916export const findDeletes = (command: string): Analysis =>
917 analyze(command, { cwd: [], isCwdKnown: true, found: [], others: 0, depth: 0 })
918
919/** More names than this from one word are not listed: the word counts as unknown. */
920const MAX_NAMES = 1000
921
922/** Expands `{a,b}` and `{1..3}` in an escaped pattern, as bash does before globbing; `null` past `MAX_NAMES`. */
923export const expandBraces = (pattern: string): string[] | null => {
924 try {
925 return braces(pattern)
926 } catch {
927 return null
928 }
929}
930
931const braces = (pattern: string): string[] => {
932 let depth = 0
933 let open = -1
934 for (let i = 0; i < pattern.length; i++) {
935 const c = pattern[i]
936 if (c === '\\') {
937 i++
938 continue
939 }
940 if (c === '{') {
941 if (depth++ === 0) open = i
942 } else if (c === '}' && depth > 0 && --depth === 0) {
943 const body = pattern.slice(open + 1, i)
944 const pre = pattern.slice(0, open)
945 const post = pattern.slice(i + 1)
946 const range = /^(-?\d+|[a-zA-Z])\.\.(-?\d+|[a-zA-Z])(?:\.\.(-?\d+))?$/.exec(body)
947 const isLetters = range !== null && /^[a-zA-Z]$/.test(range[1]!) && /^[a-zA-Z]$/.test(range[2]!)
948 const parts: string[] = []
949 if (range !== null && (isLetters || (/\d/.test(range[1]!) && /\d/.test(range[2]!)))) {
950 const [a, b] = isLetters ? [range[1]!.charCodeAt(0), range[2]!.charCodeAt(0)] : [Number(range[1]), Number(range[2])]
951 const step = Math.max(1, Math.abs(Number(range[3] ?? 1)))
952 if (Math.abs(b - a) / step >= MAX_NAMES) throw new RangeError('too many names')
953 // `{01..10}` pads to the wider end, as bash does
954 const width = !isLetters && (/^-?0\d/.test(range[1]!) || /^-?0\d/.test(range[2]!)) ? Math.max(range[1]!.length, range[2]!.length) : 0
955 const show = (n: number) =>
956 isLetters ? String.fromCharCode(n) : n < 0 ? `-${String(-n).padStart(width - 1, '0')}` : String(n).padStart(width, '0')
957 for (let n = a; a <= b ? n <= b : n >= b; n += a <= b ? step : -step) parts.push(show(n))
958 } else if (range !== null) {
959 // `{a..3}` is not a range to bash: the word stays as it is
960 return braces(post).map(p => `${pre}{${body}}${p}`)
961 } else {
962 let d = 0
963 let from = 0
964 for (let j = 0; j < body.length; j++) {
965 if (body[j] === '\\') j++
966 else if (body[j] === '{') d++
967 else if (body[j] === '}') d--
968 else if (body[j] === ',' && d === 0) {
969 parts.push(body.slice(from, j))
970 from = j + 1
971 }
972 }
973 if (parts.length === 0) return braces(post).map(p => `${pre}{${body}}${p}`)
974 parts.push(body.slice(from))
975 }
976 const out = parts.flatMap(part => braces(pre + part + post))
977 if (out.length > MAX_NAMES) throw new RangeError('too many names')
978 return out
979 }
980 }
981 return [pattern]
982}
983
984export const hasGlob = isGlobPattern
985
986/** Drops the escapes from a pattern without glob characters. */
987export const unescape = (pattern: string) => pattern.replace(/\\(.)/g, '$1')
988
989/** One path segment's glob as a RegExp; `*` and `?` skip a leading dot as bash does. */
990export const segmentRegex = (segment: string): RegExp => {
991 let out = ''
992 for (let i = 0; i < segment.length; i++) {
993 const c = segment[i]!
994 if (c === '\\') out += (segment[++i] ?? '').replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
995 else if (c === '*') out += '[^/]*'
996 else if (c === '?') out += '[^/]'
997 else if (c === '[') {
998 const end = segment.indexOf(']', i + 2)
999 if (end < 0) out += '\\['
1000 else {
1001 let body = segment.slice(i + 1, end)
1002 const isNegated = body.startsWith('!') || body.startsWith('^')
1003 if (isNegated) body = body.slice(1)
1004 out += `[${isNegated ? '^' : ''}${body.replace(/\\/g, '\\\\')}]`
1005 i = end
1006 }
1007 } else out += c.replace(/[.+^${}()|\\/]/g, '\\$&')
1008 }
1009 const dotSafe = segment.startsWith('.') || segment.startsWith('\\.') ? '' : '(?!\\.)'
1010 return new RegExp(`^${dotSafe}${out}$`)
1011}
1012
1013/** Joins and folds `.` and `..` (POSIX paths). */
1014export const joinPath = (base: string, path: string) => {
1015 const parts = (path.startsWith('/') ? path : `${base}/${path}`).split('/')
1016 const out: string[] = []
1017 for (const p of parts) {
1018 if (p === '' || p === '.') continue
1019 if (p === '..') out.pop()
1020 else out.push(p)
1021 }
1022 return `/${out.join('/')}`
1023}
1024
1025/** An absolute path with the home folder written `~`. */
1026export const tilde = (abs: string, home: string) =>
1027 home !== '' && (abs === home || abs.startsWith(`${home}/`)) ? `~${abs.slice(home.length)}` : abs
1028
1029/** A path for display: relative to `cwd` inside it, `~` for home, absolute otherwise. */
1030export const showPath = (abs: string, cwd: string, home: string) =>
1031 abs === cwd ? '.' : abs.startsWith(`${cwd}/`) ? abs.slice(cwd.length + 1) : tilde(abs, home)
1032
1033export const bytes = (n: number) =>
1034 n >= 1024 ** 3
1035 ? `${(n / 1024 ** 3).toFixed(1)} GB`
1036 : n >= 1024 ** 2
1037 ? `${(n / 1024 ** 2).toFixed(1)} MB`
1038 : n >= 1024
1039 ? `${Math.round(n / 1024)} KB`
1040 : `${n} B`
1041types/index.d.ts 39 lines1/** One thing the command would delete, as the panel lists it. */
2export type Entry = {
3 /** As shown: relative to the working directory inside it, `~/...` or absolute. */
4 path: string
5 kind: 'dir' | 'file' | 'link' | 'missing' | 'unknown'
6 /** Bytes of a file, or of everything under a folder. */
7 size: number
8 /** Files and folders under a folder (counted up to a limit: `isPartial`). */
9 files: number
10 dirs: number
11 isPartial: boolean
12 /** A few paths inside a folder, relative to it. */
13 sample: string[]
14 /** Why it is listed this way (`matches nothing`, `decided when the command runs`). */
15 note: string
16 /** What the command does to it when that is not a plain delete. */
17 action?: 'truncate' | 'overwrite' | 'discard'
18 /** Said beside it: `emptied, the file stays`, `overwritten by cp`. */
19 change?: string
20}
21
22/** A delete waiting for the person's answer. */
23export type Request = {
24 id: string
25 command: string
26 cwd: string
27 entries: Entry[]
28 /** Parts of the command whose targets can't be listed beforehand. */
29 notes: string[]
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'delete-guard': {
35 queue: Request[]
36 }
37 }
38}
39