@hasna/hooks 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -5
- package/bin/hooks-mcp.js +4862 -0
- package/bin/index.js +592 -170
- package/bin/serve.js +33 -10
- package/dist/db/index.d.ts +8 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2870 -2709
- package/dist/lib/db-writer.d.ts +30 -1
- package/dist/lib/installer.d.ts +40 -1
- package/dist/lib/local-opt-in.d.ts +26 -0
- package/dist/lib/registration.d.ts +75 -0
- package/dist/lib/registry.d.ts +14 -0
- package/dist/lib/sync.d.ts +17 -1
- package/dist/sdk/index.d.ts +103 -0
- package/dist/sdk/index.js +1050 -0
- package/dist/storage.js +15 -1
- package/hooks/hook-agent-rules-version-check/README.md +1 -1
- package/hooks/hook-trash-guard/README.md +147 -0
- package/hooks/hook-trash-guard/package.json +12 -0
- package/hooks/hook-trash-guard/src/hook.ts +1142 -0
- package/package.json +10 -3
- package/scripts/validate-package.ts +31 -4
- package/hooks/codewith-native-common.test.ts +0 -1935
- package/hooks/hook-affected-tests/tsconfig.json +0 -25
- package/hooks/hook-agent-rules-version-check/src/hook.test.ts +0 -104
- package/hooks/hook-agent-rules-version-check/tsconfig.json +0 -25
- package/hooks/hook-announce-start/tsconfig.json +0 -25
- package/hooks/hook-announce-stop/tsconfig.json +0 -25
- package/hooks/hook-autoformat/tsconfig.json +0 -25
- package/hooks/hook-branchprotect/tsconfig.json +0 -25
- package/hooks/hook-checkbugs/tsconfig.json +0 -15
- package/hooks/hook-checkdocs/tsconfig.json +0 -15
- package/hooks/hook-checkfiles/tsconfig.json +0 -15
- package/hooks/hook-checklint/tsconfig.json +0 -15
- package/hooks/hook-checkpoint/tsconfig.json +0 -25
- package/hooks/hook-checksecurity/tsconfig.json +0 -15
- package/hooks/hook-checktasks/tsconfig.json +0 -20
- package/hooks/hook-checktests/tsconfig.json +0 -15
- package/hooks/hook-conflict-detect/tsconfig.json +0 -25
- package/hooks/hook-contextrefresh/tsconfig.json +0 -25
- package/hooks/hook-desktopnotify/tsconfig.json +0 -25
- package/hooks/hook-dm-inject/tsconfig.json +0 -25
- package/hooks/hook-envsetup/tsconfig.json +0 -25
- package/hooks/hook-failure-to-task/tsconfig.json +0 -25
- package/hooks/hook-filelock/tsconfig.json +0 -25
- package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +0 -302
- package/hooks/hook-fleet-blockers-gate/tsconfig.json +0 -25
- package/hooks/hook-fleet-catchup/src/hook.test.ts +0 -156
- package/hooks/hook-fleet-catchup/tsconfig.json +0 -25
- package/hooks/hook-gitguard/tsconfig.json +0 -25
- package/hooks/hook-knowledge-context/src/hook.test.ts +0 -379
- package/hooks/hook-packageage/tsconfig.json +0 -25
- package/hooks/hook-permissionguard/tsconfig.json +0 -25
- package/hooks/hook-phonenotify/tsconfig.json +0 -25
- package/hooks/hook-precompact/tsconfig.json +0 -25
- package/hooks/hook-protectfiles/tsconfig.json +0 -25
- package/hooks/hook-scanoutput/src/hook.test.ts +0 -217
- package/hooks/hook-spiral-detector/src/hook.test.ts +0 -72
- package/hooks/hook-stylescheck/tsconfig.json +0 -25
- package/hooks/hook-typecheck-gate/tsconfig.json +0 -25
- package/hooks/hook-workspace-repos-guard/src/hook.test.ts +0 -466
- package/hooks/hook-workspace-repos-guard/tsconfig.json +0 -21
- package/hooks/mention-context/src/hook.test.ts +0 -68
|
@@ -0,0 +1,1142 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PreToolUse hook: trash-guard
|
|
5
|
+
*
|
|
6
|
+
* Intercepts `rm` issued through the Bash tool and rewrites it into
|
|
7
|
+
* `<abs>/trash guard <same args>` (the `@hasna/trash` guard subcommand), so
|
|
8
|
+
* the delete lands in a trash store instead of being unrecoverable.
|
|
9
|
+
*
|
|
10
|
+
* Self-contained block decision. This hook NEVER consults a store, a network,
|
|
11
|
+
* a credential or the `@hasna/trash` package internals to decide whether to
|
|
12
|
+
* refuse a delete: it either has a trash binary to redirect to, or it refuses
|
|
13
|
+
* the command. There is no path in which a delete it cannot redirect is
|
|
14
|
+
* silently allowed:
|
|
15
|
+
*
|
|
16
|
+
* trash absent -> permissionDecision "deny" with a reason
|
|
17
|
+
* trash present -> updatedInput rewrite to `<abs>/trash guard <same args>`
|
|
18
|
+
*
|
|
19
|
+
* Output contract (deliberately narrow):
|
|
20
|
+
* - no delete verb -> emit nothing (stay silent and cheap)
|
|
21
|
+
* - rewritable `rm` -> permissionDecision "allow" + a COMPLETE
|
|
22
|
+
* updatedInput, and nothing else
|
|
23
|
+
* - anything else -> permissionDecision "deny" + a reason
|
|
24
|
+
*
|
|
25
|
+
* A malformed rewrite is worse than a refusal: the harness falls back to the
|
|
26
|
+
* ORIGINAL tool input when `updatedInput` is missing or empty, which runs the
|
|
27
|
+
* raw `rm`. So the rewrite path emits a complete schema-valid tool_input
|
|
28
|
+
* (every key the model supplied, plus command/description/timeout/
|
|
29
|
+
* run_in_background, plus dangerouslyDisableSandbox when it was present) and
|
|
30
|
+
* re-verifies the rewritten text before allowing it — if the verification
|
|
31
|
+
* fails, the hook denies instead of allowing a partial rewrite.
|
|
32
|
+
*
|
|
33
|
+
* Scope and ownership:
|
|
34
|
+
* - Only `rm`'s own grammar is rewritten. `rmdir`, `unlink`, `shred`,
|
|
35
|
+
* `git rm`, `git clean` and `find -delete` are REFUSED with a reason —
|
|
36
|
+
* their flags and semantics are not `rm`'s, and a wrong rewrite is silent
|
|
37
|
+
* where a refusal is visible.
|
|
38
|
+
* - Deletes under the protected repo-checkout roots
|
|
39
|
+
* (`$HOME/.hasna/repos/clones`, `$HOME/workspace/repos`) belong to the
|
|
40
|
+
* `workspace-repos-guard` hook, which blocks them. This hook abstains
|
|
41
|
+
* there: it does not restate that policy, it hands the command over. A
|
|
42
|
+
* command that mixes an owned `rm` with a handed-over one is refused
|
|
43
|
+
* rather than partially rewritten.
|
|
44
|
+
*
|
|
45
|
+
* Fail-closed on internal error: if evaluation throws, the hook denies a
|
|
46
|
+
* command that contains a delete-verb word and stays silent otherwise.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { homedir } from "os";
|
|
50
|
+
import { dirname, isAbsolute, join, normalize, resolve, sep } from "path";
|
|
51
|
+
import { readFileSync, realpathSync, statSync } from "fs";
|
|
52
|
+
import { spawnSync } from "node:child_process";
|
|
53
|
+
import {
|
|
54
|
+
SYSTEM_PROTECTED_ROOTS,
|
|
55
|
+
getCommand,
|
|
56
|
+
readInput,
|
|
57
|
+
respond,
|
|
58
|
+
warn,
|
|
59
|
+
type CodewithHookInput,
|
|
60
|
+
type CodewithHookOutput,
|
|
61
|
+
} from "../../codewith-native-common";
|
|
62
|
+
|
|
63
|
+
const RULE = "trash-guard";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Default Bash-tool timeout (ms) re-supplied on the rewrite. The rewritten
|
|
67
|
+
* command uploads and verifies the hosted capsule before source cleanup; an explicit
|
|
68
|
+
* value keeps the rewritten tool_input complete.
|
|
69
|
+
*/
|
|
70
|
+
const DEFAULT_TIMEOUT_MS = 600000;
|
|
71
|
+
|
|
72
|
+
/** Description supplied only when the model did not provide one. */
|
|
73
|
+
const DEFAULT_DESCRIPTION = "Delete via trash guard (rm intercepted and made recoverable)";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Protected repo-checkout roots. The POLICY for these roots — block every
|
|
77
|
+
* delete under them — belongs to `hook-workspace-repos-guard`; this hook only
|
|
78
|
+
* needs to know where the handoff boundary is so it does not shadow it.
|
|
79
|
+
*/
|
|
80
|
+
const HANDOFF_ROOTS = [".hasna/repos/clones", "workspace/repos"] as const;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The catastrophic class (§15 decision 11.3): the filesystem root, the home
|
|
84
|
+
* directory itself, the Hasna state root, the credential stores, and the
|
|
85
|
+
* system roots the `pre-bash` hook's protected-path rules already name. A
|
|
86
|
+
* delete on one of these is refused outright — never rewritten, never trashed.
|
|
87
|
+
* Keep the list in step with `pre-bash`'s protected roots.
|
|
88
|
+
*/
|
|
89
|
+
const PROTECTED_HOME_TREES = [".hasna", ".ssh", ".aws"] as const;
|
|
90
|
+
|
|
91
|
+
/** Delete verbs that are refused rather than rewritten, with their reason. */
|
|
92
|
+
const REFUSED_VERBS: Record<string, string> = {
|
|
93
|
+
rmdir:
|
|
94
|
+
"`rmdir` is a delete that trash-guard does not rewrite (it takes rmdir's flags, not rm's). Re-run it as `rm -d -- <path>` for an empty directory, or `rm -r -- <path>` for a non-empty one, and it is redirected into trash.",
|
|
95
|
+
unlink:
|
|
96
|
+
"`unlink` is a delete that trash-guard does not rewrite. Re-run it as `rm -- <path>` and it is redirected into trash.",
|
|
97
|
+
shred:
|
|
98
|
+
"`shred` destroys content irrecoverably and is never allowed. Re-run it as `rm -- <path>` and it is redirected into trash.",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Commands whose own argument string is a shell command we cannot rewrite in
|
|
103
|
+
* place. When one of these is in command position and the command mentions a
|
|
104
|
+
* delete verb, the command is refused rather than guessed at.
|
|
105
|
+
*/
|
|
106
|
+
const OPAQUE_COMMANDS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "eval", "source", ".", "su", "runuser"]);
|
|
107
|
+
|
|
108
|
+
/** Wrapper commands whose real command follows them (with their value-taking options). */
|
|
109
|
+
const WRAPPERS: Record<string, string[]> = {
|
|
110
|
+
sudo: ["-u", "--user", "-g", "--group", "-p", "--prompt", "-C", "--chdir", "-h", "--host", "-r", "--role", "-t", "--type", "-U", "--other-user"],
|
|
111
|
+
doas: ["-u", "-C"],
|
|
112
|
+
env: ["-u", "--unset", "-C", "--chdir", "-S", "--split-string"],
|
|
113
|
+
nice: ["-n", "--adjustment"],
|
|
114
|
+
ionice: ["-c", "--class", "-n", "--classdata", "-p", "--pid"],
|
|
115
|
+
stdbuf: ["-i", "-o", "-e", "--input", "--output", "--error"],
|
|
116
|
+
time: ["-f", "--format", "-o", "--output"],
|
|
117
|
+
timeout: ["-s", "--signal", "-k", "--kill-after"],
|
|
118
|
+
nohup: [],
|
|
119
|
+
setsid: [],
|
|
120
|
+
command: [],
|
|
121
|
+
builtin: [],
|
|
122
|
+
exec: [],
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Wrappers that consume positional arguments before their real command:
|
|
127
|
+
* `timeout 5 rm -rf x` runs `rm`, so the duration is skipped, not read as the
|
|
128
|
+
* verb.
|
|
129
|
+
*/
|
|
130
|
+
const WRAPPER_POSITIONALS: Record<string, number> = {
|
|
131
|
+
timeout: 1,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Applet dispatchers. `busybox rm` is a different `rm` implementation whose
|
|
136
|
+
* flags are not GNU's, so its deletes are refused rather than rewritten.
|
|
137
|
+
*/
|
|
138
|
+
const APPLET_DISPATCHERS = new Set(["busybox", "toybox"]);
|
|
139
|
+
|
|
140
|
+
/** Shell keywords that may precede the real command in a segment. */
|
|
141
|
+
const KEYWORDS = new Set(["!", "{", "}", "then", "do", "else", "elif", "if", "while", "until", "for", "case", "esac", "fi", "done", "select", "function"]);
|
|
142
|
+
|
|
143
|
+
/** git's value-taking global options, skipped before the subcommand. */
|
|
144
|
+
const GIT_VALUE_OPTIONS = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env"]);
|
|
145
|
+
|
|
146
|
+
/** Delete verbs an opaque command string might carry. */
|
|
147
|
+
const DELETE_VERB_WORD_RE = /(^|[^A-Za-z0-9_.-])(rm|rmdir|unlink|shred)(?![A-Za-z0-9_.-])/;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Whether a command string mentions a delete verb as its own word, once
|
|
151
|
+
* quoting is stripped. Used only where the command cannot be classified
|
|
152
|
+
* structurally (an opaque shell string, an unterminated command, a hook
|
|
153
|
+
* failure) — never to justify a rewrite, only a refusal.
|
|
154
|
+
*/
|
|
155
|
+
export function mentionsDeleteVerb(text: string): boolean {
|
|
156
|
+
return DELETE_VERB_WORD_RE.test(decodeWord(text));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The command name inside a word: `/bin/rm` and `./rm` are `rm`. A path-prefixed
|
|
163
|
+
* verb is still the same verb, and the swap replaces the whole word.
|
|
164
|
+
*/
|
|
165
|
+
function baseVerb(word: string): string {
|
|
166
|
+
const slash = word.lastIndexOf("/");
|
|
167
|
+
return slash >= 0 ? word.slice(slash + 1) : word;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface Word {
|
|
171
|
+
/** Raw source text of the word, including its quotes and escapes. */
|
|
172
|
+
text: string;
|
|
173
|
+
/** Byte offset of the word start in the original command string. */
|
|
174
|
+
start: number;
|
|
175
|
+
/** Byte offset just past the word end in the original command string. */
|
|
176
|
+
end: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface Segment {
|
|
180
|
+
words: Word[];
|
|
181
|
+
/** Indices into `words` that are redirection targets, not operands. */
|
|
182
|
+
redirects: Set<number>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface LexResult {
|
|
186
|
+
segments: Segment[];
|
|
187
|
+
/** False when a quote/substitution/heredoc could not be followed to its end. */
|
|
188
|
+
trustworthy: boolean;
|
|
189
|
+
/** Bodies of `$(...)` and backtick substitutions, in source order. */
|
|
190
|
+
substitutions: string[];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A reason this hook refuses a command. */
|
|
194
|
+
interface Refusal {
|
|
195
|
+
reason: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** One `rm` verb located in command position, with the span of its verb token. */
|
|
199
|
+
interface RmHit {
|
|
200
|
+
word: Word;
|
|
201
|
+
operands: Word[];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface ScanResult {
|
|
205
|
+
rmHits: RmHit[];
|
|
206
|
+
/** `rm` invocations deliberately left to workspace-repos-guard. */
|
|
207
|
+
handoffHits: RmHit[];
|
|
208
|
+
refusals: Refusal[];
|
|
209
|
+
/** Deletes on the protected class (§15 decision 11.3) — refused, never redirected. */
|
|
210
|
+
protectedHits: Refusal[];
|
|
211
|
+
trustworthy: boolean;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* ------------------------------------------------------------------ */
|
|
215
|
+
/* Lexer — source spans, not decoded tokens */
|
|
216
|
+
/* ------------------------------------------------------------------ */
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Split a command string into segments and words while recording each word's
|
|
220
|
+
* byte span in the ORIGINAL string. Quoting is tracked rather than decoded so
|
|
221
|
+
* a rewrite can replace exactly the verb token and leave every other byte —
|
|
222
|
+
* flags, quoting, globs, variables, substitutions — untouched.
|
|
223
|
+
*/
|
|
224
|
+
export function lexCommand(command: string): LexResult {
|
|
225
|
+
const segments: Segment[] = [];
|
|
226
|
+
const substitutions: string[] = [];
|
|
227
|
+
let words: Word[] = [];
|
|
228
|
+
let redirects = new Set<number>();
|
|
229
|
+
let wordStart = -1;
|
|
230
|
+
let pendingRedirect = false;
|
|
231
|
+
let trustworthy = true;
|
|
232
|
+
let i = 0;
|
|
233
|
+
const n = command.length;
|
|
234
|
+
|
|
235
|
+
const closeWord = (end: number): void => {
|
|
236
|
+
if (wordStart >= 0) {
|
|
237
|
+
const index = words.length;
|
|
238
|
+
words.push({ text: command.slice(wordStart, end), start: wordStart, end });
|
|
239
|
+
if (pendingRedirect) redirects.add(index);
|
|
240
|
+
wordStart = -1;
|
|
241
|
+
}
|
|
242
|
+
pendingRedirect = false;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const closeSegment = (end: number): void => {
|
|
246
|
+
closeWord(end);
|
|
247
|
+
if (words.length > 0) segments.push({ words, redirects });
|
|
248
|
+
words = [];
|
|
249
|
+
redirects = new Set<number>();
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const scanSingle = (from: number): number => {
|
|
253
|
+
const end = command.indexOf("'", from + 1);
|
|
254
|
+
return end === -1 ? -1 : end + 1;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const scanBacktick = (from: number): number => {
|
|
258
|
+
let j = from + 1;
|
|
259
|
+
while (j < n) {
|
|
260
|
+
if (command[j] === "\\") {
|
|
261
|
+
j += 2;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (command[j] === "`") return j + 1;
|
|
265
|
+
j++;
|
|
266
|
+
}
|
|
267
|
+
return -1;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const scanParen = (from: number): number => {
|
|
271
|
+
let depth = 0;
|
|
272
|
+
let j = from;
|
|
273
|
+
while (j < n) {
|
|
274
|
+
const c = command[j];
|
|
275
|
+
if (c === "\\") {
|
|
276
|
+
j += 2;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (c === "'") {
|
|
280
|
+
const e = scanSingle(j);
|
|
281
|
+
if (e < 0) return -1;
|
|
282
|
+
j = e;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (c === '"') {
|
|
286
|
+
const e = scanDouble(j);
|
|
287
|
+
if (e < 0) return -1;
|
|
288
|
+
j = e;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (c === "`") {
|
|
292
|
+
const e = scanBacktick(j);
|
|
293
|
+
if (e < 0) return -1;
|
|
294
|
+
j = e;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (c === "(") depth++;
|
|
298
|
+
else if (c === ")") {
|
|
299
|
+
depth--;
|
|
300
|
+
if (depth === 0) return j + 1;
|
|
301
|
+
}
|
|
302
|
+
j++;
|
|
303
|
+
}
|
|
304
|
+
return -1;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
function scanDouble(from: number): number {
|
|
308
|
+
let j = from + 1;
|
|
309
|
+
while (j < n) {
|
|
310
|
+
const c = command[j];
|
|
311
|
+
if (c === "\\") {
|
|
312
|
+
j += 2;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (c === '"') return j + 1;
|
|
316
|
+
if (c === "`") {
|
|
317
|
+
const e = scanBacktick(j);
|
|
318
|
+
if (e < 0) return -1;
|
|
319
|
+
j = e;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (c === "$" && command[j + 1] === "(") {
|
|
323
|
+
const e = scanParen(j + 1);
|
|
324
|
+
if (e < 0) return -1;
|
|
325
|
+
j = e;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
j++;
|
|
329
|
+
}
|
|
330
|
+
return -1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
while (i < n) {
|
|
334
|
+
const ch = command[i];
|
|
335
|
+
|
|
336
|
+
if (ch === " " || ch === "\t" || ch === "\r") {
|
|
337
|
+
closeWord(i);
|
|
338
|
+
i++;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (ch === "\n") {
|
|
342
|
+
closeSegment(i);
|
|
343
|
+
i++;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (ch === ";") {
|
|
347
|
+
closeSegment(i);
|
|
348
|
+
i++;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (ch === "&") {
|
|
352
|
+
closeSegment(i);
|
|
353
|
+
i += command[i + 1] === "&" ? 2 : 1;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (ch === "|") {
|
|
357
|
+
closeSegment(i);
|
|
358
|
+
i += command[i + 1] === "|" ? 2 : 1;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (ch === "(" || ch === ")") {
|
|
362
|
+
closeSegment(i);
|
|
363
|
+
i++;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (ch === "<" && command[i + 1] === "<" && command[i + 2] !== "<") {
|
|
367
|
+
// A here-document body is data, not a command: skip it whole so its
|
|
368
|
+
// text is never classified as a delete, and never rewritten.
|
|
369
|
+
const marker = command.slice(i + 2).match(/^-?[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/);
|
|
370
|
+
if (!marker) {
|
|
371
|
+
trustworthy = false;
|
|
372
|
+
i = n;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const bodyStart = command.indexOf("\n", i + 2 + marker[0].length);
|
|
376
|
+
if (bodyStart === -1) {
|
|
377
|
+
trustworthy = false;
|
|
378
|
+
i = n;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const rest = command.slice(bodyStart + 1);
|
|
382
|
+
const endIdx = findHeredocEnd(rest, marker[2]);
|
|
383
|
+
if (endIdx === -1) {
|
|
384
|
+
trustworthy = false;
|
|
385
|
+
i = n;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
closeWord(i);
|
|
389
|
+
i = bodyStart + 1 + endIdx + marker[2].length;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (ch === ">" || ch === "<") {
|
|
393
|
+
closeWord(i);
|
|
394
|
+
pendingRedirect = true;
|
|
395
|
+
i += command[i + 1] === ch && ch === ">" ? 2 : 1;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (ch === "'") {
|
|
399
|
+
if (wordStart < 0) wordStart = i;
|
|
400
|
+
const e = scanSingle(i);
|
|
401
|
+
if (e < 0) {
|
|
402
|
+
trustworthy = false;
|
|
403
|
+
i = n;
|
|
404
|
+
} else i = e;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (ch === '"') {
|
|
408
|
+
if (wordStart < 0) wordStart = i;
|
|
409
|
+
const e = scanDouble(i);
|
|
410
|
+
if (e < 0) {
|
|
411
|
+
trustworthy = false;
|
|
412
|
+
i = n;
|
|
413
|
+
} else i = e;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (ch === "`") {
|
|
417
|
+
if (wordStart < 0) wordStart = i;
|
|
418
|
+
const e = scanBacktick(i);
|
|
419
|
+
if (e < 0) {
|
|
420
|
+
trustworthy = false;
|
|
421
|
+
i = n;
|
|
422
|
+
} else {
|
|
423
|
+
substitutions.push(command.slice(i + 1, e - 1));
|
|
424
|
+
i = e;
|
|
425
|
+
}
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (ch === "$" && command[i + 1] === "(") {
|
|
429
|
+
if (wordStart < 0) wordStart = i;
|
|
430
|
+
const e = scanParen(i + 1);
|
|
431
|
+
if (e < 0) {
|
|
432
|
+
trustworthy = false;
|
|
433
|
+
i = n;
|
|
434
|
+
} else {
|
|
435
|
+
substitutions.push(command.slice(i + 2, e - 1));
|
|
436
|
+
i = e;
|
|
437
|
+
}
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (ch === "\\") {
|
|
441
|
+
if (wordStart < 0) wordStart = i;
|
|
442
|
+
if (i + 1 >= n) {
|
|
443
|
+
trustworthy = false;
|
|
444
|
+
i = n;
|
|
445
|
+
} else i += 2;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (wordStart < 0) wordStart = i;
|
|
450
|
+
i++;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
closeSegment(n);
|
|
454
|
+
return { segments, trustworthy, substitutions };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Offset of the here-document terminator line inside `rest`, or -1. */
|
|
458
|
+
function findHeredocEnd(rest: string, delimiter: string): number {
|
|
459
|
+
let from = 0;
|
|
460
|
+
while (from <= rest.length) {
|
|
461
|
+
const at = rest.indexOf(delimiter, from);
|
|
462
|
+
if (at === -1) return -1;
|
|
463
|
+
const lineStart = at === 0 || rest[at - 1] === "\n";
|
|
464
|
+
const after = at + delimiter.length;
|
|
465
|
+
const lineEnd = after === rest.length || rest[after] === "\n" || rest[after] === "\r";
|
|
466
|
+
if (lineStart && lineEnd) return at;
|
|
467
|
+
from = at + 1;
|
|
468
|
+
}
|
|
469
|
+
return -1;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Strip one word's quoting and escapes, the way the shell would for a name. */
|
|
473
|
+
export function decodeWord(text: string): string {
|
|
474
|
+
let out = "";
|
|
475
|
+
let i = 0;
|
|
476
|
+
while (i < text.length) {
|
|
477
|
+
const ch = text[i];
|
|
478
|
+
if (ch === "\\") {
|
|
479
|
+
if (i + 1 < text.length) out += text[i + 1];
|
|
480
|
+
i += 2;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (ch === "'") {
|
|
484
|
+
const end = text.indexOf("'", i + 1);
|
|
485
|
+
if (end === -1) {
|
|
486
|
+
out += text.slice(i + 1);
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
out += text.slice(i + 1, end);
|
|
490
|
+
i = end + 1;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (ch === '"') {
|
|
494
|
+
i++;
|
|
495
|
+
while (i < text.length && text[i] !== '"') {
|
|
496
|
+
if (text[i] === "\\" && i + 1 < text.length) {
|
|
497
|
+
out += text[i + 1];
|
|
498
|
+
i += 2;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
out += text[i];
|
|
502
|
+
i++;
|
|
503
|
+
}
|
|
504
|
+
i++;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
out += ch;
|
|
508
|
+
i++;
|
|
509
|
+
}
|
|
510
|
+
return out;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/* ------------------------------------------------------------------ */
|
|
514
|
+
/* Command-position analysis */
|
|
515
|
+
/* ------------------------------------------------------------------ */
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Index of the command-position word in a segment, or -1 when the segment has
|
|
519
|
+
* no command (a bare assignment, an empty segment). Command position is the
|
|
520
|
+
* first word, skipping assignment prefixes, shell keywords and recognized
|
|
521
|
+
* command wrappers — NOT any word that happens to read like a verb, so
|
|
522
|
+
* `printf '%s\n' rm` is not a delete.
|
|
523
|
+
*/
|
|
524
|
+
function commandWordIndex(segment: Segment): number {
|
|
525
|
+
let index = 0;
|
|
526
|
+
let guard = 0;
|
|
527
|
+
while (index < segment.words.length && guard++ < 32) {
|
|
528
|
+
const raw = segment.words[index].text;
|
|
529
|
+
const decoded = decodeWord(raw);
|
|
530
|
+
if (ASSIGNMENT_RE.test(decoded) && !decoded.startsWith("-")) {
|
|
531
|
+
index++;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (KEYWORDS.has(decoded)) {
|
|
535
|
+
index++;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const wrapperValues = WRAPPERS[decoded];
|
|
539
|
+
if (wrapperValues) {
|
|
540
|
+
index++;
|
|
541
|
+
while (index < segment.words.length) {
|
|
542
|
+
const option = decodeWord(segment.words[index].text);
|
|
543
|
+
if (!option.startsWith("-") || option === "-") break;
|
|
544
|
+
if (option === "--") {
|
|
545
|
+
index++;
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
index++;
|
|
549
|
+
if (wrapperValues.includes(option)) index++;
|
|
550
|
+
}
|
|
551
|
+
let positionals = WRAPPER_POSITIONALS[decoded] ?? 0;
|
|
552
|
+
while (positionals > 0 && index < segment.words.length) {
|
|
553
|
+
const positional = decodeWord(segment.words[index].text);
|
|
554
|
+
if (positional.startsWith("-") && positional !== "-") break;
|
|
555
|
+
index++;
|
|
556
|
+
positionals--;
|
|
557
|
+
}
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
return index;
|
|
561
|
+
}
|
|
562
|
+
return -1;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Operand words of an `rm` whose verb is at `verbIndex`. */
|
|
566
|
+
function rmOperands(segment: Segment, verbIndex: number): Word[] {
|
|
567
|
+
const operands: Word[] = [];
|
|
568
|
+
let seenDoubleDash = false;
|
|
569
|
+
for (let index = verbIndex + 1; index < segment.words.length; index++) {
|
|
570
|
+
if (segment.redirects.has(index)) continue;
|
|
571
|
+
const decoded = decodeWord(segment.words[index].text);
|
|
572
|
+
if (!seenDoubleDash) {
|
|
573
|
+
if (decoded === "--") {
|
|
574
|
+
seenDoubleDash = true;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (decoded.startsWith("-") && decoded !== "-") continue;
|
|
578
|
+
}
|
|
579
|
+
operands.push(segment.words[index]);
|
|
580
|
+
}
|
|
581
|
+
return operands;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/* ------------------------------------------------------------------ */
|
|
585
|
+
/* Handoff to workspace-repos-guard */
|
|
586
|
+
/* ------------------------------------------------------------------ */
|
|
587
|
+
|
|
588
|
+
function handoffRoots(home: string): string[] {
|
|
589
|
+
return HANDOFF_ROOTS.map((suffix) => join(home, ...suffix.split("/")));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Expand `~`, `$HOME`, `${HOME}` spellings to the resolved home directory. */
|
|
593
|
+
export function expandHomeSpelling(token: string, home: string): string {
|
|
594
|
+
if (token === "~" || token === "$HOME" || token === "${HOME}") return home;
|
|
595
|
+
if (token.startsWith("~/")) return join(home, token.slice(2));
|
|
596
|
+
if (token.startsWith("$HOME/")) return join(home, token.slice("$HOME/".length));
|
|
597
|
+
if (token.startsWith("${HOME}/")) return join(home, token.slice("${HOME}/".length));
|
|
598
|
+
return token;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Absolute path of a literal operand, or null when the operand cannot be
|
|
603
|
+
* resolved before expansion (a variable, a glob, an unexpanded substitution).
|
|
604
|
+
* Unresolvable operands are treated as outside the handoff roots: the hook
|
|
605
|
+
* still rewrites them, and `trash guard` classifies what the shell expands.
|
|
606
|
+
*/
|
|
607
|
+
export function resolveLiteralTarget(raw: string, cwd: string, home: string): string | null {
|
|
608
|
+
let decoded = decodeWord(raw);
|
|
609
|
+
if (!decoded) return null;
|
|
610
|
+
if (decoded.includes("`")) return null;
|
|
611
|
+
// A bare `$HOME`/`${HOME}` is the home directory itself, not a relative path.
|
|
612
|
+
if (decoded === "$HOME" || decoded === "${HOME}" || decoded === "$HOME/" || decoded === "${HOME}/") return home;
|
|
613
|
+
const withoutHome = decoded.replace(/\$\{HOME\}/g, "").replace(/\$HOME/g, "");
|
|
614
|
+
if (withoutHome.includes("$")) return null;
|
|
615
|
+
if (/[*?[\]]/.test(decoded)) return null;
|
|
616
|
+
if (decoded.startsWith("~")) {
|
|
617
|
+
const expanded = expandHomeSpelling(decoded, home);
|
|
618
|
+
if (expanded === decoded) return null; // ~user form — another user's home
|
|
619
|
+
decoded = expanded;
|
|
620
|
+
}
|
|
621
|
+
const absolute = isAbsolute(decoded) ? normalize(decoded) : resolve(cwd, decoded);
|
|
622
|
+
const trimmed = absolute.replace(/\/+$/, "");
|
|
623
|
+
return trimmed === "" ? "/" : trimmed;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function isUnder(target: string, root: string): boolean {
|
|
627
|
+
return target === root || target.startsWith(`${root}${sep}`);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Why `target` belongs to the protected class that is refused rather than
|
|
632
|
+
* redirected, or null when it is an ordinary path. Matches both directions:
|
|
633
|
+
* the target itself (`rm -rf /etc`) and any ancestor of a protected root
|
|
634
|
+
* (`rm -rf /home`).
|
|
635
|
+
*/
|
|
636
|
+
export function protectedTargetReason(target: string, home: string): string | null {
|
|
637
|
+
if (target === home) {
|
|
638
|
+
return `\`${target}\` is the home directory itself, which is never deleted or trashed. Delete a specific file or directory inside it instead.`;
|
|
639
|
+
}
|
|
640
|
+
// Root mode, exactly as `pre-bash` evaluates it for a system root: the root
|
|
641
|
+
// itself, or any ancestor of it (`rm -rf /home` destroys every home under it).
|
|
642
|
+
for (const root of SYSTEM_PROTECTED_ROOTS) {
|
|
643
|
+
if (target !== root && !isUnder(root, target)) continue;
|
|
644
|
+
return root === "/"
|
|
645
|
+
? `\`${target}\` is the filesystem root, which is never deleted.`
|
|
646
|
+
: `\`${target}\` is the system root \`${root}\` (or an ancestor of it), which is never deleted or trashed.`;
|
|
647
|
+
}
|
|
648
|
+
for (const suffix of PROTECTED_HOME_TREES) {
|
|
649
|
+
const root = join(home, suffix);
|
|
650
|
+
// Tree mode, like `pre-bash`'s ~/.hasna rule: the store itself and
|
|
651
|
+
// everything under it.
|
|
652
|
+
if (!isUnder(target, root) && !isUnder(root, target)) continue;
|
|
653
|
+
return `\`${target}\` is the protected \`~/${suffix}\` path (or contains it), which is never deleted or trashed. Delete a specific file inside it, and only when you are sure.`;
|
|
654
|
+
}
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Whether an `rm` belongs to the protected repo-checkout roots and must be
|
|
660
|
+
* handed to `workspace-repos-guard`: any literal operand under a root, or a
|
|
661
|
+
* command whose effective cwd sits under one (a relative operand there means
|
|
662
|
+
* a path inside the guarded roots).
|
|
663
|
+
*/
|
|
664
|
+
function isHandoff(operands: Word[], cwd: string, home: string): boolean {
|
|
665
|
+
const roots = handoffRoots(home);
|
|
666
|
+
if (roots.some((root) => isUnder(cwd, root))) return true;
|
|
667
|
+
for (const operand of operands) {
|
|
668
|
+
const target = resolveLiteralTarget(operand.text, cwd, home);
|
|
669
|
+
if (target && roots.some((root) => isUnder(target, root))) return true;
|
|
670
|
+
}
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/* ------------------------------------------------------------------ */
|
|
675
|
+
/* Scan */
|
|
676
|
+
/* ------------------------------------------------------------------ */
|
|
677
|
+
|
|
678
|
+
interface ScanContext {
|
|
679
|
+
cwd: string;
|
|
680
|
+
home: string;
|
|
681
|
+
/** Recursion depth for substitution bodies. */
|
|
682
|
+
depth: number;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Classify a command string: which `rm` verbs are ours to rewrite, which are
|
|
687
|
+
* handed to the guarded-roots hook, and why anything else is refused.
|
|
688
|
+
*
|
|
689
|
+
* Only command position counts, and only `rm`'s own grammar is rewritten.
|
|
690
|
+
*/
|
|
691
|
+
export function scanCommand(command: string, options: { cwd: string; home: string; depth?: number }): ScanResult {
|
|
692
|
+
const ctx: ScanContext = { cwd: options.cwd, home: options.home, depth: options.depth ?? 0 };
|
|
693
|
+
const result: ScanResult = { rmHits: [], handoffHits: [], refusals: [], protectedHits: [], trustworthy: true };
|
|
694
|
+
|
|
695
|
+
const lexed = lexCommand(command);
|
|
696
|
+
result.trustworthy = lexed.trustworthy;
|
|
697
|
+
|
|
698
|
+
if (!lexed.trustworthy) {
|
|
699
|
+
return result;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
let cwd = ctx.cwd;
|
|
703
|
+
for (const segment of lexed.segments) {
|
|
704
|
+
const index = commandWordIndex(segment);
|
|
705
|
+
if (index < 0) continue;
|
|
706
|
+
const verb = baseVerb(decodeWord(segment.words[index].text));
|
|
707
|
+
|
|
708
|
+
if (verb === "cd") {
|
|
709
|
+
const operand = segment.words[index + 1];
|
|
710
|
+
if (operand) {
|
|
711
|
+
const target = resolveLiteralTarget(operand.text, cwd, ctx.home);
|
|
712
|
+
if (target) cwd = target;
|
|
713
|
+
else cwd = ctx.cwd;
|
|
714
|
+
}
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const refusal = REFUSED_VERBS[verb];
|
|
719
|
+
if (refusal) {
|
|
720
|
+
result.refusals.push({ reason: refusal });
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (verb === "rm") {
|
|
725
|
+
const operands = rmOperands(segment, index);
|
|
726
|
+
// `rm` with no operand deletes nothing (GNU `rm` exits "missing
|
|
727
|
+
// operand"), so `rm --help` and a bare `rm -rf` are not interceptable
|
|
728
|
+
// deletes and are left exactly as written.
|
|
729
|
+
if (operands.length === 0) continue;
|
|
730
|
+
const hit: RmHit = { word: segment.words[index], operands };
|
|
731
|
+
if (isHandoff(hit.operands, cwd, ctx.home)) {
|
|
732
|
+
result.handoffHits.push(hit);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
result.rmHits.push(hit);
|
|
736
|
+
for (const operand of hit.operands) {
|
|
737
|
+
// An unresolvable operand (variable, glob, substitution) is not
|
|
738
|
+
// classified here: the rewrite still applies, and `trash guard`
|
|
739
|
+
// classifies the paths the shell actually expands to.
|
|
740
|
+
const target = resolveLiteralTarget(operand.text, cwd, ctx.home);
|
|
741
|
+
if (!target) continue;
|
|
742
|
+
const reason = protectedTargetReason(target, ctx.home);
|
|
743
|
+
if (reason) {
|
|
744
|
+
result.protectedHits.push({ reason });
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (APPLET_DISPATCHERS.has(verb)) {
|
|
752
|
+
const appletIndex = segment.words.findIndex((word, at) => at > index && !decodeWord(word.text).startsWith("-"));
|
|
753
|
+
const applet = appletIndex >= 0 ? baseVerb(decodeWord(segment.words[appletIndex].text)) : "";
|
|
754
|
+
if (applet === "rm" || applet in REFUSED_VERBS) {
|
|
755
|
+
result.refusals.push({
|
|
756
|
+
reason: `\`${verb} ${applet}\` runs the ${verb} applet, whose own \`rm\` is not GNU \`rm\` and cannot be rewritten in place. Run the delete as \`rm -- <path>\`, which is redirected into trash.`,
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (verb === "git") {
|
|
763
|
+
const sub = gitSubcommand(segment, index);
|
|
764
|
+
if (sub === "rm" || sub === "clean") {
|
|
765
|
+
const dryOrIndexOnly = sub === "rm" ? gitRmIsNonDeleting(segment, index) : gitHasDryRun(segment, index);
|
|
766
|
+
if (!dryOrIndexOnly) {
|
|
767
|
+
result.refusals.push({
|
|
768
|
+
reason:
|
|
769
|
+
sub === "rm"
|
|
770
|
+
? "`git rm` is refused: without `--cached` it deletes the working-tree file, and its semantics are not `rm`'s. Use `rm -- <path>` (intercepted and redirected into trash) to remove the file, or `git rm --cached -- <path>` to unstage it without deleting."
|
|
771
|
+
: "`git clean` deletes untracked files irrecoverably and is refused. Remove them explicitly with `rm -- <path>` (intercepted and redirected into trash), or preview with `git clean -n`.",
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (verb === "find") {
|
|
779
|
+
const words = segment.words.map((word) => decodeWord(word.text));
|
|
780
|
+
const deleteIndex = words.indexOf("-delete");
|
|
781
|
+
if (deleteIndex >= 0) {
|
|
782
|
+
result.refusals.push({
|
|
783
|
+
reason:
|
|
784
|
+
"`find -delete` deletes files irrecoverably and is refused. List the paths first (`find … -print`), then remove them explicitly with `rm -- <path>` so the delete is redirected into trash.",
|
|
785
|
+
});
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
const execIndex = words.findIndex((word) => word === "-exec" || word === "-execdir" || word === "-ok");
|
|
789
|
+
if (execIndex >= 0 && words.slice(execIndex).some((word) => word === "rm" || word === "rmdir" || word === "unlink" || word === "shred")) {
|
|
790
|
+
result.refusals.push({
|
|
791
|
+
reason: "`find -exec <delete>` is refused: the delete runs through find, not through `rm`, and cannot be redirected into trash. Remove the paths explicitly with `rm -- <path>`.",
|
|
792
|
+
});
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (verb === "xargs" && segment.words.some((word) => decodeWord(word.text) === "rm")) {
|
|
799
|
+
result.refusals.push({
|
|
800
|
+
reason: "`xargs rm` takes its targets from stdin, so this hook cannot verify what would be deleted. Pipe the paths into an explicit `rm -- <path> …` instead, which is redirected into trash.",
|
|
801
|
+
});
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (OPAQUE_COMMANDS.has(verb) && mentionsDeleteVerb(segmentText(segment))) {
|
|
806
|
+
result.refusals.push({
|
|
807
|
+
reason: `\`${verb}\` runs a command string this hook cannot rewrite in place. Run the delete directly as \`rm -- <path>\` (intercepted and redirected into trash), or as a script that uses \`trash guard\` itself.`,
|
|
808
|
+
});
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// A delete hidden inside a command substitution runs before the rewritten
|
|
814
|
+
// verb and cannot be swapped in place — refuse rather than rewrite a
|
|
815
|
+
// command that still contains a live delete.
|
|
816
|
+
if (ctx.depth < 3) {
|
|
817
|
+
for (const body of lexed.substitutions) {
|
|
818
|
+
const inner = scanCommand(body, { cwd, home: ctx.home, depth: ctx.depth + 1 });
|
|
819
|
+
if (inner.rmHits.length > 0 || inner.refusals.length > 0 || inner.handoffHits.length > 0 || inner.protectedHits.length > 0) {
|
|
820
|
+
result.refusals.push({
|
|
821
|
+
reason: "This command runs a delete inside a command substitution `$(…)`, which this hook cannot rewrite. Run the delete as a separate `rm -- <path>` command, which is redirected into trash.",
|
|
822
|
+
});
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
} else if (lexed.substitutions.some((body) => mentionsDeleteVerb(body))) {
|
|
827
|
+
result.refusals.push({
|
|
828
|
+
reason: "This command nests a delete inside command substitutions deeper than this hook will follow. Run the delete as a separate `rm -- <path>` command.",
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
return result;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function segmentText(segment: Segment): string {
|
|
836
|
+
return segment.words.map((word) => word.text).join(" ");
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** The git subcommand, skipping git's own value-taking global options. */
|
|
840
|
+
function gitSubcommand(segment: Segment, verbIndex: number): string | null {
|
|
841
|
+
let index = verbIndex + 1;
|
|
842
|
+
while (index < segment.words.length) {
|
|
843
|
+
const option = decodeWord(segment.words[index].text);
|
|
844
|
+
if (!option.startsWith("-")) return option;
|
|
845
|
+
index++;
|
|
846
|
+
if (GIT_VALUE_OPTIONS.has(option)) index++;
|
|
847
|
+
}
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** `git rm` that does not remove a working-tree file: `--cached` or a dry run. */
|
|
852
|
+
function gitRmIsNonDeleting(segment: Segment, verbIndex: number): boolean {
|
|
853
|
+
const options = segment.words.slice(verbIndex + 1).map((word) => decodeWord(word.text));
|
|
854
|
+
if (options.includes("--cached")) return true;
|
|
855
|
+
return gitHasDryRun(segment, verbIndex);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function gitHasDryRun(segment: Segment, verbIndex: number): boolean {
|
|
859
|
+
const options = segment.words.slice(verbIndex + 1).map((word) => decodeWord(word.text));
|
|
860
|
+
return options.includes("-n") || options.includes("--dry-run");
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/* ------------------------------------------------------------------ */
|
|
864
|
+
/* Rewrite */
|
|
865
|
+
/* ------------------------------------------------------------------ */
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* A command called trash may be Apple's unrelated system utility. Resolve
|
|
869
|
+
* package provenance before executing a bounded, credential-free identity
|
|
870
|
+
* probe. Never invoke an unknown executable merely to discover what it is.
|
|
871
|
+
*/
|
|
872
|
+
export function findTrashBinary(env: NodeJS.ProcessEnv = process.env): string | null {
|
|
873
|
+
const pathValue = env.PATH ?? "";
|
|
874
|
+
const deadline = Date.now() + 2_000;
|
|
875
|
+
for (const dir of pathValue.split(":").slice(0, 64)) {
|
|
876
|
+
if (!isAbsolute(dir) || Date.now() >= deadline) continue;
|
|
877
|
+
const candidate = resolve(dir, "trash");
|
|
878
|
+
try {
|
|
879
|
+
const stat = statSync(candidate);
|
|
880
|
+
if (!stat.isFile() || (stat.mode & 0o111) === 0 || (stat.mode & 0o022) !== 0) continue;
|
|
881
|
+
const executable = realpathSync(candidate);
|
|
882
|
+
const packageRoot = resolve(dirname(executable), "../..");
|
|
883
|
+
const manifestPath = join(packageRoot, "package.json");
|
|
884
|
+
const manifestStat = statSync(manifestPath);
|
|
885
|
+
if (!manifestStat.isFile() || manifestStat.size > 16_384 || (manifestStat.mode & 0o022) !== 0) continue;
|
|
886
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
887
|
+
if (manifest.name !== "@hasna/trash" || typeof manifest.version !== "string" ||
|
|
888
|
+
typeof manifest.bin?.trash !== "string" || realpathSync(resolve(packageRoot, manifest.bin.trash)) !== executable) continue;
|
|
889
|
+
const probe = spawnSync(candidate, ["--identity"], {
|
|
890
|
+
encoding: "utf8", timeout: Math.max(1, Math.min(500, deadline - Date.now())), maxBuffer: 2_048,
|
|
891
|
+
env: { PATH: pathValue }, stdio: ["ignore", "pipe", "pipe"],
|
|
892
|
+
});
|
|
893
|
+
if (probe.error || probe.status !== 0) continue;
|
|
894
|
+
const identity = JSON.parse(probe.stdout);
|
|
895
|
+
if (identity.name === "@hasna/trash" && identity.version === manifest.version &&
|
|
896
|
+
identity.guardProtocol === "hasna.trash.guard.v1") return candidate;
|
|
897
|
+
} catch {
|
|
898
|
+
// Missing, unrelated, malformed or unresponsive packages are not targets.
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function shellQuoteWord(word: string): string {
|
|
905
|
+
if (/^[A-Za-z0-9_\-./+=:,@%^]+$/.test(word)) return word;
|
|
906
|
+
return `'${word.replace(/'/g, `'\\''`)}'`;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Replace the verb token of every owned `rm` with `<quoted trash> guard`,
|
|
911
|
+
* leaving every other byte of the command untouched.
|
|
912
|
+
*/
|
|
913
|
+
export function rewriteCommand(command: string, hits: RmHit[], trashPath: string): string {
|
|
914
|
+
const replacement = `${shellQuoteWord(trashPath)} guard`;
|
|
915
|
+
const ordered = [...hits].sort((a, b) => b.word.start - a.word.start);
|
|
916
|
+
let out = command;
|
|
917
|
+
for (const hit of ordered) {
|
|
918
|
+
out = `${out.slice(0, hit.word.start)}${replacement}${out.slice(hit.word.end)}`;
|
|
919
|
+
}
|
|
920
|
+
return out;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Re-read the rewritten command and prove it carries no command-position
|
|
925
|
+
* delete verb. A rewrite that still leaves one is not a rewrite — the caller
|
|
926
|
+
* denies instead of allowing it.
|
|
927
|
+
*/
|
|
928
|
+
export function rewriteIsComplete(original: string, rewritten: string, hits: RmHit[], cwd: string, home: string): boolean {
|
|
929
|
+
if (!rewritten || rewritten === original) return false;
|
|
930
|
+
if (hits.length === 0) return false;
|
|
931
|
+
const verify = scanCommand(rewritten, { cwd, home });
|
|
932
|
+
if (!verify.trustworthy) return false;
|
|
933
|
+
if (verify.rmHits.length > 0 || verify.handoffHits.length > 0) return false;
|
|
934
|
+
return verify.refusals.length === 0;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Build the complete tool_input for the rewrite. The harness replaces the
|
|
939
|
+
* whole input object, so every key the model supplied is preserved and the
|
|
940
|
+
* Bash tool's own fields are re-supplied at their documented defaults when
|
|
941
|
+
* absent.
|
|
942
|
+
*/
|
|
943
|
+
export function buildUpdatedInput(toolInput: Record<string, unknown> | undefined, command: string): Record<string, unknown> {
|
|
944
|
+
const out: Record<string, unknown> = {};
|
|
945
|
+
for (const [key, value] of Object.entries(toolInput ?? {})) out[key] = value;
|
|
946
|
+
|
|
947
|
+
out.command = command;
|
|
948
|
+
|
|
949
|
+
if (typeof out.description !== "string" || out.description.trim() === "") {
|
|
950
|
+
out.description = DEFAULT_DESCRIPTION;
|
|
951
|
+
}
|
|
952
|
+
if (typeof out.timeout !== "number" || !Number.isFinite(out.timeout) || out.timeout <= 0) {
|
|
953
|
+
out.timeout = DEFAULT_TIMEOUT_MS;
|
|
954
|
+
}
|
|
955
|
+
if (typeof out.run_in_background !== "boolean") {
|
|
956
|
+
out.run_in_background = false;
|
|
957
|
+
}
|
|
958
|
+
if ("dangerouslyDisableSandbox" in out && typeof out.dangerouslyDisableSandbox !== "boolean") {
|
|
959
|
+
delete out.dangerouslyDisableSandbox;
|
|
960
|
+
}
|
|
961
|
+
return out;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** A rewritten input is only allowed when it is complete and self-consistent. */
|
|
965
|
+
function updatedInputIsComplete(updated: Record<string, unknown>, original: string, hits: RmHit[], cwd: string, home: string): boolean {
|
|
966
|
+
if (typeof updated.command !== "string" || updated.command.trim() === "") return false;
|
|
967
|
+
if (typeof updated.description !== "string" || updated.description.trim() === "") return false;
|
|
968
|
+
if (typeof updated.timeout !== "number" || !Number.isFinite(updated.timeout) || updated.timeout <= 0) return false;
|
|
969
|
+
if (typeof updated.run_in_background !== "boolean") return false;
|
|
970
|
+
if ("dangerouslyDisableSandbox" in updated && typeof updated.dangerouslyDisableSandbox !== "boolean") return false;
|
|
971
|
+
return rewriteIsComplete(original, updated.command, hits, cwd, home);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/* ------------------------------------------------------------------ */
|
|
975
|
+
/* Verdict */
|
|
976
|
+
/* ------------------------------------------------------------------ */
|
|
977
|
+
|
|
978
|
+
export interface GuardDependencies {
|
|
979
|
+
home: string;
|
|
980
|
+
cwd: string;
|
|
981
|
+
findTrash: (env?: NodeJS.ProcessEnv) => string | null;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function deny(reason: string): CodewithHookOutput {
|
|
985
|
+
return {
|
|
986
|
+
hookSpecificOutput: {
|
|
987
|
+
hookEventName: "PreToolUse",
|
|
988
|
+
permissionDecision: "deny",
|
|
989
|
+
permissionDecisionReason: reason,
|
|
990
|
+
},
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* The allow path is deliberately minimal — exactly the three fields the
|
|
996
|
+
* harness consumes — so nothing else in the object can make it unparseable
|
|
997
|
+
* and drop the hook back onto the original `rm`.
|
|
998
|
+
*/
|
|
999
|
+
function allowRewrite(updatedInput: Record<string, unknown>): CodewithHookOutput {
|
|
1000
|
+
return {
|
|
1001
|
+
hookSpecificOutput: {
|
|
1002
|
+
hookEventName: "PreToolUse",
|
|
1003
|
+
permissionDecision: "allow",
|
|
1004
|
+
updatedInput,
|
|
1005
|
+
},
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Resolution is fail-closed: a lookup that throws is not "no binary found" but
|
|
1011
|
+
* a decision the guard cannot make, and it is reported as such rather than
|
|
1012
|
+
* falling through to the raw `rm`.
|
|
1013
|
+
*/
|
|
1014
|
+
function resolveTrash(deps: GuardDependencies): { path: string | null; error: string | null } {
|
|
1015
|
+
try {
|
|
1016
|
+
return { path: deps.findTrash(), error: null };
|
|
1017
|
+
} catch (cause) {
|
|
1018
|
+
return { path: null, error: cause instanceof Error ? cause.message : String(cause) };
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const ABSENT_REASON =
|
|
1023
|
+
"[trash-guard] No verified @hasna/trash guard was found on PATH. This deletion is refused. Install the current @hasna/trash package with Bun and run its setup; the operating-system trash utility is not a compatible guard.";
|
|
1024
|
+
|
|
1025
|
+
export function evaluate(input: CodewithHookInput, deps: GuardDependencies): CodewithHookOutput {
|
|
1026
|
+
if (input.hook_event_name !== "PreToolUse") return { continue: true };
|
|
1027
|
+
if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_name ?? "")) {
|
|
1028
|
+
const patch = input.tool_input?.command;
|
|
1029
|
+
if (typeof patch !== "string") return deny("[trash-guard] Unreadable patch input; deletion safety cannot be checked.");
|
|
1030
|
+
if (/^\s*\*\*\* Delete File:/m.test(patch)) {
|
|
1031
|
+
return deny("[trash-guard] Delete File would bypass recoverable deletion. First use `trash put -- <path>` or the trash_put MCP tool, then submit any remaining edits without the deletion block.");
|
|
1032
|
+
}
|
|
1033
|
+
return { continue: true };
|
|
1034
|
+
}
|
|
1035
|
+
if (input.tool_name !== "Bash") return { continue: true };
|
|
1036
|
+
|
|
1037
|
+
// A command we cannot READ is a payload we cannot verify, and the harness
|
|
1038
|
+
// falls back to the ORIGINAL tool input whenever `updatedInput` is missing or
|
|
1039
|
+
// empty -- so "cannot tell" must never resolve to "run it". Refuse instead.
|
|
1040
|
+
// A command that is PRESENT and empty runs nothing, so that stays allowed.
|
|
1041
|
+
const rawCommand = (input as { tool_input?: { command?: unknown } }).tool_input?.command;
|
|
1042
|
+
if (typeof rawCommand !== "string") {
|
|
1043
|
+
return deny(
|
|
1044
|
+
"[trash-guard] The Bash tool call carried no readable `command`, so this hook cannot verify what would run. Refusing a call it cannot verify. Re-run the delete explicitly as `rm -- <path>` so it can be redirected into trash.",
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
const command = getCommand(input);
|
|
1049
|
+
if (!command.trim()) return { continue: true };
|
|
1050
|
+
|
|
1051
|
+
const scanned = scanCommand(command, { cwd: deps.cwd, home: deps.home });
|
|
1052
|
+
|
|
1053
|
+
if (scanned.refusals.length > 0) {
|
|
1054
|
+
return deny(`[trash-guard] ${scanned.refusals[0].reason}`);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (scanned.protectedHits.length > 0) {
|
|
1058
|
+
// The catastrophic class (§15 decision 11.3): refused on the spot, never
|
|
1059
|
+
// rewritten into a trashed delete.
|
|
1060
|
+
return deny(`[trash-guard] ${scanned.protectedHits[0].reason}`);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (!scanned.trustworthy) {
|
|
1064
|
+
// The lexer could not follow this command to its end, so no rewrite of it
|
|
1065
|
+
// can be trusted. A delete-verb word means it might be a delete: refuse.
|
|
1066
|
+
return mentionsDeleteVerb(command)
|
|
1067
|
+
? deny(
|
|
1068
|
+
"[trash-guard] This command could not be parsed to the end (unterminated quote, substitution or here-document), so a delete inside it cannot be redirected. Re-run the delete as a plain `rm -- <path>` command.",
|
|
1069
|
+
)
|
|
1070
|
+
: { continue: true };
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if (scanned.rmHits.length === 0) {
|
|
1074
|
+
if (scanned.handoffHits.length > 0) {
|
|
1075
|
+
// Every delete here belongs to the guarded repo-checkout roots, which
|
|
1076
|
+
// `workspace-repos-guard` owns. Abstain and let that hook decide.
|
|
1077
|
+
return { continue: true };
|
|
1078
|
+
}
|
|
1079
|
+
return { continue: true };
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (scanned.handoffHits.length > 0) {
|
|
1083
|
+
return deny(
|
|
1084
|
+
"[trash-guard] This command mixes a delete under the protected repo-checkout roots (owned by workspace-repos-guard) with a delete outside them, and a partial rewrite would leave one of them unredirected. Re-run them as separate commands, so each delete is handled by the hook that owns it.",
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const trash = resolveTrash(deps);
|
|
1089
|
+
if (trash.error !== null) {
|
|
1090
|
+
return deny(
|
|
1091
|
+
`[trash-guard] The guard could not resolve the \`trash\` binary (${trash.error}), so this \`rm\` cannot be redirected into a recoverable delete — and an unrecoverable delete is never allowed, so the command is refused rather than run. Fix the environment (PATH, permissions) and re-run the same command.`,
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
if (trash.path === null) {
|
|
1095
|
+
return deny(ABSENT_REASON);
|
|
1096
|
+
}
|
|
1097
|
+
const trashPath = trash.path;
|
|
1098
|
+
|
|
1099
|
+
const rewritten = rewriteCommand(command, scanned.rmHits, trashPath);
|
|
1100
|
+
const updatedInput = buildUpdatedInput(input.tool_input, rewritten);
|
|
1101
|
+
|
|
1102
|
+
if (!updatedInputIsComplete(updatedInput, command, scanned.rmHits, deps.cwd, deps.home)) {
|
|
1103
|
+
// Never allow a partial rewrite: the harness falls back to the ORIGINAL
|
|
1104
|
+
// tool input when `updatedInput` is missing or empty, which runs the raw
|
|
1105
|
+
// `rm`. Refuse instead.
|
|
1106
|
+
return deny(
|
|
1107
|
+
"[trash-guard] The rewrite of this command did not verify (the delete could not be redirected cleanly), so the command is refused rather than run unredirected. Re-run the delete as a plain `rm -- <path>` command.",
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
return allowRewrite(updatedInput);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** Verdict used when the hook itself fails: refuse anything that could delete. */
|
|
1115
|
+
export function fallbackVerdict(command: string): CodewithHookOutput {
|
|
1116
|
+
return mentionsDeleteVerb(command) || /^\s*\*\*\* Delete File:/m.test(command)
|
|
1117
|
+
? deny(
|
|
1118
|
+
"[trash-guard] The hook failed while classifying this command, so it cannot be proven free of an unredirected delete. Re-run the delete as a plain `rm -- <path>` command.",
|
|
1119
|
+
)
|
|
1120
|
+
: { continue: true };
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
export async function run(): Promise<void> {
|
|
1124
|
+
const input = readInput();
|
|
1125
|
+
const command = getCommand(input);
|
|
1126
|
+
try {
|
|
1127
|
+
const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
|
|
1128
|
+
const verdict = evaluate(input, { home: homedir(), cwd, findTrash: findTrashBinary });
|
|
1129
|
+
// Native Codex rejects `continue` in PreToolUse JSON. Silence is the
|
|
1130
|
+
// documented no-op for both Codex and Claude; emit only actual decisions.
|
|
1131
|
+
if (!("continue" in verdict && verdict.continue === true)) respond(verdict);
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
warn(`${RULE} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1134
|
+
const verdict = fallbackVerdict(command);
|
|
1135
|
+
if (!("continue" in verdict && verdict.continue === true)) respond(verdict);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if (import.meta.main) {
|
|
1140
|
+
await run();
|
|
1141
|
+
process.exit(0);
|
|
1142
|
+
}
|