Holds risky Bash commands (rm -rf, git reset --hard, force push, DROP TABLE ...), shows exactly what they would destroy, and asks first. A safety net, not a…

Install (Claude Code 2.1.287 or later): /plugin install command-guard --marketplace samuelbfernandes/moddeck
Holds risky Bash commands and shows what they would destroy before they run. The question opens with one line, the risk as a plain word and what is about to happen, then the command, a few short rows and two answers: Cancel (first, so Enter means no) and Proceed. Cancel, Esc, free text, a dismissed dialog or a headless run all refuse, and the model is told why. A safety net, not a permission system: it only adds a question and never approves anything; keep your normal permissions on.
🔴 High risk · deletes data/
rm -rf data
Where Inside repository /Users/sam/thesis
Contains 2 data files (211 MB) · 1 likely secret file (4 KB) · 1 source file
Size 212 MB · 4 files · last edit within 6 hours · likely active work
Git 4 untracked files
At stake raw.csv (207 MB), fit.rds (3.9 MB), .env (4 KB)
Recoverable No · untracked files cannot be restored
Estimated from file names and your last fetch.
Run this command?
Other commands read the same way:
🔴 High risk · discards uncommitted changes in 3 files
git reset --hard origin/main
Impact Uncommitted changes to 3 tracked files, gone for good (not in the reflog)
Changes lost
M R/a.R
D b.R
M c.R
Total 3 files changed, 10 insertions(+), 4 deletions(-)
Note Untracked files are not touched
Run this command?
The level is High, Medium, Low or Unknown (Unknown: something could not be checked, and the row says what). Lists show a few rows and "+N more". A line with several risky commands gets one short section each.
Held: rm -r/-f (also rm *.csv, xargs rm), find -delete|-exec rm, git clean -f, reset --hard, checkout ./restore ., force push, branch -D, stash drop|clear, truncate, shred, dd of=, mkfs, chmod|chown -R, cargo clean, mv|cp -f onto existing files or folders, > file over a non-empty file, SQL DROP/TRUNCATE/DELETE without WHERE, and R -e unlink(recursive=TRUE). It sees through sudo, quoting, ; && pipes, bash -c, $(...) and cd /abs && ....
For a folder a held command would delete, overwrite or change recursively, the dialog adds these rows (up to 3 folders, the most dangerous first; with several folders each gets its own short block):
/, your home, Desktop/Documents/Downloads, the working directory or a parent of it. Those are always High.node_modules, target, build, dist, __pycache__, .venv, renv/library, *.Rcheck, .Rproj.user ...), source, data that is hard to regenerate (csv, rds, vcf, bam, parquet, xlsx, pdf, tex ...), likely secrets (.env, *.pem, id_*).Yes, Partly, No or Unknown, with the reason. It sets the level: only build output or a fully pushed folder is Low; irreplaceable data or unpushed work is High./plugin > command-guard: askFrom (low, medium default, high: the lowest risk level that opens the dialog; at the default a low-risk command runs without one, and unknown always asks), maxListed (rows shown under a list, default 5), extraPatterns (regexes; a matching command is held, nothing measured). Turn off: /plugin disable command-guard.
The level carries a coloured dot, since the dialog takes plain text only: 🔴 high, 🟠 medium, 🟢 low, and 🔴 unknown (a folder that could not be checked counts as red).
/command-guard reports the current level; /command-guard off|low|medium|high changes it, takes effect on the next command and is remembered across sessions. An unknown word changes nothing.
off: never ask (every Bash command runs, nothing is classified or measured)low: ask for everything, even low riskmedium: ask from medium risk up (the default)high: ask only for high risk (unknown always asks)The askFrom setting stays the default; a level you choose with the command overrides it. The choice is mirrored into the state value { plugin: 'command-guard', key: 'level' } (a string, off, low, medium or high), which other mods, such as the moddeck side bar, read with $.state.get.
git, find, du; no shell, no network), each with a timeout; a folder scan classifies at most 3,000 files ("sampled" is said) and stops at the first timeout. If a probe fails the command is still held and a Not checked row says why.make clean, scripts (Rscript x.R), rsync --delete, ssh host rm, SQL files, aliases. Relative paths use the session directory; an earlier cd may be missed.find/du); a risky command is held with a note to check the path yourself, and there are no folder rows. Nothing has been run on Windows.find/du were not run (only GNU ones, bun tests/real-folder.integration.ts).hooks/register.tsx 160 lines1// command-guard: hold risky Bash commands, show what they would destroy, ask first.
2//
3// THIS IS A SAFETY NET, NOT A PERMISSION SYSTEM. It only ever adds a question; it never
4// allows anything the normal permission prompt would not. Keep your permissions on.
5//
6// HOW THE CALL IS HELD OPEN (the one engine detail that matters here):
7// A hook has a 10 s budget of its OWN time, but the clock stops while a `next` call or
8// any `$` call is in flight (HookBudget in the API types). `$.ui.ask` is a `$` call
9// that waits for a person, so it can wait minutes at no cost. A pane with Proceed /
10// Cancel buttons would NOT work: its press arrives as a separate event, and a hook
11// waiting on a bare promise for that press burns its own budget and is timed out
12// after 10 s. So the question goes through the engine's own question dialog; the first
13// option is Cancel, which is the one the dialog highlights, so Enter means no.
14// There is no timeout of our own: the dialog stays until answered; an interrupt
15// (Esc, or the turn being stopped) rejects the ask and that is treated as Cancel.
16import { atom, update } from 'claude-code'
17import type { EngineInterface, Register } from 'claude-code'
18
19import { classify } from './classify'
20import { LEVELS, LEVEL_MEANING, asLevel, failClosedReason, isBelowBar, readConfig } from './config'
21import type { Level } from './config'
22import { denyText, dialogText, toastText } from './format'
23import { measureAll } from './measure'
24import type { Run } from './measure'
25import { looksWindows, unmeasured } from './windows'
26
27const PROBE_TIMEOUT_MS = 20_000
28const COMMAND = 'command-guard'
29
30/** Mirror of the persisted runtime level (`$.store` 'level'), so other mods can read it with $.state.get. */
31const levelAtom = atom({ plugin: 'command-guard', key: 'level' } as const, 'medium')
32
33/** The level in force: the person's runtime choice if there is one, else the `askFrom` setting. */
34async function levelNow($: EngineInterface, fallback: Level): Promise<Level> {
35 return asLevel(await safe(() => $.store.get('level'))) ?? fallback
36}
37
38const describe = (l: Level): string => `${l} (${LEVEL_MEANING[l]})`
39
40async function runCommand($: EngineInterface, args: string, fallback: Level): Promise<string> {
41 const word = args.trim().toLowerCase()
42 const current = await levelNow($, fallback)
43 if (word === '') {
44 return [
45 `Blast radius · ${describe(current)}`,
46 `Levels: ${LEVELS.map(l => `${l} = ${LEVEL_MEANING[l]}`).join(' · ')} · /command-guard <level> to change`,
47 ].join('\n')
48 }
49 const next = asLevel(word)
50 if (next === undefined) return `Blast radius · "${args.trim().slice(0, 30)}" is not a level, nothing changed · use off, low, medium or high (now ${current})`
51 try {
52 await $.store.set('level', next)
53 await update($, levelAtom, () => next)
54 } catch {
55 return 'Blast radius · that did not work, nothing changed'
56 }
57 return `Blast radius · ${describe(next)}`
58}
59
60export const register: Register = (on, options) => {
61 const cfg = readConfig(options)
62
63 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
64 // The person's runtime level overrides the setting; off is fail-open, before anything is classified.
65 const level = await levelNow($, cfg.askFrom)
66 if (level === 'off') return next(e)
67 const cwd = await safe(() => $.session.cwd())
68 const home = await safe(() => $.env.get('HOME'))
69 const found = classify(e.command, { cwd, home, extraPatterns: cfg.extraPatterns })
70 if (found.length === 0) return next(e)
71
72 const run: Run = (argv, dir, opts) => $.process.run(argv, { cwd: dir, timeoutMs: opts?.timeoutMs ?? PROBE_TIMEOUT_MS })
73 // WINDOWS: the probes below are Unix `find` / `du`. On native Windows `find` is a different program (text search), so an
74 // empty answer would look like "nothing there" and a destructive command would pass unasked. Never do that: hold
75 // the command with an honest "cannot measure here" instead. (Conditional findings such as `> file` need a file check
76 // we cannot make there, so they pass through rather than asking about every redirect.)
77 const win = await isWindows($, cwd, home)
78 const { reports, unlisted } = win ? unmeasured(found) : await measureAll(run, found, { maxListed: cfg.maxListed, home, cwd })
79 if (reports.length === 0 && unlisted === 0) return next(e) // conditions not met, e.g. `> new-file`
80 // Below the `askFrom` bar nothing is shown: a command whose every part is lower risk goes through. A part that
81 // was not checked (unlisted) or whose level is unknown always asks.
82 if (isBelowBar(reports.map(r => r.risk?.level ?? 'unknown'), unlisted, level)) return next(e)
83
84 const verdict = await confirm($, dialogText(e.command, reports, unlisted, cwd, cfg.maxListed))
85 if (verdict.isYes) {
86 await safe(() => $.ui.status(undefined))
87 return next(e)
88 }
89 const why = verdict.why
90
91 const toast = toastText(reports)
92 await safe(() => $.ui.toast(toast, { timeoutMs: 8000 }))
93 await safe(() => $.ui.status(toast))
94 await safe(() => $.clock.after(30_000, () => void safe(() => $.ui.status(undefined))))
95 return { deny: denyText(why, e.command, reports) }
96 }).catch(($, e, next) => {
97 // FAIL CLOSED, but only where the text itself looks destructive. The classifier or the
98 // measurer (or the dialog code) threw, or the hook outran its budget: if the command
99 // matches the coarse FALLBACK_RISKY regex or one of the user's own patterns we refuse
100 // rather than guess; anything else goes through, so a bug here cannot wedge ordinary
101 // work. Where `next` was already called (the user pressed Proceed) we just replay it.
102 if (next.called) return next(e)
103 const deny = failClosedReason((e as { command?: unknown }).command, cfg)
104 return deny === undefined ? next(e) : { deny }
105 })
106
107 on('session.start', async ($, e, next) => {
108 const level = await levelNow($, cfg.askFrom)
109 await safe(() => update($, levelAtom, () => level)) // the mirror starts from what is stored, else the setting
110 await safe(() =>
111 $.command.register({
112 name: COMMAND,
113 description: 'Change how much command-guard asks about: /command-guard [off|low|medium|high]',
114 argumentHint: '[off|low|medium|high]',
115 }),
116 ) // fail-open: without the command the setting still works
117 if (cfg.badPatterns.length)
118 await safe(() =>
119 $.ui.toast(`command-guard: ignored invalid extra patterns: ${cfg.badPatterns.map(p => p.replace(/\s+/g, ' ')).join(', ')}`, { timeoutMs: 10_000 }),
120 )
121 return next(e)
122 })
123
124 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await runCommand($, e.args, cfg.askFrom) })).catch(() => ({
125 text: 'Blast radius · that did not work, nothing changed',
126 }))
127}
128
129async function isWindows($: EngineInterface, cwd: string | undefined, home: string | undefined): Promise<boolean> {
130 if (looksWindows(cwd) || looksWindows(home)) return true
131 return (await safe(() => $.env.get('OS'))) === 'Windows_NT'
132}
133
134type Verdict = { isYes: true } | { isYes: false; why: string }
135
136// Only an exact "Proceed" lets the command run; Cancel, free text under Other, Esc, a
137// rejected dialog and a run with no surface at all are all a no.
138async function confirm($: EngineInterface, text: string): Promise<Verdict> {
139 const surfaces = await safe(() => $.session.surfaces())
140 if (surfaces !== undefined && surfaces.length === 0) return { isYes: false, why: 'no screen to ask on in this run' }
141 try {
142 const answer = await $.ui.ask(text, {
143 options: ['Cancel', 'Proceed'], // Cancel first: the dialog opens on it
144 header: 'Blast radius',
145 })
146 if (answer === 'Proceed') return { isYes: true }
147 return { isYes: false, why: answer === 'Cancel' ? 'cancelled in the dialog' : `answered "${answer.slice(0, 40).replace(/\s+/g, ' ')}", not Proceed` }
148 } catch {
149 return { isYes: false, why: 'no answer: dialog dismissed, or no one to ask' }
150 }
151}
152
153async function safe<T>(f: () => T | Promise<T>): Promise<T | undefined> {
154 try {
155 return await f()
156 } catch {
157 return undefined
158 }
159}
160hooks/classify.ts 511 lines1// The classifier: a PURE function from a shell command line to the list of risky
2// simple commands in it. No `$`, no file access: whether a target really exists is
3// the measurer's business. Anything it cannot see through (a variable as command
4// name, nesting deeper than DEPTH_LIMIT) is reported rather than waved through.
5import { lex } from './lex'
6import type { Tok, Word } from './lex'
7
8export type Kind =
9 | 'rm' | 'find' | 'truncate' | 'shred' | 'dd' | 'mkfs' | 'redirect' | 'overwrite' | 'chmod'
10 | 'git-clean' | 'git-reset' | 'git-discard' | 'git-push' | 'git-branch' | 'git-stash'
11 | 'sql' | 'r' | 'cargo-clean' | 'custom' | 'opaque'
12
13/** A path a command acts on. `path` is absolute and normalised; absent when `unresolved` says why. */
14export type Target = { raw: string; path?: string; glob: boolean; unresolved?: string }
15
16export type Finding = {
17 kind: Kind
18 reason: string // one line, shown under the command
19 command: string // the simple command as the shell would see it
20 cwd?: string // directory it runs in, undefined when a `cd` could not be followed
21 args: string[] // words after the command name (after the git subcommand for git)
22 targets: Target[]
23 critical?: boolean // targets / or the home directory
24 viaXargs?: boolean // the real operands arrive on stdin
25 // Held only if the measurer finds the condition true (the classifier cannot stat files).
26 conditional?: 'non-empty-file' | 'dest-exists'
27}
28
29export type ClassifyContext = {
30 cwd?: string // the session's working directory
31 home?: string // for `~`
32 extraPatterns?: readonly RegExp[] // the user's own, tested against the whole command line
33}
34
35type State = { cwd: string | undefined }
36type Ctx = ClassifyContext & { out: Finding[] }
37
38const DEPTH_LIMIT = 6
39
40export function classify(command: string, ctx: ClassifyContext = {}): Finding[] {
41 const out: Finding[] = []
42 scan(command, { cwd: ctx.cwd }, { ...ctx, out }, 0)
43 for (const re of ctx.extraPatterns ?? [])
44 if (re.test(command))
45 out.push(finding('custom', `matches your extra pattern ${re}`, [], [], undefined, command))
46 return out
47}
48
49// ---- paths ---------------------------------------------------------------------
50
51export function normalize(path: string): string {
52 const parts: string[] = []
53 for (const p of path.split('/'))
54 if (p === '..') parts.pop()
55 else if (p !== '' && p !== '.') parts.push(p)
56 return '/' + parts.join('/')
57}
58
59function target(w: Word, st: State, ctx: ClassifyContext): Target {
60 const base = { raw: w.text, glob: w.glob }
61 if (w.dynamic) return { ...base, unresolved: 'contains a variable or command substitution' }
62 if (w.text === '') return { ...base, unresolved: 'empty path' }
63 let t = w.text
64 if (w.tilde) {
65 if (t !== '~' && !t.startsWith('~/')) return { ...base, unresolved: 'another user\'s home' }
66 if (!ctx.home) return { ...base, unresolved: 'home directory unknown' }
67 t = ctx.home + t.slice(1)
68 }
69 if (!t.startsWith('/')) {
70 if (!st.cwd) return { ...base, unresolved: 'relative path after a cd that could not be followed' }
71 t = `${st.cwd}/${t}`
72 }
73 return { ...base, path: normalize(t) }
74}
75
76const isCritical = (t: Target, home?: string) =>
77 /^(\/|~\/?|\$HOME\/?|\.|\.\/|\.\.|\.\.\/)\*?$/.test(t.raw) ||
78 t.path === '/' || (home !== undefined && t.path === home) || t.raw === '/*'
79
80// ---- argument helpers --------------------------------------------------------------
81
82type Flags = { short: Set<string>; long: Set<string>; operands: Word[] }
83
84// Options may appear anywhere before `--` (GNU permutes). `valued` lists short options
85// that take a separate value word, `valuedLong` the long ones; their values are not operands.
86function flags(args: Word[], valued = '', valuedLong: string[] = []): Flags {
87 const f: Flags = { short: new Set(), long: new Set(), operands: [] }
88 let ended = false
89 for (let k = 0; k < args.length; k++) {
90 const t = args[k]!.text
91 if (ended || !t.startsWith('-') || t === '-') f.operands.push(args[k]!)
92 else if (t === '--') ended = true
93 else if (t.startsWith('--')) {
94 const name = t.slice(2).split('=')[0]!
95 f.long.add(name)
96 if (!t.includes('=') && valuedLong.includes(name)) k++
97 } else {
98 for (let c = 1; c < t.length; c++) {
99 f.short.add(t[c]!)
100 if (valued.includes(t[c]!)) {
101 if (c === t.length - 1) k++
102 break
103 }
104 }
105 }
106 }
107 return f
108}
109
110const base = (s: string) => s.slice(s.lastIndexOf('/') + 1)
111const isAssignment = (w: Word) => /^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(w.text)
112const show = (w: Word) => (/[\s'"$;|&<>()]/.test(w.text) || w.text === '' ? JSON.stringify(w.text) : w.text)
113
114function finding(
115 kind: Kind, reason: string, words: Word[], targets: Target[], cwd: string | undefined,
116 commandText?: string, extra: Partial<Finding> = {},
117): Finding {
118 return {
119 kind, reason, command: commandText ?? words.map(show).join(' '), cwd,
120 args: [], targets, ...extra,
121 }
122}
123
124// ---- wrappers: sudo, env, time, xargs ... -------------------------------------------
125
126// `valued`: short options of the wrapper that take a value; `positional`: operands to skip.
127const WRAPPERS: Record<string, { valued: string; positional?: number }> = {
128 sudo: { valued: 'ughpCDrtTUR' }, doas: { valued: 'uC' }, env: { valued: 'uCS' },
129 time: { valued: 'fo' }, nice: { valued: 'n' }, ionice: { valued: 'cnpt' }, nohup: { valued: '' },
130 command: { valued: '' }, builtin: { valued: '' }, exec: { valued: 'a' }, stdbuf: { valued: 'ioe' },
131 setsid: { valued: '' }, caffeinate: { valued: 'tw' }, chronic: { valued: '' }, unbuffer: { valued: '' },
132 timeout: { valued: 'sk', positional: 1 }, gtimeout: { valued: 'sk', positional: 1 },
133 xargs: { valued: 'INPdaELsJRS' },
134}
135// Words that start a command in compound statements: `if x; then rm -rf y; fi`.
136const RESERVED = new Set(['if', 'then', 'else', 'elif', 'while', 'until', 'do', '!', '{', 'function'])
137
138function skipWrapperOptions(words: Word[], from: number, spec: { valued: string; positional?: number }): number {
139 let i = from
140 while (i < words.length) {
141 const t = words[i]!.text
142 if (t === '--') return i + 1 + (spec.positional ?? 0)
143 if (isAssignment(words[i]!)) i++
144 else if (t.startsWith('-') && t.length > 1) {
145 if (!t.startsWith('--'))
146 for (let c = 1; c < t.length; c++)
147 if (spec.valued.includes(t[c]!)) {
148 if (c === t.length - 1) i++
149 break
150 }
151 i++
152 } else break
153 }
154 return i + (spec.positional ?? 0)
155}
156
157type Unwrapped = { name: string; args: Word[]; viaXargs: boolean; dynamicName: boolean; words: Word[] }
158
159function unwrap(all: Word[]): Unwrapped | undefined {
160 let i = 0
161 let viaXargs = false
162 while (i < all.length) {
163 const w = all[i]!
164 if (isAssignment(w) || (!w.dynamic && RESERVED.has(w.text))) i++
165 else if (w.dynamic)
166 return { name: '', args: all.slice(i + 1), viaXargs, dynamicName: true, words: all.slice(i) }
167 else {
168 const name = base(w.text)
169 const spec = WRAPPERS[name]
170 if (!spec) return { name, args: all.slice(i + 1), viaXargs, dynamicName: false, words: all.slice(i) }
171 if (name === 'xargs') viaXargs = true
172 i = skipWrapperOptions(all, i + 1, spec)
173 }
174 }
175 return undefined
176}
177
178// ---- the walk: lists, pipelines, subshells, cd -----------------------------------------
179
180type Simple = {
181 words: Word[]
182 redirs: { op: string; fd?: string; target?: Word }[]
183 heredocs: string[]
184}
185const emptyCmd = (): Simple => ({ words: [], redirs: [], heredocs: [] })
186
187function scan(src: string, st: State, ctx: Ctx, depth: number): void {
188 if (depth > DEPTH_LIMIT) {
189 ctx.out.push(finding('opaque', 'nested too deeply to analyse', [], [], st.cwd, src.slice(0, 80)))
190 return
191 }
192 walk(lex(src), st, ctx, depth)
193}
194
195function walk(toks: Tok[], st: State, ctx: Ctx, depth: number): void {
196 let pipeline: Simple[] = []
197 let cmd = emptyCmd()
198 const endCmd = () => {
199 if (cmd.words.length || cmd.redirs.length) pipeline.push(cmd)
200 cmd = emptyCmd()
201 }
202 const endPipeline = () => {
203 endCmd()
204 if (pipeline.length) analyzePipeline(pipeline, st, ctx, depth)
205 pipeline = []
206 }
207 for (let k = 0; k < toks.length; k++) {
208 const tk = toks[k]!
209 if (tk.t === 'word') cmd.words.push(tk.w)
210 else if (tk.t === 'redir') {
211 const next = toks[k + 1]
212 const t = next?.t === 'word' ? next.w : undefined
213 if (t) k++
214 cmd.redirs.push({ op: tk.op, ...(tk.fd ? { fd: tk.fd } : {}), ...(t ? { target: t } : {}) })
215 if (tk.body !== undefined) cmd.heredocs.push(tk.body)
216 if (tk.op === '<<<' && t) cmd.heredocs.push(t.text)
217 } else if (tk.op === '(') {
218 const close = matchParen(toks, k)
219 if (cmd.words.length === 0) {
220 // A subshell: a `cd` inside it does not leak out.
221 walk(toks.slice(k + 1, close), { ...st }, ctx, depth)
222 }
223 else endPipeline() // a function definition `f() { ... }`: its body is walked as ordinary commands
224 k = close
225 } else if (tk.op === ')') endPipeline() // `case x in a) ...`
226 else if (tk.op === '|') endCmd()
227 else endPipeline()
228 }
229 endPipeline()
230}
231
232function matchParen(toks: Tok[], open: number): number {
233 let depth = 0
234 for (let k = open; k < toks.length; k++) {
235 const t = toks[k]!
236 if (t.t === 'op' && t.op === '(') depth++
237 else if (t.t === 'op' && t.op === ')' && --depth === 0) return k
238 }
239 return toks.length
240}
241
242const SQL_CLIENT = /^(psql|sqlite3?|mysql|mariadb|duckdb)$/
243
244function analyzePipeline(cmds: Simple[], st: State, ctx: Ctx, depth: number): void {
245 const single = cmds.length === 1
246 // SQL fed through a pipe or heredoc belongs to the client at the end of the pipeline.
247 const sqlText = cmds.some(c => SQL_CLIENT.test(unwrap(c.words)?.name ?? ''))
248 ? cmds.flatMap(c => [...c.words.filter(w => !w.text.startsWith('--')).map(w => w.text), ...c.heredocs]).join(' ')
249 : undefined
250 for (const c of cmds) analyzeCommand(c, single ? st : { ...st }, ctx, depth, sqlText)
251}
252
253function analyzeCommand(c: Simple, st: State, ctx: Ctx, depth: number, sqlText?: string): void {
254 for (const w of [...c.words, ...c.redirs.flatMap(r => (r.target ? [r.target] : []))])
255 for (const sub of w.subs) scan(sub, { ...st }, ctx, depth + 1)
256 clobbers(c, st, ctx)
257 const u = unwrap(c.words)
258 if (!u) return
259 const text = u.words.map(show).join(' ')
260 if (u.dynamicName) {
261 const hot = u.args.some(a => /^-[a-zA-Z]*(rf|fr|Rf|fR)|^--recursive$/.test(a.text))
262 if (hot)
263 ctx.out.push(finding('opaque', 'the command name is a variable and the flags look like rm -rf', u.words, [], st.cwd, text))
264 return
265 }
266 const here = { ...ctx, st, u, text, sqlText, depth }
267 if (u.name === 'cd' || u.name === 'pushd') return cd(u.args, st, ctx)
268 if (u.name === 'popd') return void (st.cwd = undefined)
269 if (/^(ba|z|da|k|a)?sh$/.test(u.name)) return shellC(here)
270 if (u.name === 'eval') return scan(u.args.map(a => a.text).join(' '), { ...st }, ctx, depth + 1)
271 const hit = rules(here)
272 if (hit) ctx.out.push(...hit)
273}
274
275function cd(args: Word[], st: State, ctx: ClassifyContext): void {
276 const operands = flags(args).operands
277 if (operands.length === 0) {
278 st.cwd = ctx.home
279 return
280 }
281 const t = target(operands[0]!, st, ctx)
282 st.cwd = operands[0]!.text === '-' || t.glob ? undefined : t.path
283}
284
285type Here = Ctx & { st: State; u: Unwrapped; text: string; sqlText: string | undefined; depth: number }
286
287// bash -c 'script', sh -lc "script"
288function shellC(h: Here): void {
289 const a = h.u.args
290 const k = a.findIndex(w => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(w.text))
291 const script = k >= 0 ? a[k + 1] : undefined
292 if (script) scan(script.text, { ...h.st }, h, h.depth + 1)
293}
294
295// `> file` and `&> file` truncate; `>>` appends. Whether the file exists is for the measurer.
296function clobbers(c: Simple, st: State, ctx: Ctx): void {
297 for (const r of c.redirs) {
298 if (!['>', '>|', '&>'].includes(r.op) || !r.target) continue
299 if (r.target.text.startsWith('/dev/')) continue
300 const t = target(r.target, st, ctx)
301 if (t.unresolved) continue // unknowable target: not held (documented limit)
302 const text = [...c.words.map(show), r.op, show(r.target)].join(' ')
303 ctx.out.push(finding('redirect', `${r.op} overwrites ${t.raw} if it already has content`, [], [t], st.cwd, text, {
304 conditional: 'non-empty-file',
305 }))
306 }
307}
308
309// ---- the per-command rules ------------------------------------------------------------
310
311function rules(h: Here): Finding[] | undefined {
312 const { name, args } = h.u
313 const one = (f: Finding | undefined) => (f ? [f] : undefined)
314 const targets = (ws: Word[]) => ws.map(w => target(w, h.st, h))
315 const mk = (kind: Kind, reason: string, ts: Word[], extra: Partial<Finding> = {}) => {
316 const tt = targets(ts)
317 return finding(kind, reason, h.u.words, tt, h.st.cwd, h.text, {
318 args: args.map(a => a.text), critical: tt.some(t => isCritical(t, h.home)) || undefined,
319 viaXargs: h.u.viaXargs || undefined, ...extra,
320 })
321 }
322
323 switch (true) {
324 case name === 'rm': {
325 const f = flags(args)
326 const recursive = f.short.has('r') || f.short.has('R') || f.long.has('recursive')
327 const force = f.short.has('f') || f.long.has('force')
328 const bulk = h.u.viaXargs || f.operands.some(o => o.glob)
329 if (!recursive && !force && !bulk) return undefined // plain `rm file` is not held
330 const why = recursive ? 'recursive delete (rm -r): not recoverable' : force ? 'forced delete (rm -f): not recoverable' : 'bulk delete by glob or xargs: not recoverable'
331 return one(mk('rm', why, f.operands))
332 }
333 case name === 'find': return one(findRule(h, mk))
334 case name === 'git': return gitRule(h, mk)
335 case name === 'truncate': {
336 const f = flags(args, 'sr', ['size', 'reference'])
337 return one(mk('truncate', 'truncate cuts file contents', f.operands))
338 }
339 case name === 'shred': return one(mk('shred', 'shred overwrites file contents irrecoverably', flags(args, 'ns', ['iterations', 'size', 'random-source']).operands))
340 case name === 'dd': {
341 const of = args.find(a => a.text.startsWith('of='))
342 if (!of) return undefined
343 const path = of.text.slice(3)
344 if (/^\/dev\/(null|zero|stdout|stderr|tty|fd\/\d+)$/.test(path)) return undefined
345 return one(mk('dd', `dd writes over ${path}`, [{ ...of, text: path }]))
346 }
347 case /^(mkfs(\..+)?|mke2fs|newfs(_.+)?|mkswap|wipefs)$/.test(name):
348 return one(mk('mkfs', `${name} destroys everything on the target device`, flags(args).operands.slice(-1)))
349 case name === 'diskutil' && /^(erase|reformat|partitionDisk|zeroDisk|secureErase)/i.test(args[0]?.text ?? ''):
350 return one(mk('mkfs', `diskutil ${args[0]!.text} destroys data on the target disk`, []))
351 case /^(chmod|chown|chgrp)$/.test(name): {
352 const f = flags(args)
353 if (!f.long.has('recursive') && !f.short.has('R')) return undefined
354 return one(mk('chmod', `${name} -R rewrites permissions or ownership of a whole tree`, f.long.has('reference') ? f.operands : f.operands.slice(1)))
355 }
356 case (name === 'mv' || name === 'cp'): {
357 const f = flags(args, 't', ['target-directory'])
358 if (!(f.short.has('f') || f.long.has('force')) || f.short.has('n') || f.long.has('no-clobber')) return undefined
359 if (f.operands.length < 2) return undefined
360 const dest = f.operands[f.operands.length - 1]!
361 const srcs = f.operands.slice(0, -1)
362 return one(mk('overwrite', `${name} -f replaces existing files without asking`, [dest], {
363 conditional: 'dest-exists', args: srcs.map(s => s.text),
364 }))
365 }
366 case name === 'cargo': {
367 const sub = args.find(a => !a.text.startsWith('-') && !a.text.startsWith('+'))
368 if (sub?.text !== 'clean') return undefined
369 return one(mk('cargo-clean', 'cargo clean deletes the target/ directory (a full rebuild follows)', []))
370 }
371 case SQL_CLIENT.test(name): return one(sqlRule(h, mk))
372 case name === 'R' || name === 'Rscript': return rRule(h, mk)
373 default: return undefined
374 }
375}
376
377type Mk = (kind: Kind, reason: string, ts: Word[], extra?: Partial<Finding>) => Finding
378
379// find ... -delete / -exec rm
380function findRule(h: Here, mk: Mk): Finding | undefined {
381 const t = h.u.args.map(a => a.text)
382 const del = t.includes('-delete')
383 const exec = t.findIndex((x, k) => /^-(exec|execdir|ok|okdir)$/.test(x) && isRemover(t.slice(k + 1)))
384 if (!del && exec < 0) return undefined
385 return mk('find', del ? 'find -delete removes every match' : 'find -exec rm removes every match', [])
386}
387
388const isRemover = (rest: string[]) => {
389 const c = base(rest[0] ?? '')
390 return /^(rm|unlink|shred|rmdir)$/.test(c) || (/^(ba|z)?sh$/.test(c) && /\brm\b/.test(rest.slice(1).join(' ')))
391}
392
393const BROAD = /^(\.|\.\/|\*|:\/|:\/\.|:\/\*|:\(top\))$/
394
395function gitRule(h: Here, mk: Mk): Finding[] | undefined {
396 const a = h.u.args
397 let i = 0
398 let cwd = h.st.cwd
399 while (i < a.length && a[i]!.text.startsWith('-')) {
400 const t = a[i]!.text
401 if (t === '-C' && a[i + 1]) {
402 const r = target(a[i + 1]!, { cwd }, h)
403 cwd = r.path
404 i += 2
405 } else i += ['-c', '--git-dir', '--work-tree', '--namespace'].includes(t) ? 2 : 1
406 }
407 const sub = a[i]?.text
408 const rest = a.slice(i + 1)
409 const f = flags(rest)
410 const texts = rest.map(r => r.text)
411 const make = (kind: Kind, reason: string, ts: Word[] = []) => {
412 const m = mk(kind, reason, ts, { args: texts })
413 return { ...m, cwd }
414 }
415 const cluster = (c: string) => f.short.has(c)
416
417 switch (sub) {
418 case 'clean':
419 if (f.long.has('dry-run') || cluster('n') || cluster('i') || f.long.has('interactive')) return undefined
420 return cluster('f') || f.long.has('force') ? [make('git-clean', 'git clean -f deletes untracked files: not recoverable')] : undefined
421 case 'reset':
422 return f.long.has('hard') ? [make('git-reset', 'git reset --hard discards uncommitted changes and moves the branch')] : undefined
423 case 'checkout': {
424 const afterDashes = texts.includes('--') ? texts.slice(texts.indexOf('--') + 1) : []
425 const broad = afterDashes.some(x => BROAD.test(x)) || (afterDashes.length === 0 && f.operands.length === 1 && BROAD.test(f.operands[0]!.text))
426 if (broad || cluster('f') || f.long.has('force'))
427 return [make('git-discard', 'git checkout discards uncommitted changes to tracked files')]
428 return undefined
429 }
430 case 'restore': {
431 const staged = f.long.has('staged') || cluster('S')
432 const worktree = !staged || cluster('W') || f.long.has('worktree')
433 return worktree && f.operands.some(o => BROAD.test(o.text))
434 ? [make('git-discard', 'git restore discards uncommitted changes to tracked files')] : undefined
435 }
436 case 'push': return pushRule(f, make)
437 case 'branch': {
438 const del = cluster('D') || ((cluster('d') || f.long.has('delete')) && (cluster('f') || f.long.has('force')))
439 return del ? [make('git-branch', 'git branch -D deletes a branch even if unmerged')] : undefined
440 }
441 case 'stash': {
442 const op = f.operands[0]?.text
443 return op === 'drop' || op === 'clear' ? [make('git-stash', `git stash ${op} deletes stashed work`)] : undefined
444 }
445 default: return undefined
446 }
447}
448
449function pushRule(f: Flags, make: (k: Kind, r: string, t?: Word[]) => Finding): Finding[] | undefined {
450 const refspecs = f.operands.slice(1).map(o => o.text)
451 const force = f.long.has('force') || f.long.has('force-with-lease') || f.short.has('f') || refspecs.some(r => r.startsWith('+'))
452 const del = f.long.has('delete') || f.short.has('d') || refspecs.some(r => r.startsWith(':')) || f.long.has('mirror')
453 if (!force && !del) return undefined
454 return [make('git-push', del && !force ? 'git push --delete removes remote branches or tags' : 'a forced git push can overwrite commits on the remote')]
455}
456
457function sqlRule(h: Here, mk: Mk): Finding | undefined {
458 const text = h.sqlText ?? ''
459 const bad = destructiveSql(text)
460 return bad.length ? mk('sql', `destructive SQL: ${bad.join('; ')}`, [], { args: bad }) : undefined
461}
462
463/** Statements in `sql` that drop, truncate, or delete without WHERE. Exported for tests. */
464export function destructiveSql(sql: string): string[] {
465 const found: string[] = []
466 for (const stmt of sql.split(';')) {
467 const s = stmt.replace(/--.*$/gm, ' ').replace(/\s+/g, ' ').trim()
468 const m = /\b(DROP\s+\w+(\s+IF\s+EXISTS)?\s+\S+|TRUNCATE(\s+TABLE)?\s+\S+|DELETE\s+FROM\s+\S+)/i.exec(s)
469 if (!m) continue
470 if (/^DELETE\b/i.test(m[1]!) && /\bWHERE\b/i.test(s)) continue
471 found.push(m[1]!.slice(0, 60))
472 }
473 return found
474}
475
476// R -e / Rscript -e: unlink(recursive=TRUE), file.remove, fs::dir_delete, system("rm ...")
477function rRule(h: Here, mk: Mk): Finding[] | undefined {
478 const a = h.u.args
479 const code = a.flatMap((w, k) => (w.text === '-e' || w.text === '--expr' ? [a[k + 1]?.text ?? ''] : w.text.startsWith('--expr=') ? [w.text.slice(7)] : [])).join('\n')
480 if (!code) return undefined
481 const out: Finding[] = []
482 for (const m of code.matchAll(/\b(system2?|shell)\s*\(\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g))
483 scan(m[2]!.slice(1, -1), { ...h.st }, h, h.depth + 1)
484 // Only a literal first argument (or c("a", "b")) can be named; anything computed is "could not measure".
485 const calls = [...code.matchAll(/\b(?:unlink|file\.remove|dir_delete|file_delete)\s*\(\s*(?:c\(([^)]*)\)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'))/g)]
486 const risky = /\bunlink\s*\(.*recursive\s*=\s*(TRUE|T)\b/s.test(code) || /\b(file\.remove|dir_delete|file_delete)\s*\(/.test(code)
487 if (!risky) return out.length ? out : undefined
488 const lits = calls.flatMap(m => [...(m[1] ?? m[2]!).matchAll(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/g)].map(l => l[1] ?? l[2] ?? ''))
489 const words: Word[] = lits.map(text => ({ text, glob: /[*?]/.test(text), dynamic: false, tilde: text.startsWith('~'), subs: [] }))
490 out.push(mk('r', 'R code deletes files or directories (unlink / file.remove / dir_delete)', words, { args: [] }))
491 return out
492}
493
494// ---- last-resort regex ------------------------------------------------------------------
495
496/**
497 * Trivially safe fallback for when the classifier itself throws: coarse on purpose,
498 * it only has to say "this text looks destructive" so the hook can fail closed.
499 */
500export const FALLBACK_RISKY = new RegExp(
501 [
502 String.raw`\brm\s+(-\w*[rRf]|--(recursive|force))`,
503 String.raw`\bfind\b.*(-delete|-exec\s+\S*rm\b)`,
504 String.raw`\bgit\b.*\b(clean\s+-\w*f|reset\s+--hard|push\b.*(--force|--delete|\s-\w*f|\s\+)|branch\s+-D|stash\s+(drop|clear)|checkout\s+(--\s+)?\.|restore\s+\.)`,
505 String.raw`\b(truncate|shred|mkfs\S*|newfs\S*)\b`, String.raw`\bdd\b.*\bof=`,
506 String.raw`\b(chmod|chown|chgrp)\s+-\w*R`, String.raw`\bcargo\s+clean\b`,
507 String.raw`\b(DROP\s+\w+|TRUNCATE|DELETE\s+FROM)\b`, String.raw`\bunlink\s*\(`,
508 String.raw`(^|[^>&0-9])>\s*(?!\/dev\/)[^&>\s]`,
509 ].join('|'), 'i',
510)
511hooks/config.ts 62 lines1import type { PluginOptions } from 'claude-code'
2
3import { FALLBACK_RISKY } from './classify'
4
5export type AskFrom = 'low' | 'medium' | 'high'
6/** The runtime choice: the three `askFrom` bars plus `off` (never ask). */
7export type Level = AskFrom | 'off'
8export const LEVELS: readonly Level[] = ['off', 'low', 'medium', 'high']
9
10/** One line per level, for `/command-guard` with no argument. */
11export const LEVEL_MEANING: Record<Level, string> = {
12 off: 'never ask',
13 low: 'ask for everything, even low risk',
14 medium: 'ask from medium risk up',
15 high: 'ask only for high risk',
16}
17
18/** A stored or typed word as a level, or undefined when it is none. */
19export const asLevel = (v: unknown): Level | undefined => {
20 const t = typeof v === 'string' ? v.trim().toLowerCase() : ''
21 return (LEVELS as readonly string[]).includes(t) ? (t as Level) : undefined
22}
23export type BlastConfig = { maxListed: number; extraPatterns: RegExp[]; badPatterns: string[]; askFrom: AskFrom }
24
25/** Rank of a risk level for the `askFrom` bar. Unknown (not checked) ranks with high: it is never skipped. */
26export const RANK = { low: 0, medium: 1, high: 2, unknown: 2 } as const
27
28/** True when nothing needs a dialog: every part is below the `askFrom` bar and none went unchecked (`unlisted`). */
29export const isBelowBar = (levels: ('low' | 'medium' | 'high' | 'unknown')[], unlisted: number, askFrom: AskFrom): boolean =>
30 unlisted === 0 && levels.every(l => RANK[l] < RANK[askFrom])
31
32/** Options from plugin.json userConfig. A pattern that is not a valid regex is reported, never silently dropped. */
33export function readConfig(options: PluginOptions): BlastConfig {
34 const n = Number(options.maxListed)
35 const maxListed = Number.isFinite(n) ? Math.min(200, Math.max(5, Math.round(n))) : 5
36 const raw = Array.isArray(options.extraPatterns) ? (options.extraPatterns as string[]) : []
37 const extraPatterns: RegExp[] = []
38 const badPatterns: string[] = []
39 for (const p of raw) {
40 try {
41 extraPatterns.push(new RegExp(p))
42 } catch {
43 badPatterns.push(p)
44 }
45 }
46 const askFrom: AskFrom = options.askFrom === 'low' || options.askFrom === 'high' ? options.askFrom : 'medium'
47 return { maxListed, extraPatterns, badPatterns, askFrom }
48}
49
50/**
51 * The fail-closed rule, as a pure function: when the classifier, the measurer or the dialog
52 * code throws, refuse only a command whose text looks destructive to the coarse fallback
53 * regex (or to one of the user's own patterns). Returns the refusal, or undefined to let it run.
54 */
55export function failClosedReason(command: unknown, cfg: BlastConfig): string | undefined {
56 const text = typeof command === 'string' ? command : ''
57 const risky = FALLBACK_RISKY.test(text) || cfg.extraPatterns.some(re => re.test(text))
58 return risky
59 ? `Blocked by command-guard: could not analyse this command and it looks destructive. Not run: ${text.replace(/\s+/g, ' ').trim().slice(0, 200)}. Ask the user to confirm, then retry.`
60 : undefined
61}
62hooks/format.ts 140 lines1// Text for the dialog, the refusal the model reads and the toast. Plain text only: the dialog is the
2// engine's own question dialog, which draws the same on every surface. Layout, top to bottom:
3// a one-line headline ("High risk · deletes data/"), the command, a few "Label value" rows
4// (one honesty note at most), then the question. Nothing here contains ALL-CAPS words.
5import type { Finding } from './classify'
6import type { Row } from './folder'
7import type { Report } from './measure'
8import { PUSHED_NOTE } from './recover'
9import type { RiskLevel } from './recover'
10
11const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
12const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
13// first letter up, unless the text starts with a command name ("git diff failed")
14const cap1 = (s: string) => (/^(git|rm|cp|mv|chmod|chown|find|du|dd|cargo|psql|mysql|sqlite3)\b/.test(s) ? s : s.charAt(0).toUpperCase() + s.slice(1))
15
16const LABEL_WIDTH = 13
17const DEFAULT_ROWS = 5 // rows shown under a list heading; the rest is "+N more"
18const MAX_NOTES = 2
19const FOOTNOTE = 'Estimated from file names and your last fetch.'
20// The dialog takes plain text only, so the level is coloured with a dot. Unknown counts as red: it was not checked.
21const WORD: Record<RiskLevel, string> = { high: '🔴 High', medium: '🟠 Medium', low: '🟢 Low', unknown: '🔴 Unknown' }
22const WORST_FIRST: RiskLevel[] = ['high', 'unknown', 'medium', 'low']
23
24const row = (label: string, value: string) => `${label.padEnd(LABEL_WIDTH)}${value}`
25const levelOf = (r: Report): RiskLevel => r.risk?.level ?? 'unknown'
26const worst = (reports: Report[]): RiskLevel => WORST_FIRST.find(l => reports.some(r => levelOf(r) === l)) ?? 'unknown'
27
28// What the command does, when the measurer could not say.
29function fallbackAction(f: Finding): string {
30 const names = f.targets.map(t => t.raw)
31 const what = (none: string) => (names.length === 1 ? clip(names[0]!, 50) : names.length > 1 ? `${names.length} paths` : none)
32 switch (f.kind) {
33 case 'rm': return `deletes ${f.viaXargs ? 'the files it is given' : what('files')}`
34 case 'r': return 'deletes files from R code'
35 case 'truncate': return `empties ${what('files')}`
36 case 'shred': return `shreds ${what('files')}`
37 case 'chmod': return `changes permissions of ${what('a tree')}`
38 case 'cargo-clean': return 'deletes target/'
39 case 'redirect': case 'overwrite': return `overwrites ${what('files')}`
40 case 'dd': return 'overwrites data'
41 case 'mkfs': return 'erases a disk'
42 case 'find': return 'deletes every match'
43 case 'git-clean': return 'deletes untracked files'
44 case 'git-reset': case 'git-discard': return 'discards uncommitted changes'
45 case 'git-push': return 'overwrites remote history'
46 case 'git-branch': return 'deletes a branch'
47 case 'git-stash': return 'deletes stashed work'
48 case 'sql': return 'runs destructive SQL'
49 case 'custom': return 'matches your extra pattern'
50 case 'opaque': return clip(f.reason, 70)
51 }
52}
53const actionOf = (r: Report) => r.action ?? fallbackAction(r.finding)
54
55function list(title: string, rows: string[], more: number, max: number): string[] {
56 if (rows.length === 0) return []
57 const shown = rows.slice(0, max)
58 const left = more + rows.length - shown.length
59 return [title, ...shown.map(x => ` ${clip(x, 110)}`), ...(left > 0 ? [` +${left} more`] : [])]
60}
61
62function folderRows(rows: Row[], indent = ''): string[] {
63 return rows.map(([k, v]) => indent + row(k, clip(v, 200)))
64}
65
66function details(r: Report, sessionCwd: string | undefined, max: number): string[] {
67 const f = r.finding
68 const folders = r.folders ?? []
69 const out: string[] = []
70 if (f.cwd && f.cwd !== sessionCwd) out.push(row('Runs in', f.cwd))
71 if (folders.length === 1) out.push(...folderRows(folders[0]!.rows))
72 else
73 for (const fr of folders) out.push(fr.title, ...folderRows(fr.rows, ' '))
74 if (folders.length === 0 && r.risk?.headline) out.push(row('Impact', clip(cap1(r.risk.headline), 160)))
75 for (const g of r.riskGroups ?? []) if (!g.subset && !(g.extra && folders.length > 0)) out.push(...list(g.title, g.rows, g.more, max))
76 // a scanned folder already has its name in the headline and its size in the rows above
77 const scanned = folders.length === 1 && folders[0]!.rows.some(([k]) => k === 'Size')
78 r.groups.forEach((g, k) => {
79 if (k === 0 && scanned && g.rows.length <= 1 && g.more === 0) return
80 if (g.extra && scanned) return
81 out.push(...list(g.title, g.rows, g.more, max))
82 })
83 if (folders.length === 0 && r.summary.length && r.items !== 1) out.push(row('Total', r.summary.join('; ')))
84 const notes = r.notes.filter(n => !(folders.length > 0 && n === PUSHED_NOTE)).slice(0, MAX_NOTES)
85 for (const n of notes) out.push(row('Note', clip(cap1(n), 120)))
86 if (r.unmeasured && r.unmeasured !== 'native Windows') out.push(row('Not checked', clip(cap1(r.unmeasured), 120)))
87 return out
88}
89
90/** The question shown in the dialog. Ends in a question mark, as the dialog wants. */
91export function dialogText(command: string, reports: Report[], unlisted: number, sessionCwd?: string, maxRows = DEFAULT_ROWS): string {
92 const many = reports.length > 1
93 const lines: string[] = []
94 if (many) lines.push(`${WORD[worst(reports)]} risk · ${reports.length + unlisted} risky commands`, '')
95 else lines.push(`${WORD[worst(reports)]} risk · ${reports[0] ? clip(actionOf(reports[0]), 90) : 'risky command'}`, clip(oneLine(command), 200), '')
96 reports.forEach(r => {
97 if (many) lines.push(`${WORD[levelOf(r)]} · ${clip(actionOf(r), 80)}`, ` ${clip(oneLine(r.finding.command || r.finding.kind), 120)}`)
98 const d = details(r, sessionCwd, maxRows)
99 lines.push(...(many ? d.map(x => ` ${x}`) : d), '')
100 })
101 if (unlisted > 0) lines.push(`+${unlisted} more risky ${unlisted === 1 ? 'command' : 'commands'}, not checked`, '')
102 // the closing question is never clipped away: the dialog wants the text to end in a question mark
103 const footer = [...(reports.some(r => (r.folders ?? []).length > 0) ? [FOOTNOTE, ''] : []), 'Run this command?']
104 const body = lines.join('\n')
105 return clip(body, 6000 - footer.join('\n').length - 1) + '\n' + footer.join('\n')
106}
107
108/** One line per fact, short: what the model reads after a refusal. */
109export function summaryLines(reports: Report[], maxRows = 6): string[] {
110 const out: string[] = []
111 for (const r of reports) {
112 out.push(`${WORD[levelOf(r)]} risk · ${actionOf(r)}${r.finding.cwd ? ` (in ${r.finding.cwd})` : ''}`)
113 const folders = r.folders ?? []
114 if (folders.length === 0 && r.risk?.headline) out.push(` Impact: ${clip(r.risk.headline, 220)}`)
115 for (const fr of folders) out.push(` ${clip(fr.title, 120)} · Recoverable: ${clip(fr.rows.at(-1)?.[1] ?? '', 200)}`)
116 let left = maxRows
117 for (const g of r.groups)
118 for (const x of g.rows.slice(0, left)) (out.push(` ${clip(x, 120)}`), left--)
119 if (r.items !== 1) out.push(...r.summary.map(s => ` Total: ${s}`))
120 if (r.unmeasured) out.push(` Not checked: ${clip(r.unmeasured, 120)}`)
121 }
122 return out
123}
124
125export function denyText(why: string, command: string, reports: Report[]): string {
126 return clip(
127 [
128 `Blocked by command-guard (${why}). Not run: ${clip(oneLine(command), 200)}`,
129 ...summaryLines(reports),
130 'Ask the user before retrying, or use a narrower, reversible command.',
131 ].join('\n'), 2500,
132 )
133}
134
135/** One line (no line breaks): shown as a toast and in the status line. */
136export function toastText(reports: Report[]): string {
137 const total = reports.flatMap(r => r.summary)[0]?.replace(/^1 item, /, '')
138 return oneLine(`Blocked ${clip(oneLine(reports[0]?.finding.command ?? 'command'), 60)}${total ? ` · ${total}` : ''}`)
139}
140hooks/measure.ts 580 lines1// The measurer: READ-ONLY look at what a classified command would hit. Every probe
2// is an argv run through `run` (= $.process.run, no shell), so nothing the model
3// wrote is ever evaluated. The one place user text reaches an argv is a path or a ref,
4// and those are absolute paths or validated against REF before use.
5//
6// Contract: `measure` never throws and never says "fine". When a probe fails it sets
7// `unmeasured` and the caller holds the command anyway (fail closed on measuring).
8import type { Finding, Target } from './classify'
9import { normalize } from './classify'
10import { assessFolders } from './folder'
11import type { FolderRisk, Known, Mode } from './folder'
12import { assessLoss } from './recover'
13import type { Risk } from './recover'
14
15export type RunResult = { exitCode: number; stdout: string; stderr: string; isStdoutTruncated?: boolean }
16export type Run = (argv: readonly string[], cwd?: string, opts?: { timeoutMs?: number }) => Promise<RunResult>
17// home and cwd are only for the folder-risk wording (CRITICAL: home, working directory, its parents); both optional
18export type Config = { maxListed: number; home?: string; cwd?: string }
19
20// subset: the rows only repeat names the plain listing shows; extra: left out of the dialog when a folder summary is shown
21export type Group = { title: string; rows: string[]; more: number; subset?: boolean; extra?: boolean }
22export type Report = {
23 finding: Finding
24 groups: Group[]
25 risk?: Risk // what could not be got back: uncommitted, untracked, unpushed, outside git
26 riskGroups?: Group[] // the rows behind `risk`, shown BEFORE the plain listing
27 folders?: FolderRisk[] // per directory: what it is, what is in it, what could be got back
28 summary: string[] // totals
29 action?: string // what the command does, lower case, for the headline: "deletes data/"
30 items?: number // how many existing targets a path command names
31 notes: string[] // caveats that do not stop the hold
32 unmeasured?: string // set when the blast radius could not be established
33 skip?: boolean // a conditional finding whose condition is false: nothing to hold
34}
35
36// ---- formatting and parsing (pure, exported for tests) --------------------------------
37
38export const fmtInt = (n: number) => n.toLocaleString('en-US')
39export function fmtBytes(bytes: number): string {
40 const u = ['B', 'KB', 'MB', 'GB', 'TB']
41 let v = bytes
42 let k = 0
43 while (v >= 1024 && k < u.length - 1) (v /= 1024, k++)
44 return `${k === 0 ? v : +v.toFixed(v >= 10 ? 0 : 1)} ${u[k]}`
45}
46export const fmtKb = (kb: number) => fmtBytes(kb * 1024)
47const cap1 = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
48const lines = (s: string) => s.split('\n').map(l => l.replace(/\r$/, '')).filter(l => l !== '')
49const plural = (n: number, w: string, many = w + 's') => `${fmtInt(n)} ${n === 1 ? w : many}`
50
51/** `du -sk` output: "<kb>\t<path>" per line. */
52export function parseDu(out: string): Map<string, number> {
53 const m = new Map<string, number>()
54 for (const l of lines(out)) {
55 const hit = /^(\d+)\s+(.*)$/.exec(l)
56 if (hit) m.set(hit[2]!, Number(hit[1]))
57 }
58 return m
59}
60
61/** `git diff --name-status` output to display rows ("M path", "R old -> new"). */
62export function parseNameStatus(out: string): string[] {
63 return lines(out).map(l => {
64 const [st, ...paths] = l.split('\t')
65 return `${(st ?? '?').slice(0, 1)} ${paths.join(' -> ')}`
66 })
67}
68
69/** `git clean -n` output: "Would remove <path>" per line. */
70export function parseCleanDryRun(out: string): string[] {
71 return lines(out).flatMap(l => {
72 const m = /^Would (?:remove|skip repository) (.*)$/.exec(l)
73 return m ? [m[1]!.replace(/\/$/, '')] : []
74 })
75}
76
77const cap = (title: string, rows: string[], max: number, flags: { subset?: boolean; extra?: boolean } = {}): Group => ({
78 title, rows: rows.slice(0, max), more: Math.max(0, rows.length - max), ...flags,
79})
80
81function short(path: string, cwd?: string): string {
82 if (cwd && path.startsWith(cwd + '/')) return path.slice(cwd.length + 1)
83 return path
84}
85
86const REF = /^[A-Za-z0-9_@][A-Za-z0-9_.\-/@{}^~]*$/ // never starts with "-": a ref cannot become an option
87const chunks = <T>(a: T[], n: number) => Array.from({ length: Math.ceil(a.length / n) }, (_, k) => a.slice(k * n, (k + 1) * n))
88
89// ---- probes on paths ------------------------------------------------------------------
90
91const empty = (): Omit<Report, 'finding'> => ({ groups: [], summary: [], notes: [] })
92
93async function duMany(run: Run, paths: string[]): Promise<Map<string, number>> {
94 const all = new Map<string, number>()
95 for (const part of chunks(paths, 60))
96 for (const [p, kb] of parseDu((await run(['du', '-sk', ...part])).stdout)) all.set(p, kb)
97 return all
98}
99
100async function present(run: Run, paths: string[], extra: string[] = []): Promise<Set<string>> {
101 const found = new Set<string>()
102 for (const part of chunks(paths, 60))
103 for (const l of lines((await run(['find', ...part, '-maxdepth', '0', ...extra, '-print'])).stdout)) found.add(l)
104 return found
105}
106
107/** Expand a glob target with `find -path` at exactly the glob's depth (no shell involved). */
108export async function expandGlob(run: Run, t: Target): Promise<string[]> {
109 const parts = t.path!.split('/').filter(Boolean)
110 const g = parts.findIndex(p => /[*?[]/.test(p))
111 const baseParts = parts.slice(0, g)
112 const rest = parts.slice(g)
113 const base = '/' + baseParts.join('/')
114 const esc = (s: string) => s.replace(/[\\*?[]/g, c => '\\' + c)
115 const pattern = `${base === '/' ? '' : esc(base)}/${rest.join('/')}`
116 const res = await run(['find', base, '-mindepth', String(rest.length), '-maxdepth', String(rest.length), '-path', pattern])
117 return lines(res.stdout).filter(p => {
118 // Shell globs skip dot-files unless the pattern component itself starts with a dot.
119 const comps = p.split('/').filter(Boolean).slice(baseParts.length)
120 return comps.every((c, k) => !c.startsWith('.') || (rest[k] ?? '').startsWith('.'))
121 })
122}
123
124// folders: how the command treats a directory (default: it deletes it); false: no folder assessment
125const VERBS: Record<string, [string, string]> = { // verb -> [headline form, list title]
126 delete: ['deletes', 'Deletes'], empty: ['empties', 'Empties'], 'overwrite and destroy': ['shreds', 'Shreds'],
127 'change recursively': ['changes permissions of', 'Changes'], 'delete (untracked)': ['deletes', 'Deletes untracked'],
128 overwrite: ['overwrites', 'Overwrites'], 'overwrite or merge into': ['overwrites', 'Overwrites or merges into'],
129}
130type PathOpts = { verb: string; expandSingleDir: boolean; cap: number; risk?: boolean; folders?: Mode | false; canLower?: boolean }
131
132// Rows for a set of targets: kind, size, file count; for one directory also its biggest children.
133async function describePaths(run: Run, f: Finding, targets: Target[], o: PathOpts, cfg: Config): Promise<Omit<Report, 'finding'>> {
134 const r = empty()
135 const paths: string[] = []
136 for (const t of targets) {
137 if (!t.path) r.notes.push(`could not resolve ${t.raw}: ${t.unresolved}`)
138 else if (t.glob) {
139 const hits = await expandGlob(run, t)
140 if (hits.length === 0) r.notes.push(`${t.raw} matches nothing right now`)
141 paths.push(...hits)
142 } else paths.push(t.path)
143 }
144 const uniq = [...new Set(paths)].slice(0, o.cap)
145 if (paths.length > uniq.length) r.notes.push(`checked the first ${fmtInt(uniq.length)} of ${fmtInt(paths.length)} paths`)
146 if (uniq.length === 0) return r
147
148 const exists = await present(run, uniq)
149 const live = uniq.filter(p => exists.has(p))
150 const dirs = await present(run, live, ['-type', 'd'])
151 const sizes = await duMany(run, live)
152 const counts = await countFiles(run, [...dirs], r)
153 const links = o.folders === false ? new Set<string>() : await linksAmong(run, live, dirs)
154 const truncated = r.notes.some(n => n.startsWith('file counts are lower bounds'))
155 const rows = uniq.map(p => {
156 if (!exists.has(p)) return `${short(p, f.cwd)} · does not exist`
157 const kb = sizes.get(p)
158 const bits: string[] = links.has(p) ? ['symlink'] : []
159 if (counts?.has(p)) bits.push(plural(counts.get(p)!, 'file'))
160 if (kb !== undefined) bits.push(fmtKb(kb))
161 return `${short(p, f.cwd)}${dirs.has(p) ? '/' : ''}${bits.length ? ` · ${bits.join(', ')}` : ''}`
162 })
163 const [verbForm, title] = VERBS[o.verb] ?? [o.verb + 's', cap1(o.verb)]
164 r.groups.push(cap(title, rows, cfg.maxListed))
165 r.items = live.length
166 if (live.length === 1) r.action = `${verbForm} ${short(live[0]!, f.cwd)}${dirs.has(live[0]!) ? '/' : ''}`
167 else if (live.length > 1) r.action = `${verbForm} ${plural(live.length, dirs.size === live.length ? 'folder' : dirs.size === 0 ? 'file' : 'item')}`
168 const totalKb = [...sizes.values()].reduce((a, b) => a + b, 0)
169 const totalFiles = counts && dirs.size > 0 ? [...counts.values()].reduce((a, b) => a + b, 0) : undefined
170 r.summary.push(
171 live.length === 0
172 ? 'nothing exists at that path right now'
173 : [plural(live.length, 'item'), totalFiles !== undefined ? plural(totalFiles, 'file') : undefined, fmtKb(totalKb)]
174 .filter(Boolean).join(', '),
175 )
176 if (o.expandSingleDir && live.length === 1 && dirs.has(live[0]!)) await children(run, live[0]!, f, r, cfg)
177 // removing a symlink removes only the link: it is not "a file outside git" whose content is lost
178 const atRisk = o.folders === 'delete' || o.folders === undefined ? live.filter(p => !links.has(p)) : live
179 if (o.risk !== false && atRisk.length > 0) await addRisk(run, atRisk, r, cfg)
180 if (o.folders !== false && live.length > 0) {
181 const known = new Map<string, Known>([...dirs].map(d => [d, { kb: sizes.get(d), files: counts?.get(d), filesLowerBound: truncated }]))
182 await addFolders(run, f, r, live, dirs, links, { mode: o.folders ?? 'delete', canLower: o.canLower !== false, known }, cfg)
183 }
184 return r
185}
186
187// Symbolic links among the paths (find -type l does not follow them). Best effort: a failed probe means "none seen".
188async function linksAmong(run: Run, live: string[], dirs: Set<string>): Promise<Set<string>> {
189 try {
190 return await present(run, live.filter(p => !dirs.has(p)), ['-type', 'l'])
191 } catch {
192 return new Set()
193 }
194}
195
196// Folder risk for the directories (and symlinks) among the targets, merged into the overall RISK line. An add-on to the
197// listing: if it fails the listing and the file-level risk stand, and a note says so.
198async function addFolders(
199 run: Run, f: Finding, r: Omit<Report, 'finding'>, live: string[], dirs: Set<string> | undefined, linkSet: Set<string> | undefined,
200 o: { mode: Mode; canLower: boolean; known?: Map<string, Known> }, cfg: Config,
201): Promise<void> {
202 try {
203 const dirSet = dirs ?? (await present(run, live, ['-type', 'd']))
204 const links = linkSet ?? (await present(run, live.filter(p => !dirSet.has(p)), ['-type', 'l']))
205 const cands = [...dirSet, ...links]
206 if (cands.length === 0) return
207 const res = await assessFolders(run, cands, { home: cfg.home, cwd: cfg.cwd, runCwd: f.cwd, mode: o.mode, known: o.known })
208 r.folders = res.folders
209 if (res.skipped > 0) r.notes.push(`folder details for ${res.folders.length} of ${res.folders.length + res.skipped} folders`)
210 mergeFolderRisk(r, res.folders, o.canLower && res.skipped === 0 && cands.length === live.length)
211 } catch (err) {
212 r.notes.push(`folder details unavailable: ${(err as Error).message || 'probe failed'}`)
213 }
214}
215
216const LEVEL_RANK: Record<string, number> = { low: 0, medium: 1, high: 3 }
217
218/** The folder verdict can raise the overall level; it can lower MEDIUM to LOW only when it saw every target completely. */
219function mergeFolderRisk(r: Omit<Report, 'finding'>, folders: FolderRisk[], mayLower: boolean): void {
220 const worst = folders.reduce((a, b) => ((LEVEL_RANK[b.level] ?? -1) > (LEVEL_RANK[a.level] ?? -1) ? b : a))
221 const cur = r.risk
222 if (!cur) {
223 r.risk = { level: worst.level, headline: worst.headline }
224 return
225 }
226 if ((worst.level === 'high' && cur.level !== 'high') || (worst.level === 'medium' && cur.level === 'low'))
227 r.risk = { level: worst.level, headline: [worst.headline, cur.headline].filter(Boolean).join('; ') }
228 else if (mayLower && cur.level === 'medium' && folders.every(x => x.level === 'low' && x.complete))
229 r.risk = { level: 'low', headline: worst.headline }
230 // a plain folder holding nothing but safe repositories is "outside any repository" to the file-level check
231 else if (mayLower && cur.level === 'high' && folders.every(x => x.level === 'low' && x.complete && x.onlyRepos))
232 r.risk = { level: 'low', headline: worst.headline }
233}
234
235// Attach the data-loss assessment. assessLoss never throws; a failed probe yields level 'unknown'.
236async function addRisk(run: Run, live: string[], r: Omit<Report, 'finding'>, cfg: Config): Promise<void> {
237 const a = await assessLoss(run, live, cfg)
238 r.risk = a.risk
239 r.riskGroups = a.groups
240 r.notes.push(...a.notes)
241}
242
243// One find over all dirs: every non-directory under them, attributed to its dir by prefix.
244async function countFiles(run: Run, dirs: string[], r: Omit<Report, 'finding'>): Promise<Map<string, number> | undefined> {
245 if (dirs.length === 0) return new Map()
246 try {
247 const res = await run(['find', ...dirs, '!', '-type', 'd', '-print'])
248 const counts = new Map<string, number>(dirs.map(d => [d, 0]))
249 for (const l of lines(res.stdout)) for (const d of dirs) if (l.startsWith(d + '/')) counts.set(d, counts.get(d)! + 1)
250 if (res.isStdoutTruncated) r.notes.push('file counts are lower bounds (listing truncated)')
251 return counts
252 } catch (err) {
253 r.notes.push(`file count unavailable: ${(err as Error).message}`)
254 return undefined
255 }
256}
257
258async function children(run: Run, dir: string, f: Finding, r: Omit<Report, 'finding'>, cfg: Config): Promise<void> {
259 try {
260 const kids = lines((await run(['find', dir, '-mindepth', '1', '-maxdepth', '1'])).stdout).slice(0, 500)
261 const dirs = await present(run, kids, ['-type', 'd'])
262 const sizes = await duMany(run, kids)
263 const rows = kids
264 .map(k => ({ k, kb: sizes.get(k) ?? 0 }))
265 .sort((a, b) => b.kb - a.kb)
266 .map(({ k, kb }) => `${short(k, dir)}${dirs.has(k) ? '/' : ''} · ${fmtKb(kb)}`)
267 if (rows.length) r.groups.push(cap(`Largest inside ${short(dir, f.cwd)}/`, rows, cfg.maxListed, { extra: true }))
268 } catch (err) {
269 r.notes.push(`could not list the contents: ${(err as Error).message}`)
270 }
271}
272
273// ---- per-kind measurement ---------------------------------------------------------------
274
275const pos = (args: string[]) => {
276 const dd = args.indexOf('--')
277 return (dd < 0 ? args : args.slice(0, dd)).filter(a => !a.startsWith('-')).concat(dd < 0 ? [] : args.slice(dd + 1))
278}
279
280async function git(run: Run, cwd: string | undefined, ...argv: string[]): Promise<string> {
281 const res = await run(['git', '--no-optional-locks', '--no-pager', ...argv], cwd)
282 if (res.exitCode !== 0) throw new Error(`git ${argv[0]} failed: ${res.stderr.trim().split('\n')[0] || `exit ${res.exitCode}`}`)
283 return res.stdout
284}
285
286export async function measure(run: Run, f: Finding, cfg: Config): Promise<Report> {
287 try {
288 return { finding: f, ...(await dispatch(run, f, cfg)) }
289 } catch (err) {
290 return { finding: f, groups: [], summary: [], notes: [], unmeasured: (err as Error).message || 'probe failed' }
291 }
292}
293
294async function dispatch(run: Run, f: Finding, cfg: Config): Promise<Omit<Report, 'finding'>> {
295 const dp = (verb: string, targets = f.targets, expandSingleDir = true) =>
296 describePaths(run, f, targets, { verb, expandSingleDir, cap: 500 }, cfg)
297 switch (f.kind) {
298 case 'rm': {
299 if (f.viaXargs || f.targets.length === 0)
300 return { ...empty(), unmeasured: 'the paths arrive on stdin (xargs) or are not named' }
301 return dp('delete')
302 }
303 case 'truncate': return dp('empty', f.targets, false)
304 case 'shred': return dp('overwrite and destroy', f.targets, false)
305 case 'chmod': return describePaths(run, f, f.targets, { verb: 'change recursively', expandSingleDir: true, cap: 500, risk: false, folders: 'modify' }, cfg)
306 case 'r': {
307 const r = await dp('delete')
308 if (f.targets.length === 0) r.notes.push('the paths in the R code are computed: could not name them')
309 return r
310 }
311 case 'cargo-clean': return cargoClean(run, f, cfg)
312 case 'redirect': return redirect(run, f, cfg)
313 case 'overwrite': return overwrite(run, f, cfg)
314 case 'dd': return dd(run, f, cfg)
315 case 'mkfs': return { ...empty(), unmeasured: 'a device is erased; its contents are not inspected' }
316 case 'find': return findRule(run, f, cfg)
317 case 'git-clean': return gitClean(run, f, cfg)
318 case 'git-reset': return gitReset(run, f, cfg)
319 case 'git-discard': return gitDiscard(run, f, cfg)
320 case 'git-push': return gitPush(run, f, cfg)
321 case 'git-branch': return gitBranch(run, f, cfg)
322 case 'git-stash': return gitStash(run, f, cfg)
323 case 'sql': return { ...empty(), groups: [{ title: 'Statements', rows: f.args, more: 0 }], unmeasured: 'database contents are not inspected' }
324 case 'custom': return { ...empty(), action: 'matches your extra pattern', unmeasured: 'nothing measured for your extra patterns' }
325 case 'opaque': return { ...empty(), unmeasured: f.reason }
326 }
327}
328
329async function cargoClean(run: Run, f: Finding, cfg: Config) {
330 const a = f.args
331 const flag = (n: string) => { const k = a.indexOf(n); return k >= 0 ? a[k + 1] : a.find(x => x.startsWith(n + '='))?.slice(n.length + 1) }
332 const dir = flag('--target-dir')
333 const manifest = flag('--manifest-path')
334 const abs = (p: string) => (p.startsWith('/') ? normalize(p) : f.cwd ? normalize(`${f.cwd}/${p}`) : undefined)
335 const where = dir ? abs(dir) : manifest ? abs(manifest.replace(/[^/]*$/, '') + 'target') : f.cwd ? `${f.cwd}/target` : undefined
336 if (!where) return { ...empty(), unmeasured: 'the working directory is not known' }
337 const r = await describePaths(run, f, [{ raw: where, path: where, glob: false }], { verb: 'delete', expandSingleDir: true, cap: 1, risk: false }, cfg)
338 r.notes.push('cargo may clean the workspace\'s target/ or CARGO_TARGET_DIR instead')
339 return r
340}
341
342async function redirect(run: Run, f: Finding, cfg: Config) {
343 const p = f.targets[0]!.path!
344 // find -L <p> -maxdepth 0 -type f -size +0c : prints p only for a non-empty regular file.
345 const hit = lines((await run(['find', '-L', p, '-maxdepth', '0', '-type', 'f', '-size', '+0c', '-print'])).stdout)
346 if (hit.length === 0) return { ...empty(), skip: true }
347 return describePaths(run, f, f.targets, { verb: 'overwrite', expandSingleDir: false, cap: 1 }, cfg)
348}
349
350async function overwrite(run: Run, f: Finding, cfg: Config) {
351 const dest = f.targets[0]!.path!
352 const cands = [dest, ...f.args.map(s => `${dest}/${s.slice(s.lastIndexOf('/') + 1)}`)]
353 const hits = [...(await present(run, cands, ['-type', 'f']))]
354 if (hits.length === 0) {
355 const dirs = await overwrittenDirs(run, f, dest)
356 if (dirs.length === 0) return { ...empty(), skip: true }
357 const targets = dirs.map(p => ({ raw: p, path: p, glob: false }))
358 return describePaths(run, f, targets, { verb: 'overwrite or merge into', expandSingleDir: false, cap: 100, folders: 'overwrite', canLower: false }, cfg)
359 }
360 const targets = hits.map(p => ({ raw: p, path: p, glob: false }))
361 return describePaths(run, f, targets, { verb: 'overwrite', expandSingleDir: false, cap: 100 }, cfg)
362}
363
364// `cp -rf src dest` / `mv -f src dest` where dest is a folder that already holds a folder named like src: its files are replaced.
365async function overwrittenDirs(run: Run, f: Finding, dest: string): Promise<string[]> {
366 const abs = f.args.filter(a => !/[*?[$`~]/.test(a)).map(a => (a.startsWith('/') ? normalize(a) : f.cwd ? normalize(`${f.cwd}/${a}`) : '')).filter(Boolean)
367 if (abs.length === 0 || (await present(run, [dest], ['-type', 'd'])).size === 0) return []
368 const srcDirs = await present(run, abs, ['-type', 'd'])
369 const inDest = [...srcDirs].map(p => `${dest}/${p.slice(p.lastIndexOf('/') + 1)}`)
370 return inDest.length ? [...(await present(run, inDest, ['-type', 'd']))] : []
371}
372
373async function dd(run: Run, f: Finding, cfg: Config) {
374 const t = f.targets[0]!
375 if (t.path?.startsWith('/dev/'))
376 return { ...empty(), action: `writes over ${t.path}`, notes: ['Everything on the device is overwritten'], unmeasured: 'device contents are not inspected' }
377 return describePaths(run, f, f.targets, { verb: 'overwrite', expandSingleDir: false, cap: 1 }, cfg)
378}
379
380// find ... -delete / -exec rm -> the same find with the destructive part swapped for -print.
381export function findPreviewArgs(args: string[]): { argv: string[]; note?: string } | undefined {
382 const out: string[] = []
383 let note: string | undefined
384 for (let i = 0; i < args.length; i++) {
385 const t = args[i]!
386 if (/^-(fprint0?|fprintf|fls)$/.test(t)) return undefined // would write a file
387 if (t === '-delete') out.push('-print')
388 else if (/^-(exec|execdir|ok|okdir)$/.test(t)) {
389 let j = i + 1
390 while (j < args.length && args[j] !== ';' && args[j] !== '+') j++
391 const cmd = (args[i + 1] ?? '').slice((args[i + 1] ?? '').lastIndexOf('/') + 1)
392 if (/^(rm|unlink|shred|rmdir|sh|bash|zsh)$/.test(cmd)) out.push('-print')
393 else note = 'an -exec filter was left out, so the list may include extra paths'
394 i = j
395 } else out.push(t)
396 }
397 return { argv: out, ...(note ? { note } : {}) }
398}
399
400async function findRule(run: Run, f: Finding, cfg: Config) {
401 if (f.args.some(a => /[$`]/.test(a))) return { ...empty(), unmeasured: 'the find arguments contain shell expansions' }
402 const prev = findPreviewArgs(f.args)
403 if (!prev) return { ...empty(), unmeasured: 'the find writes files itself (-fprint/-fls): not previewed' }
404 const res = await run(['find', ...prev.argv], f.cwd)
405 const abs = (p: string) => (p.startsWith('/') ? p : f.cwd ? normalize(`${f.cwd}/${p}`) : p)
406 const found = lines(res.stdout).map(abs)
407 const r = empty()
408 if (prev.note) r.notes.push(prev.note)
409 if (res.exitCode !== 0 && found.length === 0) throw new Error(`find failed: ${res.stderr.trim().split('\n')[0] || `exit ${res.exitCode}`}`)
410 if (res.isStdoutTruncated) r.notes.push('match list truncated: counts are lower bounds')
411 const sized = found.slice(0, 300)
412 const sizes = await duMany(run, sized)
413 const rows = sized.map(p => `${short(p, f.cwd)} ${fmtKb(sizes.get(p) ?? 0)}`).concat(found.slice(300).map(p => short(p, f.cwd)))
414 r.groups.push(cap('Deletes', rows, cfg.maxListed))
415 r.action = `deletes ${plural(found.length, 'match', 'matches')}`
416 const kb = [...sizes.values()].reduce((a, b) => a + b, 0)
417 r.summary.push(`${plural(found.length, 'match', 'matches')}, ${fmtKb(kb)}${found.length > sized.length ? ' (first 300 sized)' : ''}`)
418 if (found.length > 0) await addRisk(run, found.slice(0, 500), r, cfg)
419 if (found.length > 0) {
420 // a match that is a directory is deleted with everything in it (find -delete only when it is empty by then)
421 const matched = found.slice(0, 500)
422 await addFolders(run, f, r, matched, undefined, undefined, { mode: 'delete', canLower: true }, cfg)
423 }
424 return r
425}
426
427// ---- git ------------------------------------------------------------------------------------
428
429async function gitClean(run: Run, f: Finding, cfg: Config) {
430 const a = f.args
431 const letters = new Set(a.filter(x => /^-[^-]/.test(x)).flatMap(x => [...x.slice(1)]))
432 const probe = ['clean', '-n', ...['d', 'x', 'X'].filter(l => letters.has(l)).map(l => '-' + l)]
433 const paths: string[] = []
434 let afterDashes = false
435 for (let i = 0; i < a.length; i++) {
436 const x = a[i]!
437 if (afterDashes) paths.push(x)
438 else if (x === '--') afterDashes = true
439 else if (x === '-e' || x === '--exclude') probe.push('-e', a[++i] ?? '')
440 else if (x.startsWith('--exclude=')) probe.push(x)
441 else if (!x.startsWith('-')) paths.push(x)
442 }
443 const out = await git(run, f.cwd, ...probe, '--', ...paths)
444 const rel = parseCleanDryRun(out)
445 if (!f.cwd) throw new Error('the working directory is not known')
446 const targets = rel.map(p => ({ raw: p, path: normalize(`${f.cwd}/${p}`), glob: false }))
447 if (targets.length === 0) return { ...empty(), notes: ['git clean -n finds nothing to remove right now'] }
448 const r = await describePaths(run, f, targets, { verb: 'delete (untracked)', expandSingleDir: false, cap: 300, canLower: false }, cfg)
449 r.action = `deletes ${plural(targets.length, 'untracked path')}`
450 if (!letters.has('x') && !letters.has('X')) r.notes.push('Ignored files are kept (no -x)')
451 r.risk = { level: 'high', headline: `${plural(targets.length, 'untracked path')} never committed, gone for good${letters.has('x') || letters.has('X') ? ' (includes ignored files)' : ''}` }
452 return r
453}
454
455async function gitReset(run: Run, f: Finding, cfg: Config) {
456 const ref = pos(f.args).find(a => REF.test(a)) ?? 'HEAD'
457 if (!REF.test(ref)) throw new Error(`unusual ref "${ref}"`)
458 const files = parseNameStatus(await git(run, f.cwd, 'diff', '--name-status', ref))
459 const stat = (await git(run, f.cwd, 'diff', '--shortstat', ref)).trim()
460 const lost = lines(await git(run, f.cwd, 'log', '--oneline', '-n', String(cfg.maxListed + 1), `${ref}..HEAD`))
461 const r = empty()
462 r.groups.push(cap('Changes lost', files, cfg.maxListed))
463 if (lost.length) r.groups.push(cap('Commits leaving the branch', lost, cfg.maxListed))
464 r.action = `discards uncommitted changes${files.length ? ` in ${plural(files.length, 'file')}` : ''}`
465 r.summary.push(stat || 'no uncommitted changes to tracked files')
466 r.notes.push('Untracked files are not touched')
467 r.risk = files.length
468 ? { level: 'high', headline: `uncommitted changes to ${plural(files.length, 'tracked file')}, gone for good (not in the reflog)` }
469 : lost.length ? { level: 'medium', headline: 'commits leave the branch; git reflog can bring them back' }
470 : { level: 'low', headline: 'nothing uncommitted to lose' }
471 return r
472}
473
474async function gitDiscard(run: Run, f: Finding, cfg: Config) {
475 const a = f.args
476 const dd = a.indexOf('--')
477 const before = (dd < 0 ? a : a.slice(0, dd)).filter(x => !x.startsWith('-'))
478 const after = dd < 0 ? [] : a.slice(dd + 1)
479 const source = a.find(x => x.startsWith('--source='))?.slice(9)
480 const tree = source ?? (dd >= 0 ? before.find(x => REF.test(x)) : undefined)
481 if (tree !== undefined && !REF.test(tree)) throw new Error(`unusual ref "${tree}"`)
482 const paths = after.length ? after : dd < 0 ? before : []
483 const spec = ['--name-status', ...(tree ? [tree] : paths.length ? [] : ['HEAD']), '--', ...(paths.length ? paths : ['.'])]
484 const files = parseNameStatus(await git(run, f.cwd, 'diff', ...spec))
485 const stat = (await git(run, f.cwd, 'diff', '--shortstat', ...spec.filter(s => s !== '--name-status'))).trim()
486 const r = empty()
487 r.groups.push(cap('Changes lost', files, cfg.maxListed))
488 r.action = `discards uncommitted changes${files.length ? ` in ${plural(files.length, 'file')}` : ''}`
489 r.summary.push(stat || 'no uncommitted changes to tracked files here')
490 r.notes.push('Untracked files are not touched')
491 r.risk = files.length
492 ? { level: 'high', headline: `uncommitted changes to ${plural(files.length, 'tracked file')}, gone for good` }
493 : { level: 'low', headline: 'nothing uncommitted to lose here' }
494 return r
495}
496
497async function gitPush(run: Run, f: Finding, cfg: Config) {
498 const a = f.args
499 if (a.some(x => ['--all', '--mirror', '--tags'].includes(x))) throw new Error('pushes every ref: cannot enumerate')
500 const operands = pos(a)
501 const remote = operands[0]
502 const specs = operands.slice(1)
503 const pairs = specs.length
504 ? specs.map(s => {
505 const body = s.replace(/^\+/, '')
506 const [src, dst] = body.includes(':') ? [body.split(':')[0]!, body.split(':').slice(1).join(':')] : [body, body]
507 return { src, dst: dst.replace(/^refs\/heads\//, '') }
508 })
509 : [{ src: 'HEAD', dst: '' }]
510 const deleting = a.includes('--delete') || a.includes('-d')
511 const r = empty()
512 for (const p of pairs) {
513 const src = deleting && p.src === p.dst ? '' : p.src
514 const dstRef = p.dst === '' || !remote ? '@{u}' : `${remote}/${p.dst}`
515 for (const ref of [src, dstRef]) if (ref !== '' && !REF.test(ref)) throw new Error(`unusual ref "${ref}"`)
516 if (src === '') {
517 const tip = lines(await git(run, f.cwd, 'log', '--oneline', '-n', '5', dstRef))
518 r.groups.push(cap(`${dstRef} is deleted; latest commits`, tip, cfg.maxListed))
519 r.action = `deletes ${dstRef} on the remote`
520 continue
521 }
522 const over = lines(await git(run, f.cwd, 'log', '--oneline', '-n', String(cfg.maxListed + 1), `${src}..${dstRef}`))
523 const sent = lines(await git(run, f.cwd, 'log', '--oneline', '-n', String(cfg.maxListed + 1), `${dstRef}..${src}`))
524 r.groups.push(cap(`Overwritten on ${dstRef}`, over, cfg.maxListed))
525 r.groups.push(cap(`Sent from ${src}`, sent, cfg.maxListed))
526 r.action ??= `force-pushes over ${dstRef}`
527 r.summary.push(`${plural(over.length, 'remote commit')} lost, ${plural(sent.length, 'commit')} sent`)
528 if (over.length) r.risk = { level: 'high', headline: `${plural(over.length, 'remote commit')} overwritten for anyone who has not pulled them` }
529 }
530 r.notes.push('Remote state is as of your last fetch')
531 return r
532}
533
534async function gitBranch(run: Run, f: Finding, cfg: Config) {
535 const r = empty()
536 for (const b of pos(f.args)) {
537 if (!REF.test(b)) throw new Error(`unusual branch name "${b}"`)
538 const only = lines(await git(run, f.cwd, 'log', '--oneline', '-n', String(cfg.maxListed + 1), b, '--not', `--exclude=${b}`, '--branches', '--tags', '--remotes'))
539 const tip = (await git(run, f.cwd, 'rev-parse', '--short', b)).trim()
540 r.groups.push(cap(
541 only.length ? `Only on ${b}` : `${b}: every commit is also elsewhere`, only, cfg.maxListed,
542 ))
543 r.notes.push(`Undo: git branch ${b} ${tip}`)
544 r.action = `deletes branch ${pos(f.args).join(', ')}`
545 if (only.length) r.risk = { level: 'high', headline: `${plural(only.length, 'commit')} on ${b} exist on no other branch, tag or remote` }
546 }
547 return r
548}
549
550async function gitStash(run: Run, f: Finding, cfg: Config) {
551 const op = f.args[0]
552 const r = empty()
553 if (op === 'clear') {
554 const all = lines(await git(run, f.cwd, 'stash', 'list'))
555 r.groups.push(cap('Stashes deleted', all, cfg.maxListed))
556 r.action = 'deletes all stashes'
557 r.summary.push(plural(all.length, 'stash', 'stashes'))
558 r.risk = { level: 'high', headline: `${plural(all.length, 'stash', 'stashes')} of uncommitted work exist nowhere else` }
559 return r
560 }
561 const ref = f.args.slice(1).find(x => !x.startsWith('-')) ?? 'stash@{0}'
562 if (!/^stash@\{\d+\}$/.test(ref)) throw new Error(`unusual stash ref "${ref}"`)
563 const stat = lines(await git(run, f.cwd, 'stash', 'show', '--stat', ref))
564 r.groups.push(cap(`${ref} holds`, stat.slice(0, -1), cfg.maxListed))
565 r.action = `deletes ${ref}`
566 r.summary.push(stat.at(-1)?.trim() ?? ref)
567 r.notes.push('git fsck --unreachable can still find a dropped stash until gc runs')
568 r.risk = { level: 'high', headline: 'stashed work exists nowhere else' }
569 return r
570}
571
572export async function measureAll(run: Run, findings: Finding[], cfg: Config, max = 6): Promise<{ reports: Report[]; unlisted: number }> {
573 const reports: Report[] = []
574 for (const f of findings.slice(0, max)) {
575 const rep = await measure(run, f, cfg)
576 if (!rep.skip) reports.push(rep)
577 }
578 return { reports, unlisted: Math.max(0, findings.length - max) }
579}
580hooks/windows.ts 20 lines1// Native Windows handling: the measurer's probes are Unix `find` and `du`, which do not exist there (Windows has its own
2// `find`, a text search, that would answer nothing and make a destructive command look harmless). So on Windows a risky
3// command is held WITHOUT a listing, saying so, instead of being measured.
4import type { Finding } from './classify'
5import type { Report } from './measure'
6
7export const WINDOWS_NOTE = 'Nothing can be listed on Windows. Check the path yourself first'
8
9/** A drive-letter or UNC path: `C:\Users\me`, `C:/Users/me`, `\\server\share`. */
10export const looksWindows = (p: string | undefined): boolean => p !== undefined && /^([A-Za-z]:[\\/]|\\\\)/.test(p)
11
12/** Reports that say plainly nothing was measured. Conditional findings (`> file`) need a file check, so they pass. */
13export function unmeasured(found: Finding[]): { reports: Report[]; unlisted: number } {
14 const held = found.filter(f => f.conditional === undefined)
15 return {
16 reports: held.slice(0, 6).map(f => ({ finding: f, groups: [], summary: [], notes: [WINDOWS_NOTE], unmeasured: 'native Windows' })),
17 unlisted: Math.max(0, held.length - 6),
18 }
19}
20hooks/lex.ts 188 lines1// A small POSIX-shell lexer: just enough to find the simple commands in a Bash
2// tool call. It never evaluates anything. Quotes are removed, `$(...)`, backticks
3// and `<(...)` bodies are captured as `subs` (classified recursively), and anything
4// that expands at run time (`$VAR`, `${..}`, `{a,b}`, `$(..)`) marks the word `dynamic`.
5
6export type Word = {
7 text: string
8 glob: boolean // an unquoted * ? or [..] is in it
9 dynamic: boolean // its value is only known at run time
10 tilde: boolean // starts with an unquoted ~
11 subs: string[] // command substitutions found inside it
12}
13
14export type Tok =
15 | { t: 'word'; w: Word }
16 | { t: 'op'; op: ';' | '&&' | '||' | '|' | '&' | '(' | ')' }
17 | { t: 'redir'; op: string; fd?: string; body?: string }
18
19const blank = (): Word => ({ text: '', glob: false, dynamic: false, tilde: false, subs: [] })
20const NAME = /[A-Za-z0-9_]/
21// Sticky regexes: tested in place, so a long command is not re-sliced at every bracket.
22const BRACKET = /\[[^\s\]]+\]/y
23const BRACE = /\{[^\s{}]*(,|\.\.)[^\s{}]*\}/y
24const REDIR = /(&>>|&>|>>|>\||>&|<<<|<<-|<<|<&|<>|>|<)/y
25const at = (re: RegExp, s: string, i: number) => ((re.lastIndex = i), re.exec(s))
26
27// Index of the `close` char matching an already-opened `open`, quote aware; -1 if none.
28function matchClose(src: string, from: number, open: string, close: string): number {
29 let depth = 1
30 for (let i = from; i < src.length; i++) {
31 const c = src[i]
32 if (c === '\\') i++
33 else if (c === "'") {
34 const j = src.indexOf("'", i + 1)
35 if (j < 0) return -1
36 i = j
37 } else if (c === '"') {
38 let j = i + 1
39 while (j < src.length && src[j] !== '"') j += src[j] === '\\' ? 2 : 1
40 if (j >= src.length) return -1
41 i = j
42 } else if (c === close) {
43 if (open === close || --depth === 0) return i
44 } else if (c === open) depth++
45 }
46 return -1
47}
48
49type Pending = { tok: Extract<Tok, { t: 'redir' }>; delim: string; strip: boolean }
50
51export function lex(src: string): Tok[] {
52 const toks: Tok[] = []
53 const heredocs: Pending[] = []
54 let cur: Word | undefined
55 let expectDelim: { tok: Extract<Tok, { t: 'redir' }>; strip: boolean } | undefined
56 let i = 0
57
58 const word = () => (cur ??= blank())
59 const flush = () => {
60 if (!cur) return
61 toks.push({ t: 'word', w: cur })
62 if (expectDelim) heredocs.push({ ...expectDelim, delim: cur.text })
63 expectDelim = undefined
64 cur = undefined
65 }
66 const op = (o: Extract<Tok, { t: 'op' }>['op']) => {
67 flush()
68 toks.push({ t: 'op', op: o })
69 }
70 // `$(..)`, backticks, `<(..)`: the body is kept for recursive classification.
71 const capture = (open: string, close: string, openAt: number, rawFrom: number): number => {
72 const end = matchClose(src, openAt + 1, open, close)
73 const stop = end < 0 ? src.length : end
74 const w = word()
75 w.subs.push(src.slice(openAt + 1, stop))
76 w.dynamic = true
77 w.text += src.slice(rawFrom, stop + 1)
78 return stop
79 }
80 const readBodies = () => {
81 for (const h of heredocs.splice(0)) {
82 const lines: string[] = []
83 while (i < src.length) {
84 const nl = src.indexOf('\n', i)
85 const line = src.slice(i, nl < 0 ? src.length : nl)
86 i = nl < 0 ? src.length : nl + 1
87 if ((h.strip ? line.replace(/^\t+/, '') : line) === h.delim) break
88 lines.push(line)
89 }
90 h.tok.body = lines.join('\n')
91 }
92 }
93 // A `$` outside single quotes. `quoted` = inside double quotes.
94 const dollar = (quoted: boolean) => {
95 const n = src[i + 1]
96 if (n === '(') i = capture('(', ')', i + 1, i)
97 else if (n === '{') {
98 const end = matchClose(src, i + 2, '{', '}')
99 const stop = end < 0 ? src.length - 1 : end
100 const w = word()
101 w.text += src.slice(i, stop + 1)
102 w.dynamic = true
103 i = stop
104 } else if (n === "'" && !quoted) {
105 const end = src.indexOf("'", i + 2)
106 word().text += src.slice(i + 2, end < 0 ? src.length : end)
107 i = end < 0 ? src.length : end
108 } else if (n !== undefined && (NAME.test(n) || '@*#?!$-'.includes(n))) {
109 let j = i + 1
110 if (NAME.test(n)) while (j + 1 < src.length && NAME.test(src[j + 1]!)) j++
111 const w = word()
112 w.text += src.slice(i, j + 1)
113 w.dynamic = true
114 i = j
115 } else word().text += '$'
116 }
117
118 while (i < src.length) {
119 const c = src[i]!
120 if (c === ' ' || c === '\t' || c === '\r') flush()
121 else if (c === '\n') {
122 op(';')
123 i++
124 readBodies()
125 continue
126 } else if (c === '#' && !cur) {
127 while (i < src.length && src[i] !== '\n') i++
128 continue
129 } else if (c === '\\') {
130 if (src[i + 1] === '\n') i++
131 else {
132 word().text += src[i + 1] ?? ''
133 i++
134 }
135 } else if (c === "'") {
136 const end = src.indexOf("'", i + 1)
137 word().text += src.slice(i + 1, end < 0 ? src.length : end)
138 i = end < 0 ? src.length : end
139 } else if (c === '"') {
140 word()
141 i++
142 while (i < src.length && src[i] !== '"') {
143 const d = src[i]!
144 if (d === '\\' && '$"`\\\n'.includes(src[i + 1] ?? 'x')) {
145 if (src[i + 1] !== '\n') word().text += src[i + 1]
146 i++
147 } else if (d === '$') dollar(true)
148 else if (d === '`') i = capture('`', '`', i, i)
149 else word().text += d
150 i++
151 }
152 } else if (c === '`') i = capture('`', '`', i, i)
153 else if (c === '$') dollar(false)
154 else if ((c === '<' || c === '>') && src[i + 1] === '(') i = capture('(', ')', i + 1, i)
155 else if (c === '<' || c === '>' || (c === '&' && src[i + 1] === '>')) {
156 const fd = cur && /^\d+$/.test(cur.text) ? cur.text : undefined
157 if (fd !== undefined) cur = undefined
158 flush()
159 const m = at(REDIR, src, i)!
160 const tok: Extract<Tok, { t: 'redir' }> = { t: 'redir', op: m[1]!, ...(fd ? { fd } : {}) }
161 toks.push(tok)
162 if (tok.op === '<<' || tok.op === '<<-') expectDelim = { tok, strip: tok.op === '<<-' }
163 i += m[1]!.length - 1
164 } else if (c === ';') op(';')
165 else if (c === '&') {
166 if (src[i + 1] === '&') (op('&&'), i++)
167 else op('&')
168 } else if (c === '|') {
169 if (src[i + 1] === '|') (op('||'), i++)
170 else {
171 op('|')
172 if (src[i + 1] === '&') i++
173 }
174 } else if (c === '(' || c === ')') op(c)
175 else {
176 const w = word()
177 if (c === '*' || c === '?') w.glob = true
178 else if (c === '[' && at(BRACKET, src, i)) w.glob = true
179 else if (c === '{' && at(BRACE, src, i)) w.dynamic = true
180 else if (c === '~' && w.text === '') w.tilde = true
181 w.text += c
182 }
183 i++
184 }
185 flush()
186 return toks
187}
188hooks/folder.ts 574 lines1// Folder risk: for every DIRECTORY a held command would delete, overwrite or change recursively, answer in a few
2// short rows: where the folder sits (repository root, folder inside a repository, outside any repository), what kind of
3// content it holds (build output that can be regenerated, source, data that is hard to regenerate, likely secrets),
4// how big it is and how recently something in it changed, what git could give back, and ONE closing row
5// "Recoverable: Yes / Partly / No" that also sets the risk level.
6//
7// Everything is a heuristic from names and git state, and the wording says so. Probes are read-only argv arrays through
8// `run` (find, du, git: nothing a shell could evaluate), bounded three ways: a probe timeout, a cap on how many files
9// are classified ("sampled" is said when it bites), and a stop at the first tree walk that times out.
10// Contract like the rest of the measurer: never throws; a scan that did not finish never reads as LOW.
11import { GIT, statusEntries, wholeRepoFact } from './recover'
12import type { RiskLevel } from './recover'
13import type { Run, RunResult } from './measure'
14
15export type Mode = 'delete' | 'modify' | 'overwrite' // what the command does to the folder
16export type Recoverable = 'yes' | 'partly' | 'no' | 'unknown'
17export type Row = [label: string, value: string]
18export type FolderRisk = {
19 path: string
20 title: string // "data/ · High"
21 level: RiskLevel
22 rows: Row[] // [label, value] pairs, already worded; the last one is "Recoverable"
23 headline: string // one short phrase for the overall impact line (no full stop)
24 regenerable: boolean // true only when everything found is build/cache output by name
25 onlyRepos?: boolean // a folder outside git that holds nothing but whole repositories (all of them safe)
26 complete: boolean // false when the scan stopped early or failed: such a result never lowers a risk level
27}
28export type Known = { kb?: number; files?: number; filesLowerBound?: boolean }
29export type FolderContext = {
30 home?: string
31 cwd?: string // the session's directory
32 runCwd?: string // the directory the command runs in (after a `cd`)
33 mode: Mode
34 maxFolders?: number
35 known?: Map<string, Known> // sizes and counts the caller already measured
36 note?: string // e.g. cargo clean: the caller says this is build output
37}
38
39export const SAMPLE_CAP = 3000 // files classified at most; more is "sampled"
40export const FOLDER_PROBE_MS = 8000
41const SIZE_CAP = 240 // files sized with du, data and secrets first
42const NESTED_CAP = 5
43const BANDS: [number, string][] = [
44 [15, 'within 15 minutes'], [60, 'within the hour'], [360, 'within 6 hours'],
45 [1440, 'within a day'], [10080, 'within a week'], [43200, 'within 30 days'],
46]
47const LEVEL_WORD: Record<RiskLevel, string> = { high: 'High', medium: 'Medium', low: 'Low', unknown: 'Unknown' }
48const cap1 = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
49
50const lines = (s: string) => s.split('\n').map(l => l.replace(/\r$/, '')).filter(l => l !== '')
51const fmt = (n: number) => n.toLocaleString('en-US')
52const plural = (n: number, w: string, many = w + 's') => `${fmt(n)} ${n === 1 ? w : many}`
53const inside = (child: string, parent: string) => child === parent || child.startsWith(parent === '/' ? '/' : parent + '/')
54const base = (p: string) => p.slice(p.lastIndexOf('/') + 1)
55const parentOf = (p: string) => (p.lastIndexOf('/') > 0 ? p.slice(0, p.lastIndexOf('/')) : '/')
56const clip = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
57const sum = (a: number[]) => a.reduce((x, y) => x + y, 0)
58const chunks = <T>(a: T[], n: number) => Array.from({ length: Math.ceil(a.length / n) }, (_, k) => a.slice(k * n, (k + 1) * n))
59export function kbText(kb: number): string {
60 const u = ['KB', 'MB', 'GB', 'TB']
61 let v = kb
62 let k = 0
63 while (v >= 1024 && k < u.length - 1) (v /= 1024, k++)
64 return `${+v.toFixed(v >= 10 || k === 0 ? 0 : 1)} ${u[k]}`
65}
66function duMap(out: string): Map<string, number> {
67 const m = new Map<string, number>()
68 for (const l of lines(out)) {
69 const hit = /^(\d+)\s+(.*)$/.exec(l)
70 if (hit) m.set(hit[2]!, Number(hit[1]))
71 }
72 return m
73}
74
75// ---- names: what a folder or file probably is -------------------------------------------------
76
77export type Cls = 'regen' | 'data' | 'secret' | 'source' | 'other'
78
79/** Directory names that are build or cache output almost everywhere (pruned from the file listing, sized as a whole). */
80export const REGEN_NAMES = [
81 'node_modules', '__pycache__', '.venv', 'venv', '.cache', '.Rproj.user', '.pytest_cache', '.mypy_cache', '.ruff_cache',
82 '.tox', '.gradle', '.next', '.nuxt', '.ipynb_checkpoints', '.eggs', '.parcel-cache', '.sass-cache', '.Rcache',
83 '*.Rcheck', '*.egg-info', 'target', 'build', 'dist', '_build', '_site', 'htmlcov',
84]
85// Build output only by convention: a folder called build or dist can also hold something somebody wrote.
86const GENERIC = new Set(['target', 'build', 'dist', '_build', '_site', '.cache'])
87const RENV_LIBRARY = '*/renv/library' // R package library managed by renv
88
89const regenName = (name: string) => REGEN_NAMES.some(n => (n.startsWith('*') ? name.endsWith(n.slice(1)) : name === n))
90
91/** Which part of the folder's own path makes all of it build output (its name or a specific parent), or undefined. */
92export function regenContext(dir: string): string | undefined {
93 const parts = dir.split('/').filter(Boolean)
94 for (let k = 0; k < parts.length; k++) {
95 const p = parts[k]!
96 if (p === 'library' && parts[k - 1] === 'renv') return 'renv/library'
97 if (regenName(p) && (k === parts.length - 1 || !GENERIC.has(p))) return p
98 }
99 return undefined
100}
101
102const DATA_EXT = new Set((
103 'csv tsv rds rda rdata vcf bcf bam cram sam bai fam bed bim ped map gds bgen pgen pvar psam h5 hdf5 hdf parquet feather ' +
104 'arrow xlsx xls ods fasta fa fna faa fastq fq gtf gff gff3 gz bz2 xz zst zip tar tgz 7z sqlite sqlite3 db ipynb tex bib ' +
105 'docx doc odt pdf pptx key npy npz mat sav dta sas7bdat rdb rdx qs fst'
106).split(' '))
107const SOURCE_EXT = new Set((
108 'r py rs js mjs ts tsx jsx c h cc cpp hpp java go sh bash zsh jl sql md rmd qmd rst toml yaml yml json cfg ini css html ' +
109 'cmake lock txt nf smk pl rb'
110).split(' '))
111const SOURCE_NAMES = new Set(['makefile', 'dockerfile', 'license', 'readme', 'description', 'namespace', 'snakefile'])
112
113/** Name-only guess for one file. Never reads the file. */
114export function classifyName(name: string): Exclude<Cls, 'regen'> {
115 const n = name.toLowerCase()
116 if (n === '.env' || n.startsWith('.env.') || /\.(pem|key|p12|pfx|kdbx|keystore)$/.test(n)) return 'secret'
117 if (['.netrc', '.npmrc', '.pypirc', '.renviron', 'credentials'].includes(n)) return 'secret'
118 if (n.startsWith('id_') && !n.endsWith('.pub')) return 'secret'
119 const ext = n.includes('.') ? n.slice(n.lastIndexOf('.') + 1) : ''
120 if (DATA_EXT.has(ext)) return 'data'
121 if (SOURCE_EXT.has(ext) || SOURCE_NAMES.has(n)) return 'source'
122 return 'other'
123}
124
125const CLASS_WORDS: Record<Exclude<Cls, 'regen'>, [string, string]> = {
126 data: ['data file', 'data files'], secret: ['likely secret file', 'likely secret files'],
127 source: ['source file', 'source files'], other: ['other file', 'other files'],
128}
129
130// ---- where the folder sits ----------------------------------------------------------------------
131
132const SYNC = /(^|\/)(Dropbox[^/]*|OneDrive[^/]*|Google ?Drive[^/]*|GoogleDrive[^/]*|iCloud ?Drive[^/]*|Mobile Documents|com~apple~CloudDocs|pCloud ?Drive|Box Sync|Library\/CloudStorage)(\/|$)/i
133const SYSTEM_TOP = /^\/(usr|etc|var|bin|sbin|lib|lib32|lib64|opt|boot|dev|proc|sys|run|root|home|Users|System|Library|Applications|Volumes|private|mnt|media|tmp)$/
134const HOME_FOLDERS = /^(Desktop|Documents|Downloads|Pictures|Music|Movies|Videos|Library|Applications|Public)$/i
135
136/** A hedged hint when the path looks like it lives in a synced folder, or in a folder iCloud may sync. */
137export function syncHint(path: string, home?: string): string | undefined {
138 const m = SYNC.exec(path)
139 if (m) {
140 const name = /^(Mobile Documents|com~apple~CloudDocs)$/i.test(m[2]!) ? 'iCloud Drive' : /^Library\/CloudStorage$/i.test(m[2]!) ? 'cloud storage' : m[2]!
141 return `Looks synced (${name}): deletes may reach your other devices; its trash may keep a copy (not checked)`
142 }
143 if (home && parentOf(path) === home && /^(Desktop|Documents)$/i.test(base(path)))
144 return 'Desktop and Documents may sync with iCloud (not checked): a delete could reach your other devices'
145 return undefined
146}
147
148export type Critical = { reason: string; skipScan: boolean }
149
150/** CRITICAL targets: root, home and its parents, top-level system folders, the usual home folders, the working directory or a parent of it. */
151export function criticalReason(dir: string, ctx: { home?: string; cwd?: string; runCwd?: string }): Critical | undefined {
152 const { home } = ctx
153 if (dir === '/') return { reason: 'the filesystem root', skipScan: true }
154 if (home && dir === home) return { reason: 'your home folder', skipScan: true }
155 if (home && inside(home, dir)) return { reason: 'a parent of your home folder', skipScan: true }
156 if (SYSTEM_TOP.test(dir)) return { reason: 'a top-level system folder', skipScan: true }
157 if (home && parentOf(dir) === home && HOME_FOLDERS.test(base(dir))) return { reason: `your ${base(dir)} folder`, skipScan: false }
158 for (const [where, label] of [[ctx.runCwd, 'the folder the command runs in'], [ctx.cwd, 'the current working directory']] as const) {
159 if (where === undefined || !inside(where, dir)) continue
160 return { reason: where === dir ? label : `a parent of ${label}`, skipScan: false }
161 }
162 return undefined
163}
164
165// ---- git view of one folder ---------------------------------------------------------------------
166
167type Bucket = 'pushed' | 'local' | 'changed' | 'untracked' | 'ignored' | 'outside'
168type PathSet = { exact: Set<string>; dirs: Set<string>; all: string[] } // dirs: collapsed directories, with a trailing '/'
169type GitView = {
170 root: string // the logical root: where git runs
171 isRoot: boolean // the folder IS the repository root (its .git goes with it)
172 noRemote: boolean
173 changed: PathSet; untracked: PathSet; ignored: PathSet
174 touched: PathSet // files in commits that no remote has
175 unpushedCommits: number
176 tracked: string[] // absolute paths
177}
178
179function pathSet(abs: string[]): PathSet {
180 return { exact: new Set(abs.filter(p => !p.endsWith('/'))), dirs: new Set(abs.filter(p => p.endsWith('/'))), all: abs }
181}
182// `path` is listed, or lies below a collapsed directory that is listed
183function covers(s: PathSet, path: string): boolean {
184 if (s.exact.has(path)) return true
185 for (let i = path.indexOf('/', 1); i > 0; i = path.indexOf('/', i + 1)) if (s.dirs.has(path.slice(0, i + 1))) return true
186 return s.dirs.has(path + '/')
187}
188const hasBelow = (s: PathSet, dir: string) => s.all.some(e => e.startsWith(dir + '/'))
189
190function bucketOf(path: string, isDir: boolean, g: GitView): Bucket {
191 if (covers(g.changed, path) || (isDir && hasBelow(g.changed, path))) return 'changed'
192 if (covers(g.untracked, path) || (isDir && hasBelow(g.untracked, path))) return 'untracked'
193 if (covers(g.ignored, path) || (isDir && hasBelow(g.ignored, path))) return 'ignored'
194 if (g.noRemote || covers(g.touched, path) || (isDir && hasBelow(g.touched, path))) return 'local'
195 return 'pushed'
196}
197
198type Probe = (argv: string[], cwd?: string) => Promise<RunResult | undefined>
199
200// null: the folder is not inside any repository
201async function gitView(run: Run, dir: string): Promise<GitView | null> {
202 const gitRun = (cwd: string, ...argv: string[]) => run([...GIT, ...argv], cwd)
203 const top = await gitRun(dir, 'rev-parse', '--show-toplevel', '--show-prefix')
204 if (top.exitCode !== 0) return null
205 const [toplevel = '', prefix = ''] = top.stdout.split('\n').map(l => l.replace(/\r$/, ''))
206 const rel = prefix.replace(/\/$/, '')
207 // git reports the real path; keep the path the user typed so that every path below matches the listing
208 const root = !rel ? dir : dir.endsWith('/' + rel) ? dir.slice(0, dir.length - rel.length - 1) : toplevel
209 const spec = rel ? ['--', rel] : []
210 const remotes = lines((await gitRun(root, 'remote')).stdout)
211 const status = await gitRun(root, 'status', '--porcelain=v1', '-z', '--ignored', ...spec)
212 if (status.exitCode !== 0) throw new Error(`git status failed: ${status.stderr.trim().split('\n')[0] || `exit ${status.exitCode}`}`)
213 const ents = statusEntries(status.stdout).map(e => ({ xy: e.xy, abs: `${root}/${e.path}` }))
214 const lost = (xy: string) => xy !== '??' && xy !== '!!' && !(xy === ' D' || xy === 'D ')
215 const files = await gitRun(root, 'ls-files', '-z', ...spec)
216 const unpushed = remotes.length
217 ? await gitRun(root, '-c', 'core.quotepath=off', 'log', '--name-only', '--format=%x01%h', '-n', '300', 'HEAD', '--not', '--remotes', ...spec)
218 : undefined
219 const touched: string[] = []
220 let commits = 0
221 for (const l of lines(unpushed?.exitCode === 0 ? unpushed.stdout : '')) {
222 if (l.startsWith('\x01')) commits++
223 else touched.push(`${root}/${l}`)
224 }
225 return {
226 root, isRoot: !rel, noRemote: remotes.length === 0,
227 changed: pathSet(ents.filter(e => lost(e.xy)).map(e => e.abs)),
228 untracked: pathSet(ents.filter(e => e.xy === '??').map(e => e.abs)),
229 ignored: pathSet(ents.filter(e => e.xy === '!!').map(e => e.abs)),
230 touched: pathSet(touched), unpushedCommits: commits,
231 tracked: files.stdout.split('\0').filter(Boolean).map(p => `${root}/${p}`),
232 }
233}
234
235// ---- the listing --------------------------------------------------------------------------------
236
237/** `( -type d ( -name n1 -o -name n2 ... ) -prune )`: build/cache directories and .git directories are not entered. */
238export function pruneExpr(): string[] {
239 const names = REGEN_NAMES.flatMap((n, k) => [...(k ? ['-o'] : []), '-name', n])
240 return ['(', '-type', 'd', '(', ...names, '-o', '-path', RENV_LIBRARY, '-o', '-name', '.git', ')', '-prune', ')']
241}
242
243type Entry = { path: string; dir: boolean; cls: Cls; bucket: Bucket; kb?: number }
244type Scan = {
245 dir: string; mode: Mode; git: GitView | null
246 entries: Entry[]; sampled: boolean; listed: number
247 gitDirs: string[] // .git directories found below the folder, other than its own
248 nested: { root: string; text: string; atStake: boolean }[]; nestedMore: number
249 totalKb?: number; files?: number; filesLowerBound?: boolean
250 newest?: string; newestCount?: number; newestMins?: number
251 links: number; crossesFs: boolean
252 ctxRegen?: string; partial: boolean; whole?: { text: string; atStake: boolean }
253}
254
255async function scanFolder(light: Run, heavy: Probe, state: { slow: boolean }, dir: string, ctx: FolderContext): Promise<Scan> {
256 const known = ctx.known?.get(dir)
257 const git = await gitView(light, dir)
258 // a build/ or dist/ that git tracks holds committed files: judge it by what is in it, not by its name
259 const ctxRegen = git && git.tracked.length > 0 ? undefined : regenContext(dir)
260 const expr = pruneExpr()
261 const walk = (...tail: string[]) => ['find', dir, '-mindepth', '1', ...expr, ...tail]
262
263 // build/cache and .git directories (not entered), then the other files, capped
264 const prunedOut = await heavy(['find', dir, '-mindepth', '1', ...expr.slice(1, -1), '-print'])
265 const prunedAll = lines(prunedOut?.stdout ?? '')
266 const ownGit = `${dir}/.git`
267 const gitDirs = prunedAll.filter(p => base(p) === '.git' && p !== ownGit)
268 const prunedRegen = prunedAll.filter(p => base(p) !== '.git')
269 const listRes = ctxRegen ? undefined : await heavy(walk('-o', '-type', 'f', '-print'))
270 const listed = lines(listRes?.stdout ?? '')
271 const sampled = listed.length > SAMPLE_CAP || !!listRes?.isStdoutTruncated
272 const files = listed.slice(0, SAMPLE_CAP)
273
274 const s: Scan = {
275 dir, mode: ctx.mode, git, entries: [], sampled, listed: listed.length, gitDirs, nested: [], nestedMore: 0,
276 totalKb: known?.kb, files: known?.files, filesLowerBound: known?.filesLowerBound,
277 links: 0, crossesFs: false, ctxRegen, partial: false,
278 }
279 if (!ctxRegen && git?.isRoot) s.whole = await wholeRepoFact(light, git.root, git.noRemote)
280
281 // nested repositories: their .git goes too; look at the first few
282 const roots = gitDirs.map(p => p.slice(0, -5))
283 s.nestedMore = Math.max(0, roots.length - NESTED_CAP)
284 for (const root of roots.slice(0, NESTED_CAP)) s.nested.push({ root, ...(await nestedFact(light, root)) })
285 const nestedIn = (p: string) => roots.some(r => p.startsWith(r + '/'))
286
287 const where = (p: string, isDir: boolean): Bucket => (git ? bucketOf(p, isDir, git) : 'outside')
288 for (const p of files)
289 if (!nestedIn(p)) s.entries.push({ path: p, dir: false, cls: ctxRegen ? 'regen' : classifyName(base(p)), bucket: where(p, false) })
290 for (const p of prunedRegen.filter(q => !nestedIn(q))) {
291 const tracked = git?.tracked.some(t => t.startsWith(p + '/')) ?? false
292 const cls: Cls = GENERIC.has(base(p)) && tracked ? 'source' : 'regen' // a committed dist/ or build/ is not just build output
293 s.entries.push({ path: p, dir: true, cls, bucket: where(p, true) })
294 }
295 if (ctxRegen) s.entries.push({ path: dir, dir: true, cls: 'regen', bucket: where(dir, true) })
296
297 // sizes: regenerable directories as a whole, data and secrets one by one, the folder in total
298 await sizes(heavy, s, ctxRegen !== undefined)
299 if (s.totalKb === undefined && !state.slow) s.totalKb = parseTotal(await heavy(['du', '-sk', dir]), dir)
300 if (!ctxRegen && !state.slow) await recency(heavy, s, walk)
301 if (!state.slow) s.links = lines((await heavy(walk('-o', '-type', 'l', '-print')))?.stdout ?? '').length
302 if (!state.slow && s.totalKb !== undefined) {
303 const one = parseTotal(await heavy(['du', '-skx', dir]), dir)
304 s.crossesFs = one !== undefined && s.totalKb - one > Math.max(64, s.totalKb * 0.01)
305 }
306 s.partial = state.slow
307 return s
308}
309
310const parseTotal = (r: RunResult | undefined, dir: string) => (r ? duMap(r.stdout).get(dir) : undefined)
311
312async function sizes(heavy: Probe, s: Scan, regenOnly: boolean): Promise<void> {
313 const dirs = s.entries.filter(e => e.dir && e.path !== s.dir)
314 if (dirs.length) {
315 const m = duMap((await heavy(['du', '-sk', ...dirs.slice(0, 40).map(e => e.path)]))?.stdout ?? '')
316 for (const e of dirs) e.kb = m.get(e.path)
317 }
318 if (regenOnly) return
319 const order: Cls[] = ['data', 'secret']
320 const picked = s.entries.filter(e => !e.dir && order.includes(e.cls)).slice(0, SIZE_CAP)
321 for (const part of chunks(picked, 60)) {
322 const m = duMap((await heavy(['du', '-k', ...part.map(e => e.path)]))?.stdout ?? '')
323 for (const e of part) e.kb = m.get(e.path)
324 }
325}
326
327async function recency(heavy: Probe, s: Scan, walk: (...tail: string[]) => string[]): Promise<void> {
328 for (const [mins, label] of BANDS) {
329 const r = await heavy(walk('-o', '-type', 'f', '-mmin', `-${mins}`, '-print'))
330 if (!r) return
331 const n = lines(r.stdout).length
332 if (n > 0) {
333 s.newest = label
334 s.newestCount = n
335 s.newestMins = mins
336 return
337 }
338 }
339 s.newest = 'not edited in 30 days'
340}
341
342async function nestedFact(light: Run, root: string): Promise<{ text: string; atStake: boolean }> {
343 try {
344 const remote = await light([...GIT, 'remote'], root)
345 const fact = await wholeRepoFact(light, root, lines(remote.stdout).length === 0)
346 const st = await light([...GIT, 'status', '--porcelain=v1', '-z'], root)
347 const dirty = statusEntries(st.stdout).length
348 return { text: dirty ? `${fact.text}, ${plural(dirty, 'uncommitted path')}` : fact.text, atStake: fact.atStake || dirty > 0 }
349 } catch (err) {
350 return { text: `${root}/ state unknown (${clip((err as Error).message, 60)})`, atStake: true }
351 }
352}
353
354// ---- the verdict --------------------------------------------------------------------------------
355
356type Verdict = { level: RiskLevel; recoverable: Recoverable; because: string; headline: string; onlyRepos: boolean; gitLine?: string; stake: string[] }
357
358function count<T>(a: T[], f: (x: T) => boolean) {
359 return a.filter(f).length
360}
361
362function judge(s: Scan, crit: Critical | undefined): Verdict {
363 const e = s.entries
364 const real = e.filter(x => x.cls !== 'regen')
365 const lostBy = (b: Bucket) => real.filter(x => x.bucket === b)
366 const changed = lostBy('changed'), untracked = lostBy('untracked'), outside = lostBy('outside'), ignored = lostBy('ignored'), local = lostBy('local')
367 const pushed = lostBy('pushed')
368 const whole = !!s.git?.isRoot
369 const nestedRisk = s.nested.some(n => n.atStake)
370 const hard = [...changed, ...untracked, ...outside]
371 const hardIgnored = ignored.filter(x => x.cls === 'data' || x.cls === 'secret')
372 const softIgnored = ignored.filter(x => x.cls !== 'data' && x.cls !== 'secret')
373 // committed but unpushed: restorable from .git while it stays, gone with it when the repository goes whole
374 const localLost = whole ? local : []
375 const machineOnly = whole ? [] : local
376 const noRemote = !!s.git?.noRemote
377 const onlyRepos = !s.git && s.entries.length === 0 && s.nested.length > 0 && s.nestedMore === 0 && !nestedRisk && !s.partial
378
379 let level: RiskLevel = 'low'
380 if (hard.length || hardIgnored.length || localLost.length || (whole && s.whole?.atStake) || nestedRisk) level = 'high'
381 else if (machineOnly.length && !noRemote) level = 'high'
382 else if (softIgnored.length || (s.sampled && !s.git)) level = 'medium'
383 if (level === 'medium' && s.newestMins !== undefined && s.newestMins <= 1440) level = 'high' // recent edits: active work
384 if (s.partial && level === 'low') level = 'unknown'
385 if (s.mode === 'modify') level = s.partial ? 'unknown' : real.some(x => x.cls === 'secret') || nestedRisk ? 'medium' : 'low'
386 if (crit) level = 'high'
387
388 const lost = hard.length + hardIgnored.length + softIgnored.length + localLost.length
389 const bits: string[] = []
390 if (s.git) {
391 const trackedOk = Math.max(0, s.git.tracked.length - count(s.git.tracked, t => covers(s.git!.changed, t) || covers(s.git!.touched, t)))
392 if (trackedOk) bits.push(`${plural(trackedOk, 'file')} pushed`)
393 if (s.git.unpushedCommits) bits.push(`${plural(s.git.unpushedCommits, 'commit')} not pushed`)
394 else if (noRemote && s.git.tracked.length) bits.push('no remote')
395 if (changed.length) bits.push(`${plural(changed.length, 'modified file')}`)
396 if (untracked.length) bits.push(`${plural(untracked.length, 'untracked file')}`)
397 if (ignored.length) bits.push(`${plural(ignored.length, 'ignored file')}`)
398 } else if (outside.length) bits.push(`${plural(outside.length, 'file')}${s.sampled ? ' in the sample' : ''}, no git history`)
399
400 let recoverable: Recoverable
401 let because: string
402 if (s.partial && !lost) (recoverable = 'unknown', because = 'the scan stopped early, so assume not')
403 else if (!lost && !machineOnly.length) {
404 recoverable = 'yes'
405 because = real.length || s.git?.tracked.length ? 'everything except build output is pushed (as of your last fetch)'
406 : s.entries.length ? 'only build output, which a rebuild recreates'
407 : onlyRepos ? 'every repository inside is fully pushed' : 'the folder is empty'
408 } else if (!lost) {
409 recoverable = noRemote ? 'yes' : 'partly'
410 because = noRemote ? 'git restore brings files back while .git stays; there is no remote copy' : `${plural(machineOnly.length, 'file')} committed but not pushed come back only from this .git`
411 } else {
412 recoverable = pushed.length || machineOnly.length ? 'partly' : 'no'
413 const gone = [changed.length && 'modified files', untracked.length && 'untracked files', hard.length - changed.length - untracked.length > 0 && 'files outside git', ignored.length && 'ignored files', localLost.length && 'unpushed commits'].filter(Boolean)
414 because = `${gone.join(', ')} cannot be restored${pushed.length ? '; pushed files can' : ''}`
415 }
416 if (recoverable === 'yes' && ((whole && s.whole?.atStake) || nestedRisk)) {
417 recoverable = 'partly'
418 because = 'commits on no remote, stashes or worktrees exist only in the .git that goes too'
419 }
420 if (s.mode === 'modify') {
421 recoverable = s.partial ? 'unknown' : 'partly'
422 because = 'contents stay; old permissions and owners are not recorded'
423 }
424 if (crit && s.mode !== 'modify') {
425 recoverable = 'no'
426 because = `${crit.reason} is targeted; assume everything is gone (backups such as Time Machine are not checked)`
427 }
428
429 const stake = real
430 .filter(x => (x.bucket === 'changed' || x.bucket === 'untracked' || x.bucket === 'outside' || x.bucket === 'ignored' || (whole && x.bucket === 'local')) && (x.cls === 'data' || x.cls === 'secret' || x.bucket === 'changed'))
431 .sort((a, b) => (b.kb ?? 0) - (a.kb ?? 0))
432 .map(x => `${x.path.startsWith(s.dir + '/') ? x.path.slice(s.dir.length + 1) : x.path}${x.kb !== undefined ? ` (${kbText(x.kb)})` : ''}`)
433
434 const headline = crit
435 ? `${cap1(crit.reason)} is targeted`
436 : level === 'low'
437 ? s.entries.length && !real.length ? 'build output only (regenerable by name)' : onlyRepos ? 'only whole repositories, all fully pushed' : 'fully pushed (as of your last fetch)'
438 : `${recoverable === 'no' ? 'not recoverable' : recoverable === 'partly' ? 'only partly recoverable' : 'see the folder details'}: ${because}`
439 return { level, recoverable, because, headline, onlyRepos, gitLine: bits.length ? bits.join(' · ') : undefined, stake }
440}
441
442// ---- words --------------------------------------------------------------------------------------
443
444function render(s: Scan, v: Verdict, crit: Critical | undefined, ctx: FolderContext): FolderRisk {
445 const rows: Row[] = []
446 const name = (p: string) => (p.startsWith(s.dir + '/') ? p.slice(s.dir.length + 1) : p)
447 if (crit) rows.push(['Target', cap1(crit.reason)])
448
449 // where it sits
450 if (s.git?.isRoot) rows.push(['Where', 'Repository root · its history goes too'])
451 else if (s.git) rows.push(['Where', `Inside repository ${clip(s.git.root, 60)}`])
452 else rows.push(['Where', 'Outside git · no history, no remote'])
453 if (s.git?.isRoot && s.whole?.atStake) rows.push(['Local only', s.whole.text.replace(/^.*?\/ {2}/, '')])
454 if (s.nested.length)
455 rows.push(['Includes', `${plural(s.nested.length + s.nestedMore, 'repository', 'repositories')}, .git too: ${s.nested.map(n => clip(n.text.replace(s.dir + '/', ''), 80)).join('; ')}${s.nestedMore ? `; +${s.nestedMore} more` : ''}`])
456
457 // what kind of content
458 const bits: string[] = []
459 if (s.ctxRegen) bits.push(`all build output (${s.ctxRegen})`)
460 for (const cls of ['data', 'secret', 'source', 'other'] as const) {
461 const m = s.entries.filter(e => e.cls === cls && !e.dir)
462 if (!m.length) continue
463 const kb = sum(m.map(e => e.kb ?? 0))
464 const sized = m.some(e => e.kb !== undefined) && (cls === 'data' || cls === 'secret')
465 bits.push(`${plural(m.length, CLASS_WORDS[cls][0], CLASS_WORDS[cls][1])}${sized ? ` (${kbText(kb)}${m.some(e => e.kb === undefined) ? '+' : ''})` : ''}`)
466 }
467 const regen = s.entries.filter(e => e.cls === 'regen' && e.dir && e.path !== s.dir).sort((a, b) => (b.kb ?? 0) - (a.kb ?? 0))
468 if (regen.length)
469 bits.push(`build output: ${regen.slice(0, 3).map(e => `${name(e.path)}/${e.kb !== undefined ? ` ${kbText(e.kb)}` : ''}`).join(', ')}${regen.length > 3 ? `, +${regen.length - 3} more` : ''}`)
470 const committedGeneric = s.entries.filter(e => e.dir && e.cls === 'source')
471 if (committedGeneric.length) bits.push(`${committedGeneric.map(e => name(e.path) + '/').join(', ')} hold tracked files`)
472 if (ctx.note) bits.push(ctx.note)
473 if (s.sampled) bits.push(`sampled: first ${fmt(SAMPLE_CAP)} of ${fmt(s.listed)}${s.listed > SAMPLE_CAP ? '' : '+'} files`)
474 rows.push(['Contains', bits.length ? bits.join(' · ') : s.nested.length ? 'only the repositories above' : 'nothing'])
475
476 // size and recency
477 const size = [s.totalKb !== undefined ? kbText(s.totalKb) : undefined, s.files !== undefined ? `${fmt(s.files)}${s.filesLowerBound ? '+' : ''} ${s.files === 1 && !s.filesLowerBound ? 'file' : 'files'}` : undefined].filter(Boolean) as string[]
478 if (s.newest) size.push(s.newestMins === undefined ? s.newest : `last edit ${s.newest}`)
479 if (s.newestMins !== undefined && s.newestMins <= 1440) size.push('likely active work')
480 if (size.length) rows.push(['Size', size.join(' · ')])
481
482 if (v.gitLine && s.mode !== 'modify') rows.push(['Git', v.gitLine])
483 if (v.stake.length) rows.push(['At stake', `${v.stake.slice(0, 3).map(x => clip(x, 50)).join(', ')}${v.stake.length > 3 ? `, +${v.stake.length - 3} more` : ''}`])
484
485 const hints: string[] = []
486 if (s.partial) hints.push('Scan stopped early (slow disk or huge folder): figures are partial')
487 const sync = syncHint(s.dir, ctx.home)
488 if (sync) hints.push(sync)
489 if (s.links > 0) hints.push(`${plural(s.links, 'symlink')} inside: only the links are removed, not their targets`)
490 if (s.crossesFs) hints.push('Includes another filesystem (a mounted drive?): rm -r follows it unless run with --one-file-system')
491 for (const h of hints.slice(0, 2)) rows.push(['Note', clip(h, 140)])
492 rows.push(['Recoverable', `${cap1(v.recoverable)} · ${v.because}`])
493
494 return {
495 path: s.dir, title: `${dirLabel(s.dir, ctx)} · ${LEVEL_WORD[v.level]}`, level: v.level, rows, headline: v.headline,
496 regenerable: s.entries.length > 0 && s.entries.every(e => e.cls === 'regen'), complete: !s.partial, onlyRepos: v.onlyRepos,
497 }
498}
499const name2 = (p: string, root: string) => p.slice(root.length + 1)
500
501// ---- symbolic links and unscanned folders -------------------------------------------------------
502
503async function symlinkRisk(light: Run, dir: string, ctx: FolderContext, crit: Critical | undefined): Promise<FolderRisk> {
504 const toDir = lines((await light(['find', '-L', dir, '-maxdepth', '0', '-type', 'd', '-print'])).stdout).length > 0
505 const act: Record<Mode, string> = {
506 delete: 'rm removes only the link. A trailing slash (link/) can reach the target, so check how it is written',
507 modify: 'chmod -R changes the target of a link named on the command line; chown may change the link itself',
508 overwrite: 'cp or mv -f replaces the link or writes through it; the target was not inspected',
509 }
510 const level: RiskLevel = ctx.mode === 'delete' && !crit ? 'low' : 'medium'
511 const rows: Row[] = []
512 if (crit) rows.push(['Target', cap1(crit.reason)])
513 rows.push(['What', `Symbolic link ${toDir ? 'to a folder' : 'to a file (or broken)'} · the target was not inspected`])
514 rows.push(['Effect', act[ctx.mode]])
515 const sync = syncHint(dir, ctx.home)
516 if (sync) rows.push(['Note', sync])
517 rows.push(['Recoverable', ctx.mode === 'delete' ? 'Yes · only the link goes; the target keeps its content' : 'Unknown · the target was not inspected'])
518 return { path: dir, title: `${dirLabel(dir, ctx)} (symlink) · ${LEVEL_WORD[level]}`, level, rows, headline: ctx.mode === 'delete' ? 'a symlink: only the link goes, not its target' : 'a symlink: its target was not inspected', regenerable: false, complete: ctx.mode === 'delete' }
519}
520
521const dirLabel = (dir: string, ctx: FolderContext) => (dir === '/' ? '/' : `${ctx.runCwd && dir.startsWith(ctx.runCwd + '/') ? name2(dir, ctx.runCwd) : dir}/`)
522
523function unscanned(dir: string, crit: Critical | undefined, ctx: FolderContext, why: string): FolderRisk {
524 const level: RiskLevel = crit ? 'high' : 'unknown'
525 const rows: Row[] = []
526 if (crit) rows.push(['Target', cap1(crit.reason)])
527 rows.push(['Scan', `Skipped · ${why}`])
528 const hint = syncHint(dir, ctx.home)
529 if (hint) rows.push(['Note', hint])
530 if (crit && ctx.mode === 'modify')
531 rows.push(['Recoverable', `Partly · contents stay, but old permissions and owners are not recorded; changing them across ${crit.reason} can break ssh keys and system files`])
532 else rows.push(['Recoverable', crit ? 'No · assume everything is gone (backups such as Time Machine are not checked)' : 'Unknown · nothing was inspected, so assume not'])
533 return {
534 path: dir, title: `${dirLabel(dir, ctx)} · ${LEVEL_WORD[level]}`, level, rows,
535 headline: crit ? `${cap1(crit.reason)} is targeted` : why, regenerable: false, complete: false,
536 }
537}
538
539// ---- entry point --------------------------------------------------------------------------------
540
541/** Folder risk for up to `maxFolders` directories (CRITICAL ones first). Never throws. */
542export async function assessFolders(run: Run, dirs: string[], ctx: FolderContext): Promise<{ folders: FolderRisk[]; skipped: number }> {
543 const ordered = [...new Set(dirs)].map(d => ({ d, c: criticalReason(d, ctx) })).sort((a, b) => Number(!!b.c) - Number(!!a.c))
544 const max = ctx.maxFolders ?? 3
545 const folders: FolderRisk[] = []
546 for (const { d, c } of ordered.slice(0, max)) folders.push(await assessOne(run, d, c, ctx))
547 return { folders, skipped: Math.max(0, ordered.length - max) }
548}
549
550async function assessOne(run: Run, dir: string, crit: Critical | undefined, ctx: FolderContext): Promise<FolderRisk> {
551 const state = { slow: false, failed: undefined as string | undefined }
552 // light probes (git, type tests) throw to the caller; heavy ones (tree walks) stop the scan at the first timeout
553 const light: Run = (argv, cwd) => run(argv, cwd, { timeoutMs: FOLDER_PROBE_MS })
554 const heavy: Probe = async (argv, cwd) => {
555 if (state.slow) return undefined
556 try {
557 return await run(argv, cwd, { timeoutMs: FOLDER_PROBE_MS })
558 } catch (err) {
559 state.slow = true
560 state.failed = (err as Error).message || 'probe failed'
561 return undefined
562 }
563 }
564 try {
565 const link = lines((await light(['find', dir, '-maxdepth', '0', '-type', 'l', '-print'])).stdout).length > 0
566 if (link) return await symlinkRisk(light, dir, ctx, crit)
567 if (crit?.skipScan) return unscanned(dir, crit, ctx, 'too broad to inspect')
568 const s = await scanFolder(light, heavy, state, dir, ctx)
569 return render(s, judge(s, crit), crit, ctx)
570 } catch (err) {
571 return unscanned(dir, crit, ctx, `could not scan (${clip((err as Error).message || 'probe failed', 100)})`)
572 }
573}
574hooks/recover.ts 245 lines1// The recoverability check: for paths that are about to be deleted, overwritten or emptied,
2// say what can NOT be got back. READ-ONLY and argv-only like the rest of the measurer.
3//
4// Questions answered per path, using git where the path is inside a repository:
5// uncommitted change tracked, but modified/staged: the edit exists only in the working tree
6// untracked never committed: no history anywhere
7// ignored git-ignored: data, builds, .Rdata ... not in history either
8// unpushed committed, but on no remote branch: exists only on this machine
9// pushed and clean recoverable (git restore, or clone) -- "as of your last fetch"
10// not in a repository no history, no remote: gone for good
11// and, for a repository that is deleted whole (its .git goes too): the local-only commits,
12// stashes and uncommitted work that go with it.
13//
14// Contract: never throws, never says "safe" when a probe failed. A failed probe yields level
15// 'unknown' with a note, and the command is held anyway (the caller already holds it).
16import type { Group, Run } from './measure'
17
18export const PUSHED_NOTE = 'Pushed means as of your last fetch'
19export type RiskLevel = 'high' | 'medium' | 'low' | 'unknown'
20export type Risk = { level: RiskLevel; headline: string }
21export type Assessment = { risk: Risk; groups: Group[]; notes: string[] }
22
23type Cfg = { maxListed: number }
24type Tally = { changed: string[]; untracked: string[]; ignored: string[]; unpushed: string[]; trackedClean: number }
25
26export const GIT = ['git', '--no-optional-locks', '--no-pager', '--literal-pathspecs']
27const lines = (s: string) => s.split('\n').map(l => l.replace(/\r$/, '')).filter(l => l !== '')
28const n = (k: number) => k.toLocaleString('en-US')
29const plural = (k: number, w: string, many = w + 's') => `${n(k)} ${k === 1 ? w : many}`
30const dirname = (p: string) => (p.lastIndexOf('/') > 0 ? p.slice(0, p.lastIndexOf('/')) : '/')
31const inside = (child: string, parent: string) => child === parent || child.startsWith(parent === '/' ? '/' : parent + '/')
32const chunks = <T>(a: T[], size: number) => Array.from({ length: Math.ceil(a.length / size) }, (_, k) => a.slice(k * size, (k + 1) * size))
33const cap = (title: string, rows: string[], max: number, flags: { subset?: boolean; extra?: boolean } = {}): Group => ({ title, rows: rows.slice(0, max), more: Math.max(0, rows.length - max), ...flags })
34
35const LABELS: Record<string, string> = { ' M': 'modified', 'M ': 'staged', MM: 'staged+modified', 'A ': 'new (staged)', AM: 'new+modified', ' T': 'type changed', 'R ': 'renamed (staged)', UU: 'CONFLICT' }
36const label = (xy: string) => LABELS[xy] ?? `changed (${xy.trim() || xy})`
37
38/** `git status --porcelain=v1 -z` as raw entries: the two status letters and the path (repository-root relative). */
39export function statusEntries(out: string): { xy: string; path: string }[] {
40 const res: { xy: string; path: string }[] = []
41 const toks = out.split('\0')
42 for (let i = 0; i < toks.length; i++) {
43 const t = toks[i]!
44 if (t.length < 4) continue
45 const xy = t.slice(0, 2)
46 if (xy[0] === 'R' || xy[0] === 'C') i++ // the next token is the rename's original path
47 res.push({ xy, path: t.slice(3) })
48 }
49 return res
50}
51
52/** `git status --porcelain=v1 -z` into change classes. Paths are relative to the repository root. */
53export function parseStatusZ(out: string): { changed: string[]; untracked: string[]; ignored: string[] } {
54 const res = { changed: [] as string[], untracked: [] as string[], ignored: [] as string[] }
55 for (const { xy, path } of statusEntries(out)) {
56 if (xy === '??') res.untracked.push(path)
57 else if (xy === '!!') res.ignored.push(path)
58 else if (xy[1] === 'D' && xy[0] === ' ') continue // already gone from the working tree
59 else if (xy[0] === 'D' && xy[1] === ' ') continue // staged removal, nothing left to lose
60 else res.changed.push(`${label(xy)} ${path}`)
61 }
62 return res
63}
64
65async function git(run: Run, cwd: string, ...argv: string[]) {
66 return run([...GIT, ...argv], cwd)
67}
68
69async function dirsAmong(run: Run, paths: string[]): Promise<Set<string>> {
70 const found = new Set<string>()
71 for (const part of chunks(paths, 60))
72 for (const l of lines((await run(['find', ...part, '-maxdepth', '0', '-type', 'd', '-print'])).stdout)) found.add(l)
73 return found
74}
75
76async function countFiles(run: Run, dir: string): Promise<number> {
77 const res = await run(['find', dir, '!', '-type', 'd', '-print'])
78 return lines(res.stdout).length
79}
80
81/** Repositories wholly inside a deleted directory (their .git goes too). */
82async function nestedRepos(run: Run, dirs: string[]): Promise<string[]> {
83 const roots = new Set<string>()
84 for (const d of dirs.slice(0, 20)) {
85 const res = await run(['find', d, '-maxdepth', '6', '-name', '.git', '-prune', '-print'])
86 for (const l of lines(res.stdout)) if (l.endsWith('/.git')) roots.add(l.slice(0, -5))
87 }
88 return [...roots].slice(0, 20)
89}
90
91export async function assessLoss(run: Run, paths: string[], cfg: Cfg): Promise<Assessment> {
92 const out: Assessment = { risk: { level: 'unknown', headline: '' }, groups: [], notes: [] }
93 try {
94 return await assess(run, [...new Set(paths)].slice(0, 500), cfg)
95 } catch (err) {
96 out.risk.headline = 'could not check what git could restore: assume nothing is recoverable'
97 out.notes.push(`risk check failed: ${(err as Error).message || 'probe failed'}`)
98 return out
99 }
100}
101
102async function assess(run: Run, paths: string[], cfg: Cfg): Promise<Assessment> {
103 const notes: string[] = []
104 const dirs = await dirsAmong(run, paths)
105 const dirList = [...dirs]
106
107 // 1. which repository (if any) holds each path; repos that are deleted whole count as scopes of their own
108 const scopes = new Map<string, string[]>() // repo root -> paths inside it
109 const outside: string[] = []
110 const rootOf = new Map<string, string | null>()
111 for (const p of paths) {
112 const probe = dirs.has(p) ? p : dirname(p)
113 if (!rootOf.has(probe)) {
114 const res = await git(run, probe, 'rev-parse', '--show-toplevel')
115 rootOf.set(probe, res.exitCode === 0 ? res.stdout.trim() : null)
116 }
117 const root = rootOf.get(probe)
118 if (root) scopes.set(root, [...(scopes.get(root) ?? []), p])
119 else outside.push(p)
120 }
121 const whole = new Set<string>()
122 for (const root of scopes.keys()) if (dirs.has(root) && paths.includes(root)) whole.add(root)
123 for (const r of await nestedRepos(run, dirList)) {
124 whole.add(r)
125 if (!scopes.has(r)) scopes.set(r, [r])
126 }
127
128 // 2. per repository: uncommitted, untracked, ignored, unpushed
129 const total: Tally = { changed: [], untracked: [], ignored: [], unpushed: [], trackedClean: 0 }
130 const wholeFacts: string[] = []
131 let wholeAtStake = 0
132 let noRemote = false
133 for (const [root, inRepo] of scopes) {
134 const remotes = lines((await git(run, root, 'remote')).stdout)
135 const t = await tally(run, root, inRepo, cfg, notes, remotes.length > 0)
136 total.changed.push(...t.changed)
137 total.untracked.push(...t.untracked)
138 total.ignored.push(...t.ignored)
139 total.unpushed.push(...t.unpushed)
140 total.trackedClean += t.trackedClean
141 if (remotes.length === 0) noRemote = true
142 if (whole.has(root)) {
143 const fact = await wholeRepoFact(run, root, remotes.length === 0)
144 wholeFacts.push(fact.text)
145 if (fact.atStake) wholeAtStake++
146 }
147 }
148
149 // 3. words
150 const groups: Group[] = []
151 const max = cfg.maxListed
152 if (wholeFacts.length) groups.push(cap('Whole repositories inside (their history goes too)', wholeFacts, max, { extra: true }))
153 if (total.changed.length) groups.push(cap('Uncommitted changes (gone for good)', total.changed, max))
154 if (total.untracked.length) groups.push(cap('Untracked, never committed (gone for good)', total.untracked, max, { subset: true }))
155 if (total.ignored.length) groups.push(cap('Git-ignored (gone unless you can regenerate them)', total.ignored, max, { subset: true }))
156 if (total.unpushed.length)
157 groups.push(cap('Committed, not pushed (only in this .git)', total.unpushed, max))
158 if (outside.length) groups.push(cap('Outside git, no history or remote (gone for good)', outside, max, { subset: true }))
159
160 const lostForever = total.changed.length + total.untracked.length + total.ignored.length + outside.length
161 const bits: string[] = []
162 if (total.changed.length) bits.push(`${plural(total.changed.length, 'file')} with uncommitted changes`)
163 if (total.untracked.length) bits.push(`${plural(total.untracked.length, 'untracked path')}`)
164 if (total.ignored.length) bits.push(`${plural(total.ignored.length, 'git-ignored path')}`)
165 if (outside.length) bits.push(`${plural(outside.length, 'path')} outside any repository`)
166 if (total.unpushed.length) bits.push(`${plural(total.unpushed.length, 'unpushed commit')} touching these paths`)
167 if (wholeFacts.length) bits.push(`${plural(wholeFacts.length, 'whole repository', 'whole repositories')}`)
168
169 let risk: Risk
170 // a repository deleted whole is only a hard loss when something exists nowhere else: commits on no remote, stashes,
171 // linked worktrees, or no remote at all. A clean, fully pushed one comes back with a fresh clone.
172 const hardLoss = total.changed.length + total.untracked.length + outside.length + total.unpushed.length + wholeAtStake
173 if (hardLoss > 0) risk = { level: 'high', headline: `${bits.join(', ')}: none of it is on a remote` }
174 else if (total.ignored.length > 0) risk = { level: 'medium', headline: `${bits.join(', ')}: not in version control` }
175 else if (scopes.size > 0)
176 risk = {
177 level: 'low',
178 headline: noRemote
179 ? `${plural(total.trackedClean, 'tracked file')} committed locally (no remote): git restore works while .git stays`
180 : `${plural(total.trackedClean, 'tracked file')} pushed (as of your last fetch): git restore or a fresh clone brings them back${wholeFacts.length ? ', no local-only commits or stashes' : ''}`,
181 }
182 else risk = { level: 'unknown', headline: 'nothing to assess' }
183
184 if (noRemote && scopes.size > 0) notes.push('No remote: every commit exists only on this machine')
185 if (lostForever === 0 && total.unpushed.length === 0 && wholeFacts.length === 0) notes.push(PUSHED_NOTE)
186 return { risk, groups, notes }
187}
188
189async function tally(run: Run, root: string, inRepo: string[], cfg: Cfg, notes: string[], hasRemote: boolean): Promise<Tally> {
190 const t: Tally = { changed: [], untracked: [], ignored: [], unpushed: [], trackedClean: 0 }
191 const specs = inRepo.slice(0, 200)
192 const st = await git(run, root, 'status', '--porcelain=v1', '-z', '--ignored', '--', ...specs)
193 if (st.exitCode !== 0) throw new Error(`git status failed in ${root}: ${st.stderr.trim().split('\n')[0] || `exit ${st.exitCode}`}`)
194 if (st.isStdoutTruncated) notes.push(`${root}: git status output was truncated, counts are lower bounds`)
195 const parsed = parseStatusZ(st.stdout)
196 t.changed = parsed.changed
197 // a collapsed directory ("build/") stands for many files: say how many
198 const expand = async (entries: string[]) => {
199 const rows: string[] = []
200 let looked = 0
201 for (const e of entries) {
202 if (e.endsWith('/') && looked < 10) {
203 looked++
204 const count = await countFiles(run, `${root}/${e.replace(/\/$/, '')}`)
205 rows.push(`${e} (${plural(count, 'file')})`)
206 } else rows.push(e)
207 }
208 return rows
209 }
210 t.untracked = await expand(parsed.untracked)
211 t.ignored = await expand(parsed.ignored)
212
213 const ls = await git(run, root, 'ls-files', '-z', '--', ...specs)
214 const tracked = ls.stdout.split('\0').filter(Boolean).length
215 t.trackedClean = Math.max(0, tracked - parsed.changed.length)
216
217 // without any remote every commit is "unpushed": that says nothing about these paths, so it is a note instead
218 if (hasRemote) {
219 const unpushed = await git(run, root, 'log', '--format=%h %s', '-n', String(cfg.maxListed + 1), 'HEAD', '--not', '--remotes', '--', ...specs)
220 if (unpushed.exitCode === 0) t.unpushed = lines(unpushed.stdout)
221 }
222 // an empty repository (no HEAD yet) makes `git log HEAD` fail: that is not an error worth stopping for
223 return t
224}
225
226/** What a whole repository would take with it. `atStake` is true when any of it exists nowhere else. */
227export async function wholeRepoFact(run: Run, root: string, noRemote: boolean): Promise<{ text: string; atStake: boolean }> {
228 const local = await git(run, root, 'rev-list', '--count', '--branches', '--tags', '--not', '--remotes')
229 const stash = await git(run, root, 'stash', 'list')
230 const wt = await git(run, root, 'worktree', 'list')
231 const localCommits = local.exitCode === 0 ? Number(local.stdout.trim()) || 0 : undefined
232 const stashes = lines(stash.stdout).length
233 const worktrees = lines(wt.stdout).length - 1
234 const bits = [
235 localCommits !== undefined ? `${plural(localCommits, 'commit')} on no remote` : 'local-only commits unknown',
236 `${plural(stashes, 'stash', 'stashes')}`,
237 ]
238 if (worktrees > 0) bits.push(`${plural(worktrees, 'linked worktree')}`)
239 if (noRemote) bits.push('no remote')
240 return {
241 text: `${root}/ ${bits.join(', ')}`,
242 atStake: localCommits === undefined || localCommits > 0 || stashes > 0 || worktrees > 0 || noRemote,
243 }
244}
245types/index.d.ts 12 lines1/** The runtime sensitivity: off = never ask, low = ask for everything, medium = from medium up, high = only high. */
2export type BlastLevel = 'off' | 'low' | 'medium' | 'high'
3
4declare module 'claude-code' {
5 interface PluginState {
6 'command-guard': {
7 /** The persisted runtime level (`$.store` 'level'), mirrored here so other mods, such as the moddeck side bar, can read it. Overrides the askFrom setting. */
8 level: BlastLevel
9 }
10 }
11}
12