@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.
- package/README.md +40 -7
- package/bin/hooks-mcp.js +2013 -847
- package/bin/index.js +2508 -1291
- package/bin/native-safety-entry.js +20 -15
- package/bin/native-safety-worker.cjs +20 -0
- package/dist/index.js +2268 -1047
- package/dist/lib/claude-skills-coordination.d.ts +25 -3
- package/dist/lib/codex-safety-check.d.ts +1 -1
- package/dist/lib/codex-safety-trust.d.ts +9 -2
- package/dist/lib/codex-settings.d.ts +6 -3
- package/dist/lib/installer.d.ts +9 -1
- package/dist/lib/native-safety-registration.d.ts +8 -2
- package/dist/native-safety.d.ts +4 -1
- package/dist/native-safety.js +2 -0
- package/hooks/codewith-native-common.ts +3 -3
- package/hooks/hook-signed-link-guard/src/hook.ts +5 -2
- package/hooks/hook-trash-guard/src/hook.ts +7 -4
- package/hooks/hook-workspace-repos-guard/README.md +20 -0
- package/hooks/hook-workspace-repos-guard/src/hook.ts +422 -10
- package/hooks/native-safety-composite.ts +75 -0
- package/hooks/native-safety-entry.ts +7 -53
- package/hooks/native-safety-worker.ts +19 -0
- package/package.json +5 -4
- package/scripts/build-native-worker.ts +152 -0
|
@@ -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 {
|
|
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", "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
12
|
-
|
|
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
|
+
}
|