@hasna/hooks 0.9.5 → 0.9.6

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.
@@ -0,0 +1,1136 @@
1
+ // @bun
2
+ // hooks/native-safety-entry.ts
3
+ import { homedir as homedir2 } from "os";
4
+ import { isAbsolute as isAbsolute3 } from "path";
5
+
6
+ // hooks/hook-trash-guard/src/hook.ts
7
+ import { dirname, isAbsolute, join, normalize, resolve, sep } from "path";
8
+ import { readFileSync, realpathSync, statSync } from "fs";
9
+ import { spawnSync } from "child_process";
10
+
11
+ // hooks/codewith-native-common.ts
12
+ function getCommand(input) {
13
+ const command = input.tool_input?.command;
14
+ return typeof command === "string" ? command : "";
15
+ }
16
+ var segmentCache = new Map;
17
+ var SYSTEM_PROTECTED_ROOTS = [
18
+ "/",
19
+ "/bin",
20
+ "/boot",
21
+ "/dev",
22
+ "/etc",
23
+ "/home",
24
+ "/lib",
25
+ "/lib32",
26
+ "/lib64",
27
+ "/libx32",
28
+ "/opt",
29
+ "/proc",
30
+ "/root",
31
+ "/run",
32
+ "/sbin",
33
+ "/srv",
34
+ "/sys",
35
+ "/usr",
36
+ "/var",
37
+ "/Applications",
38
+ "/Library",
39
+ "/System",
40
+ "/Users",
41
+ "/Volumes",
42
+ "/private"
43
+ ];
44
+ var OPAQUE_BUILTINS = new Set(["eval", "source", ".", "trap", "coproc", "exec"]);
45
+ var COMPOUND_KEYWORDS = new Set(["{", "}", "then", "do", "else", "elif", "fi", "done", "!"]);
46
+ var NAME_BINDING_BUILTINS = new Set(["read", "getopts", "mapfile", "readarray"]);
47
+ var VALUE_BINDING_BUILTINS = new Set(["export", "declare", "typeset", "readonly", "local", "let"]);
48
+ var RSYNC_OPTIONS_WITH_VALUE = new Set([
49
+ "-e",
50
+ "--rsh",
51
+ "--exclude",
52
+ "--exclude-from",
53
+ "--include",
54
+ "--include-from",
55
+ "--filter",
56
+ "--files-from",
57
+ "--rsync-path",
58
+ "--out-format",
59
+ "--log-file",
60
+ "--password-file",
61
+ "--backup-dir",
62
+ "--partial-dir",
63
+ "--compare-dest",
64
+ "--copy-dest",
65
+ "--link-dest"
66
+ ]);
67
+ var SHELL_INTERPRETERS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "mksh", "busybox"]);
68
+ var USER_SWITCH_COMMANDS = new Set(["su", "runuser"]);
69
+ var SSH_OPTIONS_WITH_VALUE = new Set([
70
+ "-B",
71
+ "-b",
72
+ "-c",
73
+ "-D",
74
+ "-E",
75
+ "-e",
76
+ "-F",
77
+ "-I",
78
+ "-i",
79
+ "-J",
80
+ "-L",
81
+ "-l",
82
+ "-m",
83
+ "-O",
84
+ "-o",
85
+ "-P",
86
+ "-p",
87
+ "-Q",
88
+ "-R",
89
+ "-S",
90
+ "-W",
91
+ "-w"
92
+ ]);
93
+ var SHELL_OPTIONS_WITH_VALUE = new Set(["-o", "+o", "--rcfile", "--init-file"]);
94
+
95
+ // hooks/hook-trash-guard/src/hook.ts
96
+ var DEFAULT_TIMEOUT_MS = 600000;
97
+ var DEFAULT_DESCRIPTION = "Delete via trash guard (rm intercepted and made recoverable)";
98
+ var HANDOFF_ROOTS = [".hasna/repos/clones", "workspace/repos"];
99
+ var PROTECTED_HOME_TREES = [".hasna", ".ssh", ".aws"];
100
+ var REFUSED_VERBS = {
101
+ rmdir: "`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.",
102
+ unlink: "`unlink` is a delete that trash-guard does not rewrite. Re-run it as `rm -- <path>` and it is redirected into trash.",
103
+ shred: "`shred` destroys content irrecoverably and is never allowed. Re-run it as `rm -- <path>` and it is redirected into trash."
104
+ };
105
+ var OPAQUE_COMMANDS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "eval", "source", ".", "su", "runuser"]);
106
+ var WRAPPERS = {
107
+ sudo: ["-u", "--user", "-g", "--group", "-p", "--prompt", "-C", "--chdir", "-h", "--host", "-r", "--role", "-t", "--type", "-U", "--other-user"],
108
+ doas: ["-u", "-C"],
109
+ env: ["-u", "--unset", "-C", "--chdir", "-S", "--split-string"],
110
+ nice: ["-n", "--adjustment"],
111
+ ionice: ["-c", "--class", "-n", "--classdata", "-p", "--pid"],
112
+ stdbuf: ["-i", "-o", "-e", "--input", "--output", "--error"],
113
+ time: ["-f", "--format", "-o", "--output"],
114
+ timeout: ["-s", "--signal", "-k", "--kill-after"],
115
+ nohup: [],
116
+ setsid: [],
117
+ command: [],
118
+ builtin: [],
119
+ exec: []
120
+ };
121
+ var WRAPPER_POSITIONALS = {
122
+ timeout: 1
123
+ };
124
+ var APPLET_DISPATCHERS = new Set(["busybox", "toybox"]);
125
+ var KEYWORDS = new Set(["!", "{", "}", "then", "do", "else", "elif", "if", "while", "until", "for", "case", "esac", "fi", "done", "select", "function"]);
126
+ var GIT_VALUE_OPTIONS = new Set(["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env"]);
127
+ var DELETE_VERB_WORD_RE = /(^|[^A-Za-z0-9_.-])(rm|rmdir|unlink|shred)(?![A-Za-z0-9_.-])/;
128
+ function mentionsDeleteVerb(text) {
129
+ return DELETE_VERB_WORD_RE.test(decodeWord(text));
130
+ }
131
+ var ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
132
+ function baseVerb(word) {
133
+ const slash = word.lastIndexOf("/");
134
+ return slash >= 0 ? word.slice(slash + 1) : word;
135
+ }
136
+ function lexCommand(command) {
137
+ const segments = [];
138
+ const substitutions = [];
139
+ let words = [];
140
+ let redirects = new Set;
141
+ let wordStart = -1;
142
+ let pendingRedirect = false;
143
+ let trustworthy = true;
144
+ let i = 0;
145
+ const n = command.length;
146
+ const closeWord = (end) => {
147
+ if (wordStart >= 0) {
148
+ const index = words.length;
149
+ words.push({ text: command.slice(wordStart, end), start: wordStart, end });
150
+ if (pendingRedirect)
151
+ redirects.add(index);
152
+ wordStart = -1;
153
+ }
154
+ pendingRedirect = false;
155
+ };
156
+ const closeSegment = (end) => {
157
+ closeWord(end);
158
+ if (words.length > 0)
159
+ segments.push({ words, redirects });
160
+ words = [];
161
+ redirects = new Set;
162
+ };
163
+ const scanSingle = (from) => {
164
+ const end = command.indexOf("'", from + 1);
165
+ return end === -1 ? -1 : end + 1;
166
+ };
167
+ const scanBacktick = (from) => {
168
+ let j = from + 1;
169
+ while (j < n) {
170
+ if (command[j] === "\\") {
171
+ j += 2;
172
+ continue;
173
+ }
174
+ if (command[j] === "`")
175
+ return j + 1;
176
+ j++;
177
+ }
178
+ return -1;
179
+ };
180
+ const scanParen = (from) => {
181
+ let depth = 0;
182
+ let j = from;
183
+ while (j < n) {
184
+ const c = command[j];
185
+ if (c === "\\") {
186
+ j += 2;
187
+ continue;
188
+ }
189
+ if (c === "'") {
190
+ const e = scanSingle(j);
191
+ if (e < 0)
192
+ return -1;
193
+ j = e;
194
+ continue;
195
+ }
196
+ if (c === '"') {
197
+ const e = scanDouble(j);
198
+ if (e < 0)
199
+ return -1;
200
+ j = e;
201
+ continue;
202
+ }
203
+ if (c === "`") {
204
+ const e = scanBacktick(j);
205
+ if (e < 0)
206
+ return -1;
207
+ j = e;
208
+ continue;
209
+ }
210
+ if (c === "(")
211
+ depth++;
212
+ else if (c === ")") {
213
+ depth--;
214
+ if (depth === 0)
215
+ return j + 1;
216
+ }
217
+ j++;
218
+ }
219
+ return -1;
220
+ };
221
+ function scanDouble(from) {
222
+ let j = from + 1;
223
+ while (j < n) {
224
+ const c = command[j];
225
+ if (c === "\\") {
226
+ j += 2;
227
+ continue;
228
+ }
229
+ if (c === '"')
230
+ return j + 1;
231
+ if (c === "`") {
232
+ const e = scanBacktick(j);
233
+ if (e < 0)
234
+ return -1;
235
+ j = e;
236
+ continue;
237
+ }
238
+ if (c === "$" && command[j + 1] === "(") {
239
+ const e = scanParen(j + 1);
240
+ if (e < 0)
241
+ return -1;
242
+ j = e;
243
+ continue;
244
+ }
245
+ j++;
246
+ }
247
+ return -1;
248
+ }
249
+ while (i < n) {
250
+ const ch = command[i];
251
+ if (ch === " " || ch === "\t" || ch === "\r") {
252
+ closeWord(i);
253
+ i++;
254
+ continue;
255
+ }
256
+ if (ch === `
257
+ `) {
258
+ closeSegment(i);
259
+ i++;
260
+ continue;
261
+ }
262
+ if (ch === ";") {
263
+ closeSegment(i);
264
+ i++;
265
+ continue;
266
+ }
267
+ if (ch === "&") {
268
+ closeSegment(i);
269
+ i += command[i + 1] === "&" ? 2 : 1;
270
+ continue;
271
+ }
272
+ if (ch === "|") {
273
+ closeSegment(i);
274
+ i += command[i + 1] === "|" ? 2 : 1;
275
+ continue;
276
+ }
277
+ if (ch === "(" || ch === ")") {
278
+ closeSegment(i);
279
+ i++;
280
+ continue;
281
+ }
282
+ if (ch === "<" && command[i + 1] === "<" && command[i + 2] !== "<") {
283
+ const marker = command.slice(i + 2).match(/^-?[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/);
284
+ if (!marker) {
285
+ trustworthy = false;
286
+ i = n;
287
+ continue;
288
+ }
289
+ const bodyStart = command.indexOf(`
290
+ `, i + 2 + marker[0].length);
291
+ if (bodyStart === -1) {
292
+ trustworthy = false;
293
+ i = n;
294
+ continue;
295
+ }
296
+ const rest = command.slice(bodyStart + 1);
297
+ const endIdx = findHeredocEnd(rest, marker[2]);
298
+ if (endIdx === -1) {
299
+ trustworthy = false;
300
+ i = n;
301
+ continue;
302
+ }
303
+ closeWord(i);
304
+ i = bodyStart + 1 + endIdx + marker[2].length;
305
+ continue;
306
+ }
307
+ if (ch === ">" || ch === "<") {
308
+ closeWord(i);
309
+ pendingRedirect = true;
310
+ i += command[i + 1] === ch && ch === ">" ? 2 : 1;
311
+ continue;
312
+ }
313
+ if (ch === "'") {
314
+ if (wordStart < 0)
315
+ wordStart = i;
316
+ const e = scanSingle(i);
317
+ if (e < 0) {
318
+ trustworthy = false;
319
+ i = n;
320
+ } else
321
+ i = e;
322
+ continue;
323
+ }
324
+ if (ch === '"') {
325
+ if (wordStart < 0)
326
+ wordStart = i;
327
+ const e = scanDouble(i);
328
+ if (e < 0) {
329
+ trustworthy = false;
330
+ i = n;
331
+ } else
332
+ i = e;
333
+ continue;
334
+ }
335
+ if (ch === "`") {
336
+ if (wordStart < 0)
337
+ wordStart = i;
338
+ const e = scanBacktick(i);
339
+ if (e < 0) {
340
+ trustworthy = false;
341
+ i = n;
342
+ } else {
343
+ substitutions.push(command.slice(i + 1, e - 1));
344
+ i = e;
345
+ }
346
+ continue;
347
+ }
348
+ if (ch === "$" && command[i + 1] === "(") {
349
+ if (wordStart < 0)
350
+ wordStart = i;
351
+ const e = scanParen(i + 1);
352
+ if (e < 0) {
353
+ trustworthy = false;
354
+ i = n;
355
+ } else {
356
+ substitutions.push(command.slice(i + 2, e - 1));
357
+ i = e;
358
+ }
359
+ continue;
360
+ }
361
+ if (ch === "\\") {
362
+ if (wordStart < 0)
363
+ wordStart = i;
364
+ if (i + 1 >= n) {
365
+ trustworthy = false;
366
+ i = n;
367
+ } else
368
+ i += 2;
369
+ continue;
370
+ }
371
+ if (wordStart < 0)
372
+ wordStart = i;
373
+ i++;
374
+ }
375
+ closeSegment(n);
376
+ return { segments, trustworthy, substitutions };
377
+ }
378
+ function findHeredocEnd(rest, delimiter) {
379
+ let from = 0;
380
+ while (from <= rest.length) {
381
+ const at = rest.indexOf(delimiter, from);
382
+ if (at === -1)
383
+ return -1;
384
+ const lineStart = at === 0 || rest[at - 1] === `
385
+ `;
386
+ const after = at + delimiter.length;
387
+ const lineEnd = after === rest.length || rest[after] === `
388
+ ` || rest[after] === "\r";
389
+ if (lineStart && lineEnd)
390
+ return at;
391
+ from = at + 1;
392
+ }
393
+ return -1;
394
+ }
395
+ function decodeWord(text) {
396
+ let out = "";
397
+ let i = 0;
398
+ while (i < text.length) {
399
+ const ch = text[i];
400
+ if (ch === "\\") {
401
+ if (i + 1 < text.length)
402
+ out += text[i + 1];
403
+ i += 2;
404
+ continue;
405
+ }
406
+ if (ch === "'") {
407
+ const end = text.indexOf("'", i + 1);
408
+ if (end === -1) {
409
+ out += text.slice(i + 1);
410
+ break;
411
+ }
412
+ out += text.slice(i + 1, end);
413
+ i = end + 1;
414
+ continue;
415
+ }
416
+ if (ch === '"') {
417
+ i++;
418
+ while (i < text.length && text[i] !== '"') {
419
+ if (text[i] === "\\" && i + 1 < text.length) {
420
+ out += text[i + 1];
421
+ i += 2;
422
+ continue;
423
+ }
424
+ out += text[i];
425
+ i++;
426
+ }
427
+ i++;
428
+ continue;
429
+ }
430
+ out += ch;
431
+ i++;
432
+ }
433
+ return out;
434
+ }
435
+ function commandWordIndex(segment) {
436
+ let index = 0;
437
+ let guard = 0;
438
+ while (index < segment.words.length && guard++ < 32) {
439
+ const raw = segment.words[index].text;
440
+ const decoded = decodeWord(raw);
441
+ if (ASSIGNMENT_RE.test(decoded) && !decoded.startsWith("-")) {
442
+ index++;
443
+ continue;
444
+ }
445
+ if (KEYWORDS.has(decoded)) {
446
+ index++;
447
+ continue;
448
+ }
449
+ const wrapperValues = WRAPPERS[decoded];
450
+ if (wrapperValues) {
451
+ index++;
452
+ while (index < segment.words.length) {
453
+ const option = decodeWord(segment.words[index].text);
454
+ if (!option.startsWith("-") || option === "-")
455
+ break;
456
+ if (option === "--") {
457
+ index++;
458
+ break;
459
+ }
460
+ index++;
461
+ if (wrapperValues.includes(option))
462
+ index++;
463
+ }
464
+ let positionals = WRAPPER_POSITIONALS[decoded] ?? 0;
465
+ while (positionals > 0 && index < segment.words.length) {
466
+ const positional = decodeWord(segment.words[index].text);
467
+ if (positional.startsWith("-") && positional !== "-")
468
+ break;
469
+ index++;
470
+ positionals--;
471
+ }
472
+ continue;
473
+ }
474
+ return index;
475
+ }
476
+ return -1;
477
+ }
478
+ function rmOperands(segment, verbIndex) {
479
+ const operands = [];
480
+ let seenDoubleDash = false;
481
+ for (let index = verbIndex + 1;index < segment.words.length; index++) {
482
+ if (segment.redirects.has(index))
483
+ continue;
484
+ const decoded = decodeWord(segment.words[index].text);
485
+ if (!seenDoubleDash) {
486
+ if (decoded === "--") {
487
+ seenDoubleDash = true;
488
+ continue;
489
+ }
490
+ if (decoded.startsWith("-") && decoded !== "-")
491
+ continue;
492
+ }
493
+ operands.push(segment.words[index]);
494
+ }
495
+ return operands;
496
+ }
497
+ function handoffRoots(home) {
498
+ return HANDOFF_ROOTS.map((suffix) => join(home, ...suffix.split("/")));
499
+ }
500
+ function expandHomeSpelling(token, home) {
501
+ if (token === "~" || token === "$HOME" || token === "${HOME}")
502
+ return home;
503
+ if (token.startsWith("~/"))
504
+ return join(home, token.slice(2));
505
+ if (token.startsWith("$HOME/"))
506
+ return join(home, token.slice("$HOME/".length));
507
+ if (token.startsWith("${HOME}/"))
508
+ return join(home, token.slice("${HOME}/".length));
509
+ return token;
510
+ }
511
+ function resolveLiteralTarget(raw, cwd, home) {
512
+ let decoded = decodeWord(raw);
513
+ if (!decoded)
514
+ return null;
515
+ if (decoded.includes("`"))
516
+ return null;
517
+ if (decoded === "$HOME" || decoded === "${HOME}" || decoded === "$HOME/" || decoded === "${HOME}/")
518
+ return home;
519
+ const withoutHome = decoded.replace(/\$\{HOME\}/g, "").replace(/\$HOME/g, "");
520
+ if (withoutHome.includes("$"))
521
+ return null;
522
+ if (/[*?[\]]/.test(decoded))
523
+ return null;
524
+ if (decoded.startsWith("~")) {
525
+ const expanded = expandHomeSpelling(decoded, home);
526
+ if (expanded === decoded)
527
+ return null;
528
+ decoded = expanded;
529
+ }
530
+ const absolute = isAbsolute(decoded) ? normalize(decoded) : resolve(cwd, decoded);
531
+ const trimmed = absolute.replace(/\/+$/, "");
532
+ return trimmed === "" ? "/" : trimmed;
533
+ }
534
+ function isUnder(target, root) {
535
+ return target === root || target.startsWith(`${root}${sep}`);
536
+ }
537
+ function protectedTargetReason(target, home) {
538
+ if (target === home) {
539
+ return `\`${target}\` is the home directory itself, which is never deleted or trashed. Delete a specific file or directory inside it instead.`;
540
+ }
541
+ for (const root of SYSTEM_PROTECTED_ROOTS) {
542
+ if (target !== root && !isUnder(root, target))
543
+ continue;
544
+ return root === "/" ? `\`${target}\` is the filesystem root, which is never deleted.` : `\`${target}\` is the system root \`${root}\` (or an ancestor of it), which is never deleted or trashed.`;
545
+ }
546
+ for (const suffix of PROTECTED_HOME_TREES) {
547
+ const root = join(home, suffix);
548
+ if (!isUnder(target, root) && !isUnder(root, target))
549
+ continue;
550
+ 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.`;
551
+ }
552
+ return null;
553
+ }
554
+ function isHandoff(operands, cwd, home) {
555
+ const roots = handoffRoots(home);
556
+ if (roots.some((root) => isUnder(cwd, root)))
557
+ return true;
558
+ for (const operand of operands) {
559
+ const target = resolveLiteralTarget(operand.text, cwd, home);
560
+ if (target && roots.some((root) => isUnder(target, root)))
561
+ return true;
562
+ }
563
+ return false;
564
+ }
565
+ function scanCommand(command, options) {
566
+ const ctx = { cwd: options.cwd, home: options.home, depth: options.depth ?? 0 };
567
+ const result = { rmHits: [], handoffHits: [], refusals: [], protectedHits: [], trustworthy: true };
568
+ const lexed = lexCommand(command);
569
+ result.trustworthy = lexed.trustworthy;
570
+ if (!lexed.trustworthy) {
571
+ return result;
572
+ }
573
+ let cwd = ctx.cwd;
574
+ for (const segment of lexed.segments) {
575
+ const index = commandWordIndex(segment);
576
+ if (index < 0)
577
+ continue;
578
+ const verb = baseVerb(decodeWord(segment.words[index].text));
579
+ if (verb === "cd") {
580
+ const operand = segment.words[index + 1];
581
+ if (operand) {
582
+ const target = resolveLiteralTarget(operand.text, cwd, ctx.home);
583
+ if (target)
584
+ cwd = target;
585
+ else
586
+ cwd = ctx.cwd;
587
+ }
588
+ continue;
589
+ }
590
+ const refusal = REFUSED_VERBS[verb];
591
+ if (refusal) {
592
+ result.refusals.push({ reason: refusal });
593
+ continue;
594
+ }
595
+ if (verb === "rm") {
596
+ const operands = rmOperands(segment, index);
597
+ if (operands.length === 0)
598
+ continue;
599
+ const hit = { word: segment.words[index], operands };
600
+ if (isHandoff(hit.operands, cwd, ctx.home)) {
601
+ result.handoffHits.push(hit);
602
+ continue;
603
+ }
604
+ result.rmHits.push(hit);
605
+ for (const operand of hit.operands) {
606
+ const target = resolveLiteralTarget(operand.text, cwd, ctx.home);
607
+ if (!target)
608
+ continue;
609
+ const reason = protectedTargetReason(target, ctx.home);
610
+ if (reason) {
611
+ result.protectedHits.push({ reason });
612
+ break;
613
+ }
614
+ }
615
+ continue;
616
+ }
617
+ if (APPLET_DISPATCHERS.has(verb)) {
618
+ const appletIndex = segment.words.findIndex((word, at) => at > index && !decodeWord(word.text).startsWith("-"));
619
+ const applet = appletIndex >= 0 ? baseVerb(decodeWord(segment.words[appletIndex].text)) : "";
620
+ if (applet === "rm" || applet in REFUSED_VERBS) {
621
+ result.refusals.push({
622
+ 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.`
623
+ });
624
+ }
625
+ continue;
626
+ }
627
+ if (verb === "git") {
628
+ const sub = gitSubcommand(segment, index);
629
+ if (sub === "rm" || sub === "clean") {
630
+ const dryOrIndexOnly = sub === "rm" ? gitRmIsNonDeleting(segment, index) : gitHasDryRun(segment, index);
631
+ if (!dryOrIndexOnly) {
632
+ result.refusals.push({
633
+ reason: sub === "rm" ? "`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." : "`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`."
634
+ });
635
+ }
636
+ }
637
+ continue;
638
+ }
639
+ if (verb === "find") {
640
+ const words = segment.words.map((word) => decodeWord(word.text));
641
+ const deleteIndex = words.indexOf("-delete");
642
+ if (deleteIndex >= 0) {
643
+ result.refusals.push({
644
+ reason: "`find -delete` deletes files irrecoverably and is refused. List the paths first (`find \u2026 -print`), then remove them explicitly with `rm -- <path>` so the delete is redirected into trash."
645
+ });
646
+ continue;
647
+ }
648
+ const execIndex = words.findIndex((word) => word === "-exec" || word === "-execdir" || word === "-ok");
649
+ if (execIndex >= 0 && words.slice(execIndex).some((word) => word === "rm" || word === "rmdir" || word === "unlink" || word === "shred")) {
650
+ result.refusals.push({
651
+ 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>`."
652
+ });
653
+ continue;
654
+ }
655
+ continue;
656
+ }
657
+ if (verb === "xargs" && segment.words.some((word) => decodeWord(word.text) === "rm")) {
658
+ result.refusals.push({
659
+ 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> \u2026` instead, which is redirected into trash."
660
+ });
661
+ continue;
662
+ }
663
+ if (OPAQUE_COMMANDS.has(verb) && mentionsDeleteVerb(segmentText(segment))) {
664
+ result.refusals.push({
665
+ 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.`
666
+ });
667
+ continue;
668
+ }
669
+ }
670
+ if (ctx.depth < 3) {
671
+ for (const body of lexed.substitutions) {
672
+ const inner = scanCommand(body, { cwd, home: ctx.home, depth: ctx.depth + 1 });
673
+ if (inner.rmHits.length > 0 || inner.refusals.length > 0 || inner.handoffHits.length > 0 || inner.protectedHits.length > 0) {
674
+ result.refusals.push({
675
+ reason: "This command runs a delete inside a command substitution `$(\u2026)`, which this hook cannot rewrite. Run the delete as a separate `rm -- <path>` command, which is redirected into trash."
676
+ });
677
+ break;
678
+ }
679
+ }
680
+ } else if (lexed.substitutions.some((body) => mentionsDeleteVerb(body))) {
681
+ result.refusals.push({
682
+ reason: "This command nests a delete inside command substitutions deeper than this hook will follow. Run the delete as a separate `rm -- <path>` command."
683
+ });
684
+ }
685
+ return result;
686
+ }
687
+ function segmentText(segment) {
688
+ return segment.words.map((word) => word.text).join(" ");
689
+ }
690
+ function gitSubcommand(segment, verbIndex) {
691
+ let index = verbIndex + 1;
692
+ while (index < segment.words.length) {
693
+ const option = decodeWord(segment.words[index].text);
694
+ if (!option.startsWith("-"))
695
+ return option;
696
+ index++;
697
+ if (GIT_VALUE_OPTIONS.has(option))
698
+ index++;
699
+ }
700
+ return null;
701
+ }
702
+ function gitRmIsNonDeleting(segment, verbIndex) {
703
+ const options = segment.words.slice(verbIndex + 1).map((word) => decodeWord(word.text));
704
+ if (options.includes("--cached"))
705
+ return true;
706
+ return gitHasDryRun(segment, verbIndex);
707
+ }
708
+ function gitHasDryRun(segment, verbIndex) {
709
+ const options = segment.words.slice(verbIndex + 1).map((word) => decodeWord(word.text));
710
+ return options.includes("-n") || options.includes("--dry-run");
711
+ }
712
+ function findTrashBinary(env = process.env) {
713
+ const pathValue = env.PATH ?? "";
714
+ const deadline = Date.now() + 2000;
715
+ for (const dir of pathValue.split(":").slice(0, 64)) {
716
+ if (!isAbsolute(dir) || Date.now() >= deadline)
717
+ continue;
718
+ const candidate = resolve(dir, "trash");
719
+ try {
720
+ const stat = statSync(candidate);
721
+ if (!stat.isFile() || (stat.mode & 73) === 0 || (stat.mode & 18) !== 0)
722
+ continue;
723
+ const executable = realpathSync(candidate);
724
+ const packageRoot = resolve(dirname(executable), "../..");
725
+ const manifestPath = join(packageRoot, "package.json");
726
+ const manifestStat = statSync(manifestPath);
727
+ if (!manifestStat.isFile() || manifestStat.size > 16384 || (manifestStat.mode & 18) !== 0)
728
+ continue;
729
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
730
+ if (manifest.name !== "@hasna/trash" || typeof manifest.version !== "string" || typeof manifest.bin?.trash !== "string" || realpathSync(resolve(packageRoot, manifest.bin.trash)) !== executable)
731
+ continue;
732
+ const probe = spawnSync(candidate, ["--identity"], {
733
+ encoding: "utf8",
734
+ timeout: Math.max(1, Math.min(500, deadline - Date.now())),
735
+ maxBuffer: 2048,
736
+ env: { PATH: pathValue },
737
+ stdio: ["ignore", "pipe", "pipe"]
738
+ });
739
+ if (probe.error || probe.status !== 0)
740
+ continue;
741
+ const identity = JSON.parse(probe.stdout);
742
+ if (identity.name === "@hasna/trash" && identity.version === manifest.version && identity.guardProtocol === "hasna.trash.guard.v1")
743
+ return candidate;
744
+ } catch {}
745
+ }
746
+ return null;
747
+ }
748
+ function shellQuoteWord(word) {
749
+ if (/^[A-Za-z0-9_\-./+=:,@%^]+$/.test(word))
750
+ return word;
751
+ return `'${word.replace(/'/g, `'\\''`)}'`;
752
+ }
753
+ function rewriteCommand(command, hits, trashPath) {
754
+ const replacement = `${shellQuoteWord(trashPath)} guard`;
755
+ const ordered = [...hits].sort((a, b) => b.word.start - a.word.start);
756
+ let out = command;
757
+ for (const hit of ordered) {
758
+ out = `${out.slice(0, hit.word.start)}${replacement}${out.slice(hit.word.end)}`;
759
+ }
760
+ return out;
761
+ }
762
+ function rewriteIsComplete(original, rewritten, hits, cwd, home) {
763
+ if (!rewritten || rewritten === original)
764
+ return false;
765
+ if (hits.length === 0)
766
+ return false;
767
+ const verify = scanCommand(rewritten, { cwd, home });
768
+ if (!verify.trustworthy)
769
+ return false;
770
+ if (verify.rmHits.length > 0 || verify.handoffHits.length > 0)
771
+ return false;
772
+ return verify.refusals.length === 0;
773
+ }
774
+ function buildUpdatedInput(toolInput, command) {
775
+ const out = {};
776
+ for (const [key, value] of Object.entries(toolInput ?? {}))
777
+ out[key] = value;
778
+ out.command = command;
779
+ if (typeof out.description !== "string" || out.description.trim() === "") {
780
+ out.description = DEFAULT_DESCRIPTION;
781
+ }
782
+ if (typeof out.timeout !== "number" || !Number.isFinite(out.timeout) || out.timeout <= 0) {
783
+ out.timeout = DEFAULT_TIMEOUT_MS;
784
+ }
785
+ if (typeof out.run_in_background !== "boolean") {
786
+ out.run_in_background = false;
787
+ }
788
+ if ("dangerouslyDisableSandbox" in out && typeof out.dangerouslyDisableSandbox !== "boolean") {
789
+ delete out.dangerouslyDisableSandbox;
790
+ }
791
+ return out;
792
+ }
793
+ function updatedInputIsComplete(updated, original, hits, cwd, home) {
794
+ if (typeof updated.command !== "string" || updated.command.trim() === "")
795
+ return false;
796
+ if (typeof updated.description !== "string" || updated.description.trim() === "")
797
+ return false;
798
+ if (typeof updated.timeout !== "number" || !Number.isFinite(updated.timeout) || updated.timeout <= 0)
799
+ return false;
800
+ if (typeof updated.run_in_background !== "boolean")
801
+ return false;
802
+ if ("dangerouslyDisableSandbox" in updated && typeof updated.dangerouslyDisableSandbox !== "boolean")
803
+ return false;
804
+ return rewriteIsComplete(original, updated.command, hits, cwd, home);
805
+ }
806
+ function deny(reason) {
807
+ return {
808
+ hookSpecificOutput: {
809
+ hookEventName: "PreToolUse",
810
+ permissionDecision: "deny",
811
+ permissionDecisionReason: reason
812
+ }
813
+ };
814
+ }
815
+ function allowRewrite(updatedInput) {
816
+ return {
817
+ hookSpecificOutput: {
818
+ hookEventName: "PreToolUse",
819
+ permissionDecision: "allow",
820
+ updatedInput
821
+ }
822
+ };
823
+ }
824
+ function resolveTrash(deps) {
825
+ try {
826
+ return { path: deps.findTrash(), error: null };
827
+ } catch (cause) {
828
+ return { path: null, error: cause instanceof Error ? cause.message : String(cause) };
829
+ }
830
+ }
831
+ var ABSENT_REASON = "[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.";
832
+ function evaluate(input, deps) {
833
+ if (input.hook_event_name !== "PreToolUse")
834
+ return { continue: true };
835
+ if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_name ?? "")) {
836
+ const patch = input.tool_input?.command;
837
+ if (typeof patch !== "string")
838
+ return deny("[trash-guard] Unreadable patch input; deletion safety cannot be checked.");
839
+ if (/^\s*\*\*\* Delete File:/m.test(patch)) {
840
+ 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.");
841
+ }
842
+ return { continue: true };
843
+ }
844
+ if (input.tool_name !== "Bash")
845
+ return { continue: true };
846
+ const rawCommand = input.tool_input?.command;
847
+ if (typeof rawCommand !== "string") {
848
+ return deny("[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.");
849
+ }
850
+ const command = getCommand(input);
851
+ if (!command.trim())
852
+ return { continue: true };
853
+ const scanned = scanCommand(command, { cwd: deps.cwd, home: deps.home });
854
+ if (scanned.refusals.length > 0) {
855
+ return deny(`[trash-guard] ${scanned.refusals[0].reason}`);
856
+ }
857
+ if (scanned.protectedHits.length > 0) {
858
+ return deny(`[trash-guard] ${scanned.protectedHits[0].reason}`);
859
+ }
860
+ if (!scanned.trustworthy) {
861
+ return mentionsDeleteVerb(command) ? deny("[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.") : { continue: true };
862
+ }
863
+ if (scanned.rmHits.length === 0) {
864
+ if (scanned.handoffHits.length > 0) {
865
+ return { continue: true };
866
+ }
867
+ return { continue: true };
868
+ }
869
+ if (scanned.handoffHits.length > 0) {
870
+ return deny("[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.");
871
+ }
872
+ const trash = resolveTrash(deps);
873
+ if (trash.error !== null) {
874
+ return deny(`[trash-guard] The guard could not resolve the \`trash\` binary (${trash.error}), so this \`rm\` cannot be redirected into a recoverable delete \u2014 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.`);
875
+ }
876
+ if (trash.path === null) {
877
+ return deny(ABSENT_REASON);
878
+ }
879
+ const trashPath = trash.path;
880
+ const rewritten = rewriteCommand(command, scanned.rmHits, trashPath);
881
+ const updatedInput = buildUpdatedInput(input.tool_input, rewritten);
882
+ if (!updatedInputIsComplete(updatedInput, command, scanned.rmHits, deps.cwd, deps.home)) {
883
+ return deny("[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.");
884
+ }
885
+ return allowRewrite(updatedInput);
886
+ }
887
+ if (false) {}
888
+
889
+ // hooks/hook-workspace-repos-guard/src/hook.ts
890
+ import { homedir } from "os";
891
+ import { isAbsolute as isAbsolute2, join as join2, normalize as normalize2, relative, resolve as resolve2, sep as sep2 } from "path";
892
+ var FILE_WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
893
+ var PATCH_TOOLS = new Set(["apply_patch", "ApplyPatch", "functions.apply_patch"]);
894
+ var DEFAULT_ORGS = ["hasna", "hasnaxyz", "hasna-products"];
895
+ var RULE = "workspace-repos-guard";
896
+ function reposRoot(home) {
897
+ return join2(home, ".hasna", "repos", "clones");
898
+ }
899
+ function legacyRoot(home) {
900
+ return join2(home, "workspace", "repos");
901
+ }
902
+ function protectedRoots(home) {
903
+ return [reposRoot(home), legacyRoot(home)];
904
+ }
905
+ function resolveAllowedOrgs(env = process.env) {
906
+ const raw = env.WORKSPACE_REPOS_GUARD_ORGS;
907
+ if (typeof raw !== "string" || !raw.trim())
908
+ return new Set(DEFAULT_ORGS);
909
+ return new Set(raw.split(",").map((s) => s.trim()).filter(Boolean));
910
+ }
911
+ function classifyPath(target, root, orgs, op) {
912
+ if (op === "read")
913
+ return { blocked: false };
914
+ let rel = relative(root, target);
915
+ if (rel === "") {
916
+ if (op === "delete")
917
+ return { blocked: true, reason: `[${RULE}] delete of the protected repo-checkout root is forbidden: ${root}` };
918
+ return { blocked: true, reason: `[${RULE}] writes directly to the protected repo-checkout root are forbidden: ${root}` };
919
+ }
920
+ if (rel.startsWith(`..${sep2}`) || rel === ".." || isAbsolute2(rel)) {
921
+ return { blocked: false };
922
+ }
923
+ const segments = rel.split(sep2).filter(Boolean);
924
+ if (op === "delete") {
925
+ return { blocked: true, reason: `[${RULE}] delete under the protected repo-checkout root (${root}) is forbidden at any depth: ${target}` };
926
+ }
927
+ if (segments.length <= 1) {
928
+ return {
929
+ blocked: true,
930
+ reason: `[${RULE}] writing would create a top-level entry directly under the protected repo-checkout root (${root}); only GitHub-org folders belong there: ${target}`
931
+ };
932
+ }
933
+ const org = segments[0];
934
+ if (!orgs.has(org)) {
935
+ return {
936
+ blocked: true,
937
+ reason: `[${RULE}] '${org}' is not an allowed GitHub org under the protected repo-checkout root (${root}); allowed: ${[...orgs].join(", ")}: ${target}`
938
+ };
939
+ }
940
+ return { blocked: false };
941
+ }
942
+ var WRITE_FLAGS = /\b(?:-o|--output|-O|--output-document|-out)\b/;
943
+ function segmentOperation(segment) {
944
+ const trimmed = segment.trim();
945
+ if (!trimmed)
946
+ return "read";
947
+ if (/(?:^|\s)(?:rm|rmdir|unlink|shred|trash|rmtree|del)(?:\s|$)/.test(trimmed))
948
+ return "delete";
949
+ if (/\bgit\b/.test(trimmed)) {
950
+ if (/\bgit\b[^;&|]*\b(?:clean|rm)\b/.test(trimmed))
951
+ return "delete";
952
+ if (/\bgit\b[^;&|]*\b(?:clone|init)\b/.test(trimmed))
953
+ return "write";
954
+ return "read";
955
+ }
956
+ if (/(?:^|\s)(?:mkdir|mkfile|touch|mv|cp|ln|tee|install|dd)(?:\s|$)/.test(trimmed))
957
+ return "write";
958
+ if (/\b(?:curl|wget)\b/.test(trimmed)) {
959
+ if (WRITE_FLAGS.test(trimmed))
960
+ return "write";
961
+ return "read";
962
+ }
963
+ if (/\bsed\b/.test(trimmed)) {
964
+ if (/(?:^|\s)-i\S*(?:\s|$)|--in-place(?:\s|$)/.test(trimmed))
965
+ return "write";
966
+ } else if (/\b(?:rsync|scp|truncate)\b/.test(trimmed)) {
967
+ return "write";
968
+ } else if (/(?:^|\s)(?:python3?|node|bun)\b[^;&|]*\s+-[ce](?:\s|$)/.test(trimmed)) {
969
+ return "write";
970
+ }
971
+ if (/(?:^|[;&|])\s*echo\b/.test(trimmed) || /[>&]/.test(trimmed))
972
+ return "write";
973
+ return "read";
974
+ }
975
+ function expandHomeSpelling2(token, home) {
976
+ let body = token.trim();
977
+ const quoted = body.match(/^(['"])([\s\S]*)\1$/);
978
+ if (quoted)
979
+ body = quoted[2];
980
+ body = body.replace(/^"*\$HOME"*\//, "$HOME/");
981
+ body = body.replace(/^"*\$\{HOME\}"*\//, "${HOME}/");
982
+ if (body === "~")
983
+ return home;
984
+ if (body.startsWith("~/"))
985
+ return join2(home, body.slice(2));
986
+ if (body === "$HOME" || body === "${HOME}")
987
+ return home;
988
+ if (body.startsWith("$HOME/"))
989
+ return join2(home, body.slice("$HOME/".length));
990
+ if (body.startsWith("${HOME}/"))
991
+ return join2(home, body.slice("${HOME}/".length));
992
+ const homeMatch = body.match(new RegExp(`^"*${regexEscape(home)}"*/`));
993
+ if (homeMatch)
994
+ return home + "/" + body.slice(homeMatch[0].length);
995
+ return token;
996
+ }
997
+ function unwrapSegment(segment) {
998
+ let s = segment.trim();
999
+ while (s.startsWith("(") || s.startsWith("{"))
1000
+ s = s.slice(1).trim();
1001
+ while (s.endsWith(")") || s.endsWith("}"))
1002
+ s = s.slice(0, -1).trim();
1003
+ return s;
1004
+ }
1005
+ function regexEscape(text) {
1006
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1007
+ }
1008
+ function bashTargets(command, home, cwd) {
1009
+ if (!command)
1010
+ return [];
1011
+ const roots = protectedRoots(home);
1012
+ const segments = command.split(/\s*&&\s*|\s*\|\|\s*|;\s*|\n+/);
1013
+ const targets = [];
1014
+ let cwdUnderRepos = null;
1015
+ for (const rawSegment of segments) {
1016
+ const segment = unwrapSegment(rawSegment);
1017
+ const op = segmentOperation(segment);
1018
+ const cdMatch = segment.match(/(?:^|\s)cd(?:\s+(?:-[A-Za-z]+|--))*\s+([^\s;&|<>()\x60]+)/);
1019
+ if (cdMatch) {
1020
+ const cdTarget = expandHomeSpelling2(cdMatch[1], home);
1021
+ if (cdTarget && roots.some((root) => cdTarget === root || cdTarget.startsWith(`${root}${sep2}`))) {
1022
+ cwdUnderRepos = cdTarget;
1023
+ }
1024
+ }
1025
+ const homeLiteral = regexEscape(home);
1026
+ const suffix = roots.map((root) => regexEscape(root.slice(home.length + 1))).join("|");
1027
+ const prefixRe = new RegExp(`(?:~|\\$HOME"*|\\$\\{HOME\\}"*|${homeLiteral}"*)/(?:${suffix})`);
1028
+ const re = new RegExp(`(${prefixRe.source})([^\\s"';&|<>()\`]*|$)`, "g");
1029
+ let m;
1030
+ let foundExplicit = false;
1031
+ while ((m = re.exec(segment)) !== null) {
1032
+ foundExplicit = true;
1033
+ const expanded = expandHomeSpelling2(m[0], home);
1034
+ targets.push({ path: normalize2(expanded).replace(/\/+$/, ""), op });
1035
+ }
1036
+ if (!foundExplicit && (op === "delete" || op === "write")) {
1037
+ const base = cwdUnderRepos ?? (roots.some((root) => cwd === root || cwd.startsWith(`${root}${sep2}`)) ? cwd : null);
1038
+ if (base) {
1039
+ const relMatch = segment.match(/(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*)?)\s*$/);
1040
+ if (relMatch) {
1041
+ targets.push({ path: normalize2(resolve2(base, relMatch[1])), op });
1042
+ }
1043
+ }
1044
+ }
1045
+ }
1046
+ return targets;
1047
+ }
1048
+ var PATCH_FILE_RE = /^\s*\*\*\*\s+(Add File|Update File|Delete File):\s+(\S+)\s*$/gm;
1049
+ function patchTargets(patch, cwd) {
1050
+ const targets = [];
1051
+ let m;
1052
+ while ((m = PATCH_FILE_RE.exec(patch)) !== null) {
1053
+ const op = m[1] === "Delete File" ? "delete" : "write";
1054
+ targets.push({ path: normalize2(resolve2(cwd, m[2])), op });
1055
+ }
1056
+ return targets;
1057
+ }
1058
+ function evaluate2(input) {
1059
+ const warnings = [];
1060
+ if (input.hook_event_name !== "PreToolUse")
1061
+ return { output: { continue: true }, warnings };
1062
+ const tool = typeof input.tool_name === "string" ? input.tool_name : "";
1063
+ const home = homedir();
1064
+ const roots = protectedRoots(home);
1065
+ const orgs = resolveAllowedOrgs();
1066
+ if (tool === "Bash") {
1067
+ const command = getCommand(input);
1068
+ if (!command)
1069
+ return { output: { continue: true }, warnings };
1070
+ const cwd2 = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
1071
+ const targets = bashTargets(command, home, cwd2);
1072
+ for (const target2 of targets) {
1073
+ for (const root of roots) {
1074
+ const verdict = classifyPath(target2.path, root, orgs, target2.op);
1075
+ if (verdict.blocked) {
1076
+ return { output: { decision: "block", reason: verdict.reason }, warnings };
1077
+ }
1078
+ }
1079
+ }
1080
+ return { output: { continue: true }, warnings };
1081
+ }
1082
+ const toolInput = input.tool_input ?? {};
1083
+ const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
1084
+ if (PATCH_TOOLS.has(tool)) {
1085
+ const patch = typeof toolInput.patch === "string" ? toolInput.patch : "";
1086
+ if (!patch)
1087
+ return { output: { continue: true }, warnings };
1088
+ const targets = patchTargets(patch, cwd);
1089
+ for (const target2 of targets) {
1090
+ for (const root of roots) {
1091
+ const verdict = classifyPath(target2.path, root, orgs, target2.op);
1092
+ if (verdict.blocked) {
1093
+ return { output: { decision: "block", reason: verdict.reason }, warnings };
1094
+ }
1095
+ }
1096
+ }
1097
+ return { output: { continue: true }, warnings };
1098
+ }
1099
+ if (!FILE_WRITE_TOOLS.has(tool))
1100
+ return { output: { continue: true }, warnings };
1101
+ const raw = typeof toolInput.file_path === "string" ? toolInput.file_path : typeof toolInput.path === "string" ? toolInput.path : typeof toolInput.notebook_path === "string" ? toolInput.notebook_path : null;
1102
+ if (!raw)
1103
+ return { output: { continue: true }, warnings };
1104
+ const expanded = expandHomeSpelling2(raw, home);
1105
+ const target = isAbsolute2(expanded) ? normalize2(expanded) : resolve2(cwd, expanded);
1106
+ for (const root of roots) {
1107
+ const verdict = classifyPath(target, root, orgs, "write");
1108
+ if (verdict.blocked) {
1109
+ return { output: { decision: "block", reason: verdict.reason }, warnings };
1110
+ }
1111
+ }
1112
+ return { output: { continue: true }, warnings };
1113
+ }
1114
+ if (false) {}
1115
+
1116
+ // hooks/native-safety-entry.ts
1117
+ var name = process.argv[1];
1118
+ if (name !== "trash-guard" && name !== "workspace-repos-guard")
1119
+ throw new Error("Invalid safety capability");
1120
+ var input = JSON.parse(await Bun.stdin.text());
1121
+ if (!input || Array.isArray(input) || input.hook_event_name !== "PreToolUse" || typeof input.tool_name !== "string" || !input.tool_input || typeof input.tool_input !== "object" || Array.isArray(input.tool_input) || typeof input.cwd !== "string" || !isAbsolute3(input.cwd))
1122
+ throw new Error("Invalid safety input");
1123
+ if (input.tool_name === "Bash" && typeof input.tool_input.command !== "string")
1124
+ throw new Error("Invalid shell input");
1125
+ if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_name)) {
1126
+ if (input.tool_input.command !== undefined && input.tool_input.patch !== undefined && input.tool_input.command !== input.tool_input.patch)
1127
+ throw new Error("Conflicting patch aliases");
1128
+ const patch = input.tool_input.command ?? input.tool_input.patch;
1129
+ if (typeof patch !== "string")
1130
+ throw new Error("Invalid patch input");
1131
+ input.tool_input = { ...input.tool_input, command: patch, patch };
1132
+ }
1133
+ var repos = evaluate2(input).output;
1134
+ var handoff = input.tool_name === "Bash" && scanCommand(input.tool_input.command, { home: homedir2(), cwd: input.cwd }).handoffHits.length > 0;
1135
+ var verdict = repos.decision === "block" ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: repos.reason } } : handoff ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "[workspace-repos-guard] Deletion under a protected repository checkout is refused." } } : name === "trash-guard" ? evaluate(input, { home: homedir2(), cwd: input.cwd, findTrash: findTrashBinary }) : { continue: true };
1136
+ process.stdout.write(JSON.stringify({ verdict: "continue" in verdict ? null : verdict }));