@hasna/hooks 0.12.2 → 0.12.4

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.
@@ -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
@@ -49,10 +51,19 @@
49
51
  * or evaluation error so a guard defect cannot wedge the agent.
50
52
  */
51
53
 
52
- import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, statSync } from "fs";
53
- import { homedir } from "os";
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";
54
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, statSync } from "node:fs";
55
+ import { homedir } from "node:os";
56
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
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}`));
@@ -804,7 +1213,10 @@ export async function run(): Promise<void> {
804
1213
  }
805
1214
  }
806
1215
 
1216
+ // Standalone run only. No top-level await: the CommonJS native safety worker
1217
+ // (scripts/build-native-worker.ts) bundles this module, and its parser refuses
1218
+ // top-level await even where import.meta.main=false makes the block dead.
1219
+ // A rejected run still exits non-zero, as an awaited one did.
807
1220
  if (import.meta.main) {
808
- await run();
809
- process.exit(0);
1221
+ void run().then(() => process.exit(0));
810
1222
  }
@@ -0,0 +1,75 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute } from "node:path";
3
+ import { evaluate as evaluateTrash, findTrashBinary, inspectTrashBinary, scanCommand } from "./hook-trash-guard/src/hook";
4
+ import { evaluate as evaluateRepos } from "./hook-workspace-repos-guard/src/hook";
5
+ import { evaluate as evaluateSignedLinks } from "./hook-signed-link-guard/src/hook";
6
+ import type { CodewithHookInput, CodewithHookOutput } from "./codewith-native-common";
7
+
8
+ // The native safety composite: trash-guard, workspace-repos-guard and
9
+ // signed-link-guard judged together for one capability name. Both the v1 entry
10
+ // (hooks/native-safety-entry.ts) and the v2 worker (hooks/native-safety-worker.ts)
11
+ // call it, so one guard decision has one implementation. It reads no stdin,
12
+ // writes no stdout and never exits: the caller owns the process.
13
+
14
+ export type NativeSafetyCompositeGuard = "trash-guard" | "workspace-repos-guard";
15
+
16
+ /** Refuses an unknown capability name exactly as the v1 entry always has. */
17
+ export function assertCompositeGuard(guard: unknown): asserts guard is NativeSafetyCompositeGuard {
18
+ if (guard !== "trash-guard" && guard !== "workspace-repos-guard") throw new Error("Invalid safety capability");
19
+ }
20
+
21
+ /**
22
+ * Judge one PreToolUse input for `guard`. Returns the exact text the v1 entry
23
+ * writes to stdout (`{"verdict":null}` or `{"verdict":{hookSpecificOutput}}`),
24
+ * and throws where the v1 entry exits non-zero.
25
+ */
26
+ export function evaluateComposite(guard: string, inputText: string): string {
27
+ assertCompositeGuard(guard);
28
+ const name = guard;
29
+ const input = JSON.parse(inputText) as CodewithHookInput;
30
+ if (!input || Array.isArray(input) || input.hook_event_name !== "PreToolUse" || typeof input.tool_name !== "string"
31
+ || !input.tool_input || typeof input.tool_input !== "object" || Array.isArray(input.tool_input)
32
+ || typeof input.cwd !== "string" || !isAbsolute(input.cwd)) throw new Error("Invalid safety input");
33
+ if (input.tool_name === "Bash" && typeof input.tool_input.command !== "string") throw new Error("Invalid shell input");
34
+ // Claude Code's Monitor runs a shell command and streams its stdout to the
35
+ // model; a websocket Monitor carries `ws` and no command.
36
+ if (input.tool_name === "Monitor" && input.tool_input.command !== undefined && typeof input.tool_input.command !== "string") throw new Error("Invalid monitor input");
37
+ const requestedExecution = (input as CodewithHookInput & { hasna_execution?: { schema: string; agent: string } }).hasna_execution;
38
+ if (requestedExecution !== undefined && (!requestedExecution || Object.keys(requestedExecution).sort().join() !== "agent,schema"
39
+ || requestedExecution.schema !== "hasna.hooks.execution.v1" || typeof requestedExecution.agent !== "string"
40
+ || !/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/.test(requestedExecution.agent))) throw new Error("Invalid execution capability");
41
+ const execution = requestedExecution === undefined ? undefined : {
42
+ runtime: process.execPath, home: homedir(), path: process.env.PATH!, agent: requestedExecution.agent,
43
+ };
44
+ if (execution && (!isAbsolute(execution.runtime) || !execution.path)) throw new Error("Invalid bound execution context");
45
+ if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_name)) {
46
+ if (input.tool_input.command !== undefined && input.tool_input.patch !== undefined && input.tool_input.command !== input.tool_input.patch) throw new Error("Conflicting patch aliases");
47
+ const patch = input.tool_input.command ?? input.tool_input.patch;
48
+ if (typeof patch !== "string") throw new Error("Invalid patch input");
49
+ input.tool_input = { ...input.tool_input, command: patch, patch };
50
+ }
51
+ // Every guard judges a Monitor command exactly as it judges the same Bash command.
52
+ const monitor = input.tool_name === "Monitor";
53
+ const shellCommand = input.tool_name === "Bash" || (monitor && typeof input.tool_input.command === "string");
54
+ const evaluated: CodewithHookInput = monitor && shellCommand ? { ...input, tool_name: "Bash" } : input;
55
+ const deny = (reason: string) => ({ hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: reason } });
56
+ const repos = evaluateRepos(evaluated).output;
57
+ // Every shell command, whatever the capability name: the guard reaches each
58
+ // registration of this entry. Its refusal outranks a Trash rewrite, because an
59
+ // allowed rewrite would also run the composite gh read.
60
+ const signedLinks = evaluateSignedLinks(evaluated);
61
+ const handoff = shellCommand && scanCommand(evaluated.tool_input!.command as string, { home: homedir(), cwd: input.cwd }).handoffHits.length > 0;
62
+ let trash: CodewithHookOutput = name === "trash-guard" && (!monitor || shellCommand)
63
+ ? evaluateTrash(evaluated, { home: homedir(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary, execution }) : { continue: true };
64
+ // A Trash rewrite is proven only for Bash. Under Monitor the deletion is
65
+ // refused rather than rewritten: a rewrite the harness dropped would run the
66
+ // raw command.
67
+ if (monitor && trash.hookSpecificOutput?.permissionDecision === "allow") {
68
+ trash = deny("[trash-guard] A deletion inside a Monitor command is refused; run it with the Bash tool, where it is redirected into trash.");
69
+ }
70
+ const verdict: CodewithHookOutput = repos.decision === "block" ? deny(repos.reason!)
71
+ : signedLinks.hookSpecificOutput ? signedLinks
72
+ : handoff ? deny("[workspace-repos-guard] Deletion under a protected repository checkout is refused.")
73
+ : trash;
74
+ return JSON.stringify({ verdict: "continue" in verdict ? null : verdict });
75
+ }
@@ -1,57 +1,11 @@
1
- import { homedir } from "node:os";
2
- import { isAbsolute } from "node:path";
3
- import { evaluate as evaluateTrash, findTrashBinary, inspectTrashBinary, scanCommand } from "./hook-trash-guard/src/hook";
4
- import { evaluate as evaluateRepos } from "./hook-workspace-repos-guard/src/hook";
5
- import { evaluate as evaluateSignedLinks } from "./hook-signed-link-guard/src/hook";
6
- import type { CodewithHookInput, CodewithHookOutput } from "./codewith-native-common";
1
+ import { assertCompositeGuard, evaluateComposite } from "./native-safety-composite";
7
2
 
3
+ // The v1 native safety entry: a thin wrapper over the composite. Its contract is
4
+ // fixed by the v1 supervisor (src/lib/native-safety.ts): the capability name is
5
+ // argv[1], the hook input is stdin, the verdict is one JSON write to stdout, and
6
+ // any failure exits non-zero.
8
7
  // Bundled with import.meta.main=false, so the original standalone run wrappers
9
8
  // cannot swallow exceptions or emit a second response. Only this entry runs.
10
9
  const name = process.argv[1];
11
- if (name !== "trash-guard" && name !== "workspace-repos-guard") throw new Error("Invalid safety capability");
12
- const input = JSON.parse(await Bun.stdin.text()) as CodewithHookInput;
13
- if (!input || Array.isArray(input) || input.hook_event_name !== "PreToolUse" || typeof input.tool_name !== "string"
14
- || !input.tool_input || typeof input.tool_input !== "object" || Array.isArray(input.tool_input)
15
- || typeof input.cwd !== "string" || !isAbsolute(input.cwd)) throw new Error("Invalid safety input");
16
- if (input.tool_name === "Bash" && typeof input.tool_input.command !== "string") throw new Error("Invalid shell input");
17
- // Claude Code's Monitor runs a shell command and streams its stdout to the
18
- // model; a websocket Monitor carries `ws` and no command.
19
- if (input.tool_name === "Monitor" && input.tool_input.command !== undefined && typeof input.tool_input.command !== "string") throw new Error("Invalid monitor input");
20
- const requestedExecution = (input as CodewithHookInput & { hasna_execution?: { schema: string; agent: string } }).hasna_execution;
21
- if (requestedExecution !== undefined && (!requestedExecution || Object.keys(requestedExecution).sort().join() !== "agent,schema"
22
- || requestedExecution.schema !== "hasna.hooks.execution.v1" || typeof requestedExecution.agent !== "string"
23
- || !/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/.test(requestedExecution.agent))) throw new Error("Invalid execution capability");
24
- const execution = requestedExecution === undefined ? undefined : {
25
- runtime: process.execPath, home: homedir(), path: process.env.PATH!, agent: requestedExecution.agent,
26
- };
27
- if (execution && (!isAbsolute(execution.runtime) || !execution.path)) throw new Error("Invalid bound execution context");
28
- if (["apply_patch", "ApplyPatch", "functions.apply_patch"].includes(input.tool_name)) {
29
- if (input.tool_input.command !== undefined && input.tool_input.patch !== undefined && input.tool_input.command !== input.tool_input.patch) throw new Error("Conflicting patch aliases");
30
- const patch = input.tool_input.command ?? input.tool_input.patch;
31
- if (typeof patch !== "string") throw new Error("Invalid patch input");
32
- input.tool_input = { ...input.tool_input, command: patch, patch };
33
- }
34
- // Every guard judges a Monitor command exactly as it judges the same Bash command.
35
- const monitor = input.tool_name === "Monitor";
36
- const shellCommand = input.tool_name === "Bash" || (monitor && typeof input.tool_input.command === "string");
37
- const evaluated: CodewithHookInput = monitor && shellCommand ? { ...input, tool_name: "Bash" } : input;
38
- const deny = (reason: string) => ({ hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "deny" as const, permissionDecisionReason: reason } });
39
- const repos = evaluateRepos(evaluated).output;
40
- // Every shell command, whatever the capability name: the guard reaches each
41
- // registration of this entry. Its refusal outranks a Trash rewrite, because an
42
- // allowed rewrite would also run the composite gh read.
43
- const signedLinks = evaluateSignedLinks(evaluated);
44
- const handoff = shellCommand && scanCommand(evaluated.tool_input!.command as string, { home: homedir(), cwd: input.cwd }).handoffHits.length > 0;
45
- let trash: CodewithHookOutput = name === "trash-guard" && (!monitor || shellCommand)
46
- ? evaluateTrash(evaluated, { home: homedir(), cwd: input.cwd, findTrash: findTrashBinary, inspectTrash: inspectTrashBinary, execution }) : { continue: true };
47
- // A Trash rewrite is proven only for Bash. Under Monitor the deletion is
48
- // refused rather than rewritten: a rewrite the harness dropped would run the
49
- // raw command.
50
- if (monitor && trash.hookSpecificOutput?.permissionDecision === "allow") {
51
- trash = deny("[trash-guard] A deletion inside a Monitor command is refused; run it with the Bash tool, where it is redirected into trash.");
52
- }
53
- const verdict: CodewithHookOutput = repos.decision === "block" ? deny(repos.reason!)
54
- : signedLinks.hookSpecificOutput ? signedLinks
55
- : handoff ? deny("[workspace-repos-guard] Deletion under a protected repository checkout is refused.")
56
- : trash;
57
- process.stdout.write(JSON.stringify({ verdict: "continue" in verdict ? null : verdict }));
10
+ assertCompositeGuard(name);
11
+ process.stdout.write(evaluateComposite(name, await Bun.stdin.text()));
@@ -0,0 +1,19 @@
1
+ import { evaluateComposite } from "./native-safety-composite";
2
+
3
+ // The native safety worker, ABI v2 (docs/adr/0001-stable-native-safety-launcher.md
4
+ // section 3.4). scripts/build-native-worker.ts bundles it into
5
+ // bin/native-safety-worker.cjs: CommonJS for Node's module shape, requiring only
6
+ // node: builtins, with no top-level await and no top-level side effects. It reads
7
+ // no stdin, writes no stdout and never exits; the loader that evaluates the
8
+ // verified bundle owns the process.
9
+
10
+ /** Replaced at build time with the package.json version; never set at run time. */
11
+ declare const HASNA_HOOKS_NATIVE_SAFETY_WORKER_VERSION: string;
12
+
13
+ export const abi = 2;
14
+ export const version: string = HASNA_HOOKS_NATIVE_SAFETY_WORKER_VERSION;
15
+
16
+ /** The v1 entry's exact stdout text for this input; throws where the v1 entry exits non-zero. */
17
+ export function evaluate(guard: string, inputText: string): string {
18
+ return evaluateComposite(guard, inputText);
19
+ }