@hasna/hooks 0.11.3 → 0.11.5

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.
@@ -103,7 +103,7 @@ const REFUSED_VERBS: Record<string, string> = {
103
103
  * place. When one of these is in command position and the command mentions a
104
104
  * delete verb, the command is refused rather than guessed at.
105
105
  */
106
- const OPAQUE_COMMANDS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "eval", "source", ".", "su", "runuser"]);
106
+ const OPAQUE_COMMANDS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "eval", "source", ".", "su", "runuser", "env"]);
107
107
 
108
108
  /** Wrapper commands whose real command follows them (with their value-taking options). */
109
109
  const WRAPPERS: Record<string, string[]> = {
@@ -119,7 +119,7 @@ const WRAPPERS: Record<string, string[]> = {
119
119
  setsid: [],
120
120
  command: [],
121
121
  builtin: [],
122
- exec: [],
122
+ exec: ["-a"],
123
123
  };
124
124
 
125
125
  /**
@@ -156,6 +156,12 @@ export function mentionsDeleteVerb(text: string): boolean {
156
156
  return DELETE_VERB_WORD_RE.test(decodeWord(text));
157
157
  }
158
158
 
159
+ /** env's split-string value may be attached to its option. This text is used
160
+ * only to refuse an opaque delete, never as executable text or a rewrite. */
161
+ export function envSplitCommandText(words: string[]): string {
162
+ return words.map((word) => word.replace(/^-[iv0]*S|^--split-string=/, " ")).join(" ");
163
+ }
164
+
159
165
  const ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
160
166
 
161
167
  /**
@@ -238,8 +244,8 @@ export function lexCommand(command: string): LexResult {
238
244
  words.push({ text: command.slice(wordStart, end), start: wordStart, end });
239
245
  if (pendingRedirect) redirects.add(index);
240
246
  wordStart = -1;
247
+ pendingRedirect = false;
241
248
  }
242
- pendingRedirect = false;
243
249
  };
244
250
 
245
251
  const closeSegment = (end: number): void => {
@@ -247,6 +253,7 @@ export function lexCommand(command: string): LexResult {
247
253
  if (words.length > 0) segments.push({ words, redirects });
248
254
  words = [];
249
255
  redirects = new Set<number>();
256
+ pendingRedirect = false;
250
257
  };
251
258
 
252
259
  const scanSingle = (from: number): number => {
@@ -390,9 +397,13 @@ export function lexCommand(command: string): LexResult {
390
397
  continue;
391
398
  }
392
399
  if (ch === ">" || ch === "<") {
400
+ // An unquoted adjacent descriptor number belongs to the redirect, not
401
+ // the command. Its following operand is also never an executable.
402
+ if (wordStart >= 0 && /^\d+$/.test(command.slice(wordStart, i))) wordStart = -1;
393
403
  closeWord(i);
394
404
  pendingRedirect = true;
395
- i += command[i + 1] === ch && ch === ">" ? 2 : 1;
405
+ i++;
406
+ if (command[i] === ch || command[i] === "&" || (ch === ">" && command[i] === "|") || (ch === "<" && command[i] === ">")) i++;
396
407
  continue;
397
408
  }
398
409
  if (ch === "'") {
@@ -521,10 +532,11 @@ export function decodeWord(text: string): string {
521
532
  * command wrappers — NOT any word that happens to read like a verb, so
522
533
  * `printf '%s\n' rm` is not a delete.
523
534
  */
524
- function commandWordIndex(segment: Segment): number {
535
+ export function commandWordIndex(segment: Segment): number {
525
536
  let index = 0;
526
537
  let guard = 0;
527
538
  while (index < segment.words.length && guard++ < 32) {
539
+ if (segment.redirects.has(index)) { index++; continue; }
528
540
  const raw = segment.words[index].text;
529
541
  const decoded = decodeWord(raw);
530
542
  if (ASSIGNMENT_RE.test(decoded) && !decoded.startsWith("-")) {
@@ -535,20 +547,37 @@ function commandWordIndex(segment: Segment): number {
535
547
  index++;
536
548
  continue;
537
549
  }
538
- const wrapperValues = WRAPPERS[decoded];
550
+ const wrapper = baseVerb(decoded);
551
+ // command -v/-V looks up every following name without executing one.
552
+ if (wrapper === "command") {
553
+ for (const word of segment.words.filter((_, at) => at > index && !segment.redirects.has(at))) {
554
+ const option = decodeWord(word.text);
555
+ if (option === "--" || !option.startsWith("-")) break;
556
+ if (/^-[pvV]*[vV][pvV]*$/.test(option)) return -1;
557
+ }
558
+ }
559
+ const wrapperValues = WRAPPERS[wrapper];
539
560
  if (wrapperValues) {
561
+ const wrapperIndex = index;
540
562
  index++;
541
563
  while (index < segment.words.length) {
564
+ if (segment.redirects.has(index)) { index++; continue; }
542
565
  const option = decodeWord(segment.words[index].text);
543
566
  if (!option.startsWith("-") || option === "-") break;
544
567
  if (option === "--") {
545
568
  index++;
546
569
  break;
547
570
  }
571
+ // env -S reparses its value as a command string. Retain env in
572
+ // command position so the opaque-command refusal owns this form.
573
+ if (wrapper === "env" && (/^-[iv0]*S/.test(option) || /^--split-string(?:=|$)/.test(option))) return wrapperIndex;
548
574
  index++;
549
- if (wrapperValues.includes(option)) index++;
575
+ if (wrapperValues.includes(option) || (wrapper === "exec" && /^-[cl]*a$/.test(option))) {
576
+ while (segment.redirects.has(index)) index++;
577
+ index++;
578
+ }
550
579
  }
551
- let positionals = WRAPPER_POSITIONALS[decoded] ?? 0;
580
+ let positionals = WRAPPER_POSITIONALS[wrapper] ?? 0;
552
581
  while (positionals > 0 && index < segment.words.length) {
553
582
  const positional = decodeWord(segment.words[index].text);
554
583
  if (positional.startsWith("-") && positional !== "-") break;
@@ -814,7 +843,8 @@ export function scanCommand(command: string, options: { cwd: string; home: strin
814
843
  continue;
815
844
  }
816
845
 
817
- if (OPAQUE_COMMANDS.has(verb) && mentionsDeleteVerb(segmentText(segment))) {
846
+ const opaqueText = verb === "env" ? envSplitCommandText(segment.words.map((word) => decodeWord(word.text))) : segmentText(segment);
847
+ if (OPAQUE_COMMANDS.has(verb) && mentionsDeleteVerb(opaqueText)) {
818
848
  result.refusals.push({
819
849
  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.`,
820
850
  });
@@ -849,7 +879,7 @@ function segmentText(segment: Segment): string {
849
879
  }
850
880
 
851
881
  /** The git subcommand, skipping git's own value-taking global options. */
852
- function gitSubcommand(segment: Segment, verbIndex: number): string | null {
882
+ export function gitSubcommand(segment: Segment, verbIndex: number): string | null {
853
883
  let index = verbIndex + 1;
854
884
  while (index < segment.words.length) {
855
885
  const option = decodeWord(segment.words[index].text);
@@ -52,6 +52,7 @@
52
52
  import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, statSync } from "fs";
53
53
  import { homedir } from "os";
54
54
  import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path";
55
+ import { commandWordIndex, decodeWord, envSplitCommandText, gitSubcommand, lexCommand, mentionsDeleteVerb } from "../../hook-trash-guard/src/hook";
55
56
  import {
56
57
  getCommand,
57
58
  readInput,
@@ -364,6 +365,51 @@ function shellWords(command: string): ShellWord[] {
364
365
  function segmentOperation(segment: string): Operation {
365
366
  const trimmed = segment.trim();
366
367
  if (!trimmed) return "read";
368
+ const parsed = lexCommand(trimmed);
369
+ // Preserve conservative classification where the shell body is opaque.
370
+ if (!parsed.trustworthy || parsed.substitutions.length || /\$\{[^}]*[|;()]|[<>]\(/.test(trimmed)) return opaqueOperation(trimmed);
371
+ const operations = parsed.segments.map((part): Operation => {
372
+ const index = commandWordIndex(part);
373
+ if (index < 0) return "read";
374
+ const words = part.words.filter((_, at) => at >= index && !part.redirects.has(at)).map((word) => decodeWord(word.text));
375
+ const tool = words[0].split("/").pop();
376
+ if (["rm", "rmdir", "unlink", "shred", "rmtree", "del"].includes(tool ?? "")) return "delete";
377
+ if (tool === "trash") {
378
+ // Hasna metadata/setup verbs take identifiers, not paths to remove.
379
+ // Keep legacy positional Trash clients and capture verbs guarded.
380
+ let at = 1;
381
+ while (at < words.length && words[at].startsWith("-")) {
382
+ const flag = words[at++];
383
+ if (["--help", "-h", "--version", "-V", "--identity"].includes(flag)) return "read";
384
+ if (["--agent", "--spool", "--files", "--info", "--config"].includes(flag)) at++;
385
+ else if (!["--json", "--local"].includes(flag)) return "delete";
386
+ }
387
+ if (at === words.length || ["status", "doctor", "list", "info", "get", "pending", "station", "setup", "backup", "storage", "retrieval", "backup-storage", "backup-inspect", "backup-refresh", "help"].includes(words[at])) return "read";
388
+ if (["restore", "restore-capsule"].includes(words[at])) return "write";
389
+ return "delete";
390
+ }
391
+ if (tool === "git") {
392
+ const sub = gitSubcommand(part, index);
393
+ if (sub === "clean" || sub === "rm") return "delete";
394
+ return sub === "clone" || sub === "init" ? "write" : "read";
395
+ }
396
+ if (tool === "env" && mentionsDeleteVerb(envSplitCommandText(words))) return "delete";
397
+ if (["sh", "bash", "zsh", "dash", "ksh", "ash", "eval", "source", ".", "su", "runuser", "env", "xargs", "find", "busybox", "toybox"].includes(tool ?? "")) {
398
+ return opaqueOperation(words.join(" "));
399
+ }
400
+ if (["mkdir", "mkfile", "touch", "mv", "cp", "ln", "tee", "install", "dd", "rsync", "scp", "truncate"].includes(tool ?? "")) return "write";
401
+ if (tool === "curl" || tool === "wget") return WRITE_FLAGS.test(words.join(" ")) ? "write" : "read";
402
+ if (tool === "sed" && words.slice(1).some((word) => /^-i|^--in-place(?:=|$)/.test(word))) return "write";
403
+ if (["python", "python3", "node", "bun"].includes(tool ?? "") && words.some((word) => word === "-c" || word === "-e")) return "write";
404
+ return "read";
405
+ });
406
+ return operations.includes("delete") ? "delete" : operations.includes("write") ? "write" : "read";
407
+ }
408
+
409
+ // Retain the prior conservative behavior for embedded shell bodies.
410
+ function opaqueOperation(segment: string): Operation {
411
+ const trimmed = segment.trim();
412
+ if (!trimmed) return "read";
367
413
 
368
414
  if (/(?:^|\s)\\?(?:[^\s;&|<>()]*\/)?(?:rm|rmdir|unlink|shred|trash|rmtree|del)(?:\s|$)/.test(trimmed)) return "delete";
369
415
  if (/\bgit\b/.test(trimmed)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {