@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.
- package/README.md +7 -5
- package/bin/index.js +19 -36
- package/bin/native-safety-entry.js +20 -15
- package/dist/index.js +1 -1
- package/dist/lib/codex-safety-check.d.ts +1 -1
- package/hooks/hook-workspace-repos-guard/README.md +20 -0
- package/hooks/hook-workspace-repos-guard/src/hook.ts +414 -5
- package/package.json +1 -1
|
@@ -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 {
|
|
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}`));
|
package/package.json
CHANGED