@hasna/hooks 0.12.2 → 0.12.3

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.
@@ -61,6 +61,26 @@ other than `$HOME`, `$( )`, backticks, a glob) fails closed to the directory
61
61
  the command runs in, so `rm -rf "$TMPDIR/x"` run from a protected checkout is
62
62
  refused. Use a literal absolute path for deletes outside the roots.
63
63
 
64
+ A here-document body is data, not commands, when nothing in the command can
65
+ run it: its delimiter is quoted (or it holds no `$( )` or backticks), and
66
+ every command word of the command, including those in command substitutions
67
+ at any depth and inside double quotes, is a command that never runs its input
68
+ or arguments as code (`cat`, `tee`,
69
+ `head`, `grep`, `jq`, `echo`, `cd`, `mkdir`, `cp`, `mv`, `rm`, `trash`, the
70
+ Hasna record CLIs such as `todos` and `conversations`, `git` with a data
71
+ subcommand such as `commit` and no `-c`, and `gh pr|issue|api|release|gist`).
72
+ No command word may be a path, an expansion, or the bare name of a file the
73
+ command writes, and the command may hold no variable assignment, process
74
+ substitution, or shell, interpreter or runner word (`bash`, `python3`,
75
+ `ssh`, `sudo`, `xargs`, `chmod`, ...). Any other command word (`rbash`,
76
+ `newgrp`, `tclsh8.6`, an unknown tool) keeps the line-by-line analysis,
77
+ because a list of runners can never be complete. A `<<` inside `${...}` or
78
+ `$[...]` is not taken for a here-document. Such a body is analysed on
79
+ its own, away from the session cwd: a protected path it names, or reaches
80
+ with its own `cd`, still counts, but a word in it (`rm`, `>`, a trailing name)
81
+ is never resolved against the cwd, and its `cd` never moves the outer shell.
82
+ Every other body keeps the line-by-line analysis.
83
+
64
84
  ## Configuration
65
85
 
66
86
  Allowed orgs default to `hasna,hasnaxyz,hasna-products`. Private workspace
@@ -36,7 +36,9 @@
36
36
  * off, so a later read-only stage never supplies an earlier stage's target.
37
37
  * apply_patch tools are inspected through their `*** Add File:` /
38
38
  * `*** Update File:` / `*** Delete File:` markers. Parenthesized command
39
- * groups are unwrapped.
39
+ * groups are unwrapped. A here-document body is data unless something in the
40
+ * command can run it (see dataHeredocs): its words are then never resolved
41
+ * against the cwd, and only protected paths it names explicitly count.
40
42
  *
41
43
  * Allowed orgs default to hasna,hasnaxyz,hasna-products and are overridable
42
44
  * with the WORKSPACE_REPOS_GUARD_ORGS env var (comma-separated) or, where
@@ -52,7 +54,16 @@
52
54
  import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, statSync } from "fs";
53
55
  import { homedir } from "os";
54
56
  import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "path";
55
- import { commandWordIndex, decodeWord, envSplitCommandText, gitSubcommand, lexCommand, mentionsDeleteVerb } from "../../hook-trash-guard/src/hook";
57
+ import {
58
+ commandWordIndex,
59
+ decodeWord,
60
+ envSplitCommandText,
61
+ gitSubcommand,
62
+ heredocMarkerAt,
63
+ lexCommand,
64
+ mentionsDeleteVerb,
65
+ type HeredocMarker,
66
+ } from "../../hook-trash-guard/src/hook";
56
67
  import {
57
68
  getCommand,
58
69
  readInput,
@@ -352,6 +363,20 @@ function shellWords(command: string): ShellWord[] {
352
363
  return words;
353
364
  }
354
365
 
366
+ /**
367
+ * Whether the word at `at`, the first one after a delete verb or subcommand,
368
+ * asks for help or a version: such a call prints and exits, acting on
369
+ * nothing. Only that first position counts. Any earlier option may take the
370
+ * request as its value (`git clean -f -e --help` excludes the pattern
371
+ * `--help` and still deletes), so a request after another option stays a
372
+ * delete. `-h`/`-V` count only where `short` (Trash and git subcommands,
373
+ * whose `-h` is help).
374
+ */
375
+ function asksForHelp(words: string[], at: number, short: boolean): boolean {
376
+ const word = words[at];
377
+ return word === "--help" || word === "--version" || (short && (word === "-h" || word === "-V"));
378
+ }
379
+
355
380
  /**
356
381
  * Classify the operation of one command segment (a `&&`/`||`/`;`-delimited
357
382
  * unit). Git is handled by its subcommand: clean|rm delete, clone|init write,
@@ -373,7 +398,10 @@ function segmentOperation(segment: string): Operation {
373
398
  if (index < 0) return "read";
374
399
  const words = part.words.filter((_, at) => at >= index && !part.redirects.has(at)).map((word) => decodeWord(word.text));
375
400
  const tool = words[0].split("/").pop();
376
- if (["rm", "rmdir", "unlink", "shred", "rmtree", "del"].includes(tool ?? "")) return "delete";
401
+ if (["rm", "rmdir", "shred"].includes(tool ?? "")) return asksForHelp(words, 1, false) ? "read" : "delete";
402
+ // POSIX/BSD unlink takes no options, so `unlink --help` removes a file
403
+ // named `--help`; rmtree and del are not modelled. Always deletes.
404
+ if (["unlink", "rmtree", "del"].includes(tool ?? "")) return "delete";
377
405
  if (tool === "trash") {
378
406
  // Hasna metadata/setup verbs take identifiers, not paths to remove.
379
407
  // Keep legacy positional Trash clients and capture verbs guarded.
@@ -386,11 +414,14 @@ function segmentOperation(segment: string): Operation {
386
414
  }
387
415
  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
416
  if (["restore", "restore-capsule"].includes(words[at])) return "write";
417
+ if (["put", "rm", "delete", "remove"].includes(words[at]) && asksForHelp(words, at + 1, true)) return "read";
389
418
  return "delete";
390
419
  }
391
420
  if (tool === "git") {
392
421
  const sub = gitSubcommand(part, index);
393
- if (sub === "clean" || sub === "rm") return "delete";
422
+ // Honoured only for `git rm|clean <request>`: a global option before the
423
+ // subcommand is not modelled here, so it keeps the delete.
424
+ if (sub === "clean" || sub === "rm") return words[1] === sub && asksForHelp(words, 2, true) ? "read" : "delete";
394
425
  return sub === "clone" || sub === "init" ? "write" : "read";
395
426
  }
396
427
  if (tool === "env" && mentionsDeleteVerb(envSplitCommandText(words))) return "delete";
@@ -499,6 +530,384 @@ function cdDestinations(operand: string | undefined, bases: string[], home: stri
499
530
 
500
531
  const REL_OPERAND = /(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*)?)\s*$/;
501
532
 
533
+ interface HeredocSpan {
534
+ /** Line of the `<<` operator, and the operator's columns on it. */
535
+ line: number;
536
+ from: number;
537
+ to: number;
538
+ /** First body line, and the terminator line just past the last. */
539
+ start: number;
540
+ end: number;
541
+ quoted: boolean;
542
+ }
543
+
544
+ /**
545
+ * Here-document bodies in a command, located line by line. Bash reads a body
546
+ * from the line after the one holding its `<<` operator, whatever quoting or
547
+ * `$( )` that operator sits in, so each line is scanned with a stack of open
548
+ * contexts (a `"..."` string, a `$( )` with its unclosed `(` count) carried
549
+ * across lines. Returns null when the scan cannot be followed to the end: a
550
+ * `'...'`, backtick or arithmetic `(( ))` spanning lines, an operator with no
551
+ * word, a body with no exact terminator line (bash's `DELIM)` form included),
552
+ * or a quote or substitution still open at the end.
553
+ */
554
+ export function heredocSpans(command: string): HeredocSpan[] | null {
555
+ const lines = command.split("\n");
556
+ const spans: HeredocSpan[] = [];
557
+ const stack: Array<'"' | number> = [];
558
+ let n = 0;
559
+ while (n < lines.length) {
560
+ const lineIndex = n;
561
+ const line = lines[n++].replace(/\r$/, "");
562
+ const pending: Array<{ marker: HeredocMarker; from: number }> = [];
563
+ for (let i = 0; i < line.length; i++) {
564
+ const ch = line[i];
565
+ const top = stack[stack.length - 1];
566
+ if (ch === "\\") {
567
+ i++;
568
+ continue;
569
+ }
570
+ if (ch === "$" && line[i + 1] === "(" && line[i + 2] === "(") {
571
+ const close = line.indexOf("))", i + 3);
572
+ if (close < 0) return null;
573
+ i = close + 1;
574
+ continue;
575
+ }
576
+ if (ch === "$" && line[i + 1] === "(") {
577
+ stack.push(0);
578
+ i++;
579
+ continue;
580
+ }
581
+ // `$[ ]` is arithmetic, so a `<<` in it is a shift, not an operator.
582
+ if (ch === "$" && line[i + 1] === "[") return null;
583
+ // Inside `${...}` a `<<` is not an operator either (`${x:-<<EOF}` starts
584
+ // no body), so a parameter expansion is skipped only when it holds no
585
+ // quote, substitution, nested expansion or `<<`; otherwise the scan
586
+ // gives up and the command keeps the line-by-line analysis.
587
+ if (ch === "$" && line[i + 1] === "{") {
588
+ const close = line.indexOf("}", i + 2);
589
+ if (close < 0 || /['"\x60$]|<</.test(line.slice(i + 2, close))) return null;
590
+ i = close;
591
+ continue;
592
+ }
593
+ if (ch === "\x60") {
594
+ const close = line.indexOf("\x60", i + 1);
595
+ if (close < 0) return null;
596
+ i = close;
597
+ continue;
598
+ }
599
+ if (top === '"') {
600
+ if (ch === '"') stack.pop();
601
+ continue;
602
+ }
603
+ // Command text, at the top level or inside `$( )`.
604
+ if (ch === "'") {
605
+ const close = line.indexOf("'", i + 1);
606
+ if (close < 0) return null;
607
+ i = close;
608
+ continue;
609
+ }
610
+ if (ch === '"') {
611
+ stack.push('"');
612
+ continue;
613
+ }
614
+ if (ch === "#" && (i === 0 || /[\s;&|()]/.test(line[i - 1]))) break;
615
+ if (ch === "(" && line[i + 1] === "(" && (i === 0 || /[\s;&|(]/.test(line[i - 1]))) {
616
+ const close = line.indexOf("))", i + 2);
617
+ if (close < 0) return null;
618
+ i = close + 1;
619
+ continue;
620
+ }
621
+ if (typeof top === "number" && (ch === "(" || ch === ")")) {
622
+ if (ch === "(") stack[stack.length - 1] = top + 1;
623
+ else if (top === 0) stack.pop();
624
+ else stack[stack.length - 1] = top - 1;
625
+ continue;
626
+ }
627
+ if (ch === "<" && line[i + 1] === "<") {
628
+ if (line[i + 2] === "<") {
629
+ i += 2;
630
+ continue;
631
+ }
632
+ const marker = heredocMarkerAt(line, i);
633
+ if (!marker) return null;
634
+ pending.push({ marker, from: i });
635
+ i = marker.end - 1;
636
+ }
637
+ }
638
+ for (const { marker, from } of pending) {
639
+ const start = n;
640
+ for (;;) {
641
+ if (n >= lines.length) return null;
642
+ let text = lines[n++].replace(/\r$/, "");
643
+ if (marker.stripTabs) text = text.replace(/^\t+/, "");
644
+ if (text === marker.delimiter) break;
645
+ }
646
+ spans.push({ line: lineIndex, from, to: marker.end, start, end: n - 1, quoted: marker.quoted });
647
+ }
648
+ }
649
+ return stack.length === 0 ? spans : null;
650
+ }
651
+
652
+ /**
653
+ * Words that can run a here-document body as code, or run something that
654
+ * may: shells, interpreters, remote, privileged and deferred runners, and
655
+ * tools with a shell escape. Matched as any word of the command, because
656
+ * wrappers (`sudo -u x bash`, `timeout 9 sh`, `xargs rm`) put the runner after
657
+ * the command word.
658
+ */
659
+ const BODY_RUNNERS = new Set([
660
+ "sh", "bash", "zsh", "dash", "ksh", "mksh", "ash", "fish", "csh", "tcsh", "nu", "xonsh", "elvish", "busybox", "toybox",
661
+ "ssh", "su", "runuser", "sudo", "doas", "pkexec", "xargs", "parallel", "expect", "script", "at", "batch", "crontab",
662
+ "watch", "flock", "chroot", "nsenter", "unshare", "docker", "podman", "kubectl", "lxc",
663
+ "python", "pypy", "node", "nodejs", "bun", "deno", "tsx", "ts-node", "zx", "npx", "bunx", "pnpx", "perl", "ruby", "irb",
664
+ "php", "lua", "luajit", "osascript", "pwsh", "powershell", "Rscript", "R", "julia", "tclsh", "wish",
665
+ "awk", "gawk", "mawk", "nawk", "sed", "make", "gmake", "gdb", "lldb", "sqlite3", "psql", "mysql", "sftp", "ftp", "lftp",
666
+ "vim", "vi", "nvim", "ex", "ed", "emacs",
667
+ // Making a file executable is how a written body becomes a runnable script.
668
+ "chmod",
669
+ ]);
670
+
671
+ /** Builtins that run a file or string as shell code, or rename a command, in command position. */
672
+ const BODY_RUNNER_COMMANDS = new Set([".", "source", "eval", "env", "alias", "hash"]);
673
+
674
+ /** Commands whose operands name files they write. */
675
+ const FILE_WRITERS = new Set(["tee", "cp", "mv", "install", "ln"]);
676
+
677
+ /**
678
+ * The only command words a command holding a data body may use: commands that
679
+ * read, write, move or delete files but never run their input or arguments as
680
+ * code, plus the Hasna record CLIs a body is usually filed through. The rest
681
+ * of the command is still analysed, so a delete here still counts. A list of
682
+ * runners can never be complete (`rbash`, `newgrp`, `sg`, `tclsh8.6`, any
683
+ * installed interpreter), so any other command word keeps the line-by-line
684
+ * analysis. `git` and `gh` are checked by subcommand (dataCommand).
685
+ */
686
+ const DATA_COMMANDS = new Set([
687
+ "cat", "tac", "tee", "head", "tail", "wc", "cut", "tr", "nl", "rev", "fold", "fmt", "paste", "uniq",
688
+ "grep", "egrep", "fgrep", "jq", "diff", "cmp", "comm", "iconv", "od", "xxd", "hexdump",
689
+ "base64", "base32", "md5sum", "sha1sum", "sha224sum", "sha256sum", "sha384sum", "sha512sum", "b2sum", "cksum",
690
+ "echo", "printf", "true", "false", ":", "test", "[", "read", "cd", "pwd", "ls", "mkdir", "touch", "stat", "sleep",
691
+ "cp", "mv", "ln", "rm", "rmdir", "unlink", "shred", "trash",
692
+ "todos", "conversations", "mementos", "knowledge", "files", "asks",
693
+ ]);
694
+
695
+ /** git subcommands that take a body only as a message, patch or blob. */
696
+ const GIT_DATA_SUBCOMMANDS = new Set([
697
+ "add", "commit", "tag", "notes", "status", "diff", "log", "show", "rev-parse", "branch", "switch", "push", "fetch",
698
+ "hash-object", "apply",
699
+ ]);
700
+
701
+ /** gh commands that take a body only as text or a request payload. */
702
+ const GH_DATA_COMMANDS = new Set(["pr", "issue", "api", "release", "gist"]);
703
+
704
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/;
705
+
706
+ /**
707
+ * Whether the command word at `index` of `words` is a data command. git is
708
+ * accepted only with `-C <dir>`, `--no-pager` or `-P` before a data
709
+ * subcommand (`-c` can define an alias that runs a shell on stdin), and gh
710
+ * only for its built-in commands (an extension or shell alias can run
711
+ * anything).
712
+ */
713
+ function dataCommand(words: string[], index: number): boolean {
714
+ const name = words[index];
715
+ if (name === "gh") return GH_DATA_COMMANDS.has(words[index + 1] ?? "");
716
+ if (name !== "git") return DATA_COMMANDS.has(name);
717
+ for (let at = index + 1; at < words.length; at++) {
718
+ const word = words[at];
719
+ if (word === "-C") at++;
720
+ else if (word === "--no-pager" || word === "-P") continue;
721
+ else return !word.startsWith("-") && GIT_DATA_SUBCOMMANDS.has(word);
722
+ }
723
+ return false;
724
+ }
725
+
726
+ const baseName = (word: string): string => word.split("/").pop() ?? "";
727
+
728
+ /**
729
+ * Index of the `"` closing the one at `open`, past escapes and the
730
+ * substitutions inside it; -1 when there is none.
731
+ */
732
+ function closingDoubleQuote(text: string, open: number): number {
733
+ for (let i = open + 1; i < text.length; i++) {
734
+ const ch = text[i];
735
+ if (ch === "\\") i++;
736
+ else if (ch === '"') return i;
737
+ else if (ch === "$" && text[i + 1] === "(") {
738
+ i = closingParen(text, i + 1);
739
+ if (i < 0) return -1;
740
+ } else if (ch === "\x60") {
741
+ i = text.indexOf("\x60", i + 1);
742
+ if (i < 0) return -1;
743
+ }
744
+ }
745
+ return -1;
746
+ }
747
+
748
+ /**
749
+ * Index of the `)` closing the `(` at `open`, skipping quoted text and
750
+ * backtick substitutions; -1 when there is none.
751
+ */
752
+ function closingParen(text: string, open: number): number {
753
+ let depth = 0;
754
+ for (let i = open; i < text.length; i++) {
755
+ const ch = text[i];
756
+ if (ch === "\\") i++;
757
+ else if (ch === "'" || ch === "\x60") {
758
+ i = text.indexOf(ch, i + 1);
759
+ if (i < 0) return -1;
760
+ } else if (ch === '"') {
761
+ i = closingDoubleQuote(text, i);
762
+ if (i < 0) return -1;
763
+ } else if (ch === "(") depth++;
764
+ else if (ch === ")" && --depth === 0) return i;
765
+ }
766
+ return -1;
767
+ }
768
+
769
+ /**
770
+ * The text of every `$( )` and backtick command substitution in `text` at
771
+ * this level, inside double quotes or not (single quotes run nothing). Their
772
+ * own substitutions are found when each is scanned in turn. Null when the
773
+ * text cannot be followed, or an arithmetic `$(( ))` holds a substitution.
774
+ */
775
+ function commandSubstitutions(text: string): string[] | null {
776
+ const found: string[] = [];
777
+ let inDouble = false;
778
+ for (let i = 0; i < text.length; i++) {
779
+ const ch = text[i];
780
+ if (ch === "\\") i++;
781
+ else if (ch === "'" && !inDouble) {
782
+ const close = text.indexOf("'", i + 1);
783
+ if (close < 0) return null;
784
+ i = close;
785
+ } else if (ch === '"') inDouble = !inDouble;
786
+ else if (ch === "$" && text[i + 1] === "(") {
787
+ const close = closingParen(text, i + 1);
788
+ if (close < 0) return null;
789
+ const inner = text.slice(i + 2, close);
790
+ if (text[i + 2] === "(") {
791
+ if (/\$\(|\x60/.test(inner)) return null;
792
+ } else found.push(inner);
793
+ i = close;
794
+ } else if (ch === "\x60") {
795
+ const close = text.indexOf("\x60", i + 1);
796
+ if (close < 0) return null;
797
+ found.push(text.slice(i + 1, close));
798
+ i = close;
799
+ }
800
+ }
801
+ return inDouble ? null : found;
802
+ }
803
+
804
+ /**
805
+ * Whether anything in `command` (here-document bodies, terminators and
806
+ * operators already blanked) could execute a body, here or in a command
807
+ * substitution: a runner word anywhere; a process substitution; a variable
808
+ * assignment (`PATH=...` changes what a command word runs); or a command word
809
+ * that is not a data command (DATA_COMMANDS, dataCommand), is a path or an
810
+ * expansion, or is the bare name of a file the command writes. Text that
811
+ * cannot be parsed counts as yes.
812
+ */
813
+ function mayRunBodies(command: string): boolean {
814
+ if (/[<>]\(/.test(command)) return true;
815
+ const segments: ReturnType<typeof lexCommand>["segments"] = [];
816
+ const queue = [command];
817
+ for (let parsed = 0; queue.length > 0; parsed++) {
818
+ if (parsed > 64) return true;
819
+ const text = queue.shift()!;
820
+ const lexed = lexCommand(text);
821
+ // lexCommand reports only top-level substitutions; one inside double
822
+ // quotes (`"$(sh <<'EOF' … )"`) runs just the same.
823
+ const nested = commandSubstitutions(text);
824
+ if (!lexed.trustworthy || !nested) return true;
825
+ segments.push(...lexed.segments);
826
+ queue.push(...new Set([...lexed.substitutions, ...nested]));
827
+ }
828
+ // Every file the command writes: redirect targets, writer operands, dd of=.
829
+ const written = new Set<string>();
830
+ for (const segment of segments) {
831
+ const words = segment.words.map((word) => decodeWord(word.text));
832
+ for (const at of segment.redirects) written.add(baseName(words[at] ?? ""));
833
+ const index = commandWordIndex(segment);
834
+ if (index < 0) continue;
835
+ const tool = baseName(words[index]);
836
+ if (FILE_WRITERS.has(tool)) {
837
+ for (const word of words.slice(index + 1)) if (!word.startsWith("-")) written.add(baseName(word));
838
+ }
839
+ if (tool === "dd") {
840
+ for (const word of words) if (word.startsWith("of=")) written.add(baseName(word.slice(3)));
841
+ }
842
+ }
843
+ written.delete("");
844
+ for (const segment of segments) {
845
+ for (const word of segment.words) {
846
+ const name = baseName(decodeWord(word.text));
847
+ if (BODY_RUNNERS.has(name) || /^(?:python|pypy|node|perl|ruby|php|lua)[0-9.]+$/.test(name)) return true;
848
+ }
849
+ const index = commandWordIndex(segment);
850
+ const words = segment.words
851
+ .map((word, at) => ({ text: decodeWord(word.text), at }))
852
+ .filter(({ at }) => !segment.redirects.has(at));
853
+ if (words.some(({ text, at }) => (index < 0 || at < index) && ASSIGNMENT.test(text))) return true;
854
+ if (index < 0) continue;
855
+ const raw = segment.words[index].text;
856
+ const name = decodeWord(raw);
857
+ if (/[$\x60]/.test(raw) || name.includes("/") || BODY_RUNNER_COMMANDS.has(name) || written.has(name)) return true;
858
+ if (!dataCommand(words.filter(({ at }) => at >= index).map(({ text }) => text), 0)) return true;
859
+ }
860
+ return false;
861
+ }
862
+
863
+ /**
864
+ * When every here-document body in `command` is data, the command with the
865
+ * bodies and their terminator lines blanked, and the bodies. A body is data
866
+ * when its delimiter is quoted, or it holds no `$( )` or backtick (expansion
867
+ * alone runs nothing), and nothing in the command can run it (mayRunBodies:
868
+ * every command word is a data command).
869
+ * Otherwise null: the command is analysed line by line as it stands.
870
+ */
871
+ function dataHeredocs(command: string): { outer: string; bodies: string[] } | null {
872
+ if (!command.includes("<<")) return null;
873
+ const spans = heredocSpans(command);
874
+ if (!spans || spans.length === 0) return null;
875
+ const lines = command.split("\n");
876
+ const outer = [...lines];
877
+ const bodies: string[] = [];
878
+ for (const span of spans) {
879
+ const body = lines.slice(span.start, span.end);
880
+ if (!span.quoted && body.some((line) => /\$\(|\x60/.test(line))) return null;
881
+ bodies.push(body.join("\n"));
882
+ for (let k = span.start; k <= span.end; k++) outer[k] = "";
883
+ }
884
+ const lexed = [...outer];
885
+ for (const span of [...spans].reverse()) {
886
+ const line = lexed[span.line];
887
+ lexed[span.line] = line.slice(0, span.from) + " ".repeat(span.to - span.from) + line.slice(span.to);
888
+ }
889
+ return mayRunBodies(lexed.join("\n")) ? null : { outer: outer.join("\n"), bodies };
890
+ }
891
+
892
+ /**
893
+ * A directory outside every protected root. A data body is analysed as if run
894
+ * from here, so only a protected path it names, or reaches with its own `cd`,
895
+ * can count; its other words never resolve against the real cwd.
896
+ */
897
+ const DETACHED_CWD = "/";
898
+
899
+ /**
900
+ * Extract protected-repo-checkout targets from a Bash command. Here-document
901
+ * bodies that are data (dataHeredocs) are analysed on their own from
902
+ * DETACHED_CWD; the rest of the command is analysed by segmentTargets.
903
+ */
904
+ export function bashTargets(command: string, home: string, cwd: string): PathTarget[] {
905
+ if (!command) return [];
906
+ const data = dataHeredocs(command);
907
+ if (!data) return segmentTargets(command, home, cwd);
908
+ return [...segmentTargets(data.outer, home, cwd), ...data.bodies.flatMap((body) => bashTargets(body, home, DETACHED_CWD))];
909
+ }
910
+
502
911
  /**
503
912
  * Extract protected-repo-checkout targets from a Bash command. Recognises
504
913
  * explicit `~/...`, `$HOME/...`, `${HOME}/...` and literal-home references
@@ -519,7 +928,7 @@ const REL_OPERAND = /(?:^|\s)(\.\.?|[^\s"';&|<>()\x60/]+(?:\/[^\s"';&|<>()\x60]*
519
928
  * pipeline or the background does not change the outer cwd, and a cd that
520
929
  * cannot be resolved statically never narrows it.
521
930
  */
522
- export function bashTargets(command: string, home: string, cwd: string): PathTarget[] {
931
+ function segmentTargets(command: string, home: string, cwd: string): PathTarget[] {
523
932
  if (!command) return [];
524
933
  const roots = protectedRoots(home);
525
934
  const underRoot = (path: string) => roots.some((root) => path === root || path.startsWith(`${root}${sep}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
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": {