@blogic-cz/agent-tools 1.5.8 → 1.5.10
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 +18 -7
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/dist/gh-tool/api.d.ts.map +1 -1
- package/dist/gh-tool/environment.d.ts +2 -0
- package/dist/gh-tool/environment.d.ts.map +1 -0
- package/dist/gh-tool/service.d.ts.map +1 -1
- package/dist/gh-tool/text-input.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/credential-guard/index.ts +903 -36
- package/src/gh-tool/api.ts +50 -24
- package/src/gh-tool/environment.ts +50 -0
- package/src/gh-tool/service.ts +3 -1
- package/src/gh-tool/text-input.ts +7 -1
|
@@ -414,6 +414,615 @@ function parseStaticShellCommands(
|
|
|
414
414
|
return { pipelines, redirects };
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
+
/** A lexical binding proof, preserving shell quotes and rejecting every other expansion. */
|
|
418
|
+
function literalBindingText(
|
|
419
|
+
command: string,
|
|
420
|
+
name: string,
|
|
421
|
+
value: string,
|
|
422
|
+
): { text: string; syntax: string } | undefined {
|
|
423
|
+
let text = "";
|
|
424
|
+
let syntax = "";
|
|
425
|
+
let quote: "'" | '"' | undefined;
|
|
426
|
+
for (let i = 0; i < command.length; i++) {
|
|
427
|
+
const char = command[i] ?? "";
|
|
428
|
+
if (char === "\\" && quote !== "'") {
|
|
429
|
+
const next = command[++i];
|
|
430
|
+
if (next === undefined || /[\r\n]/.test(next)) return undefined;
|
|
431
|
+
text += char + next;
|
|
432
|
+
syntax += " ";
|
|
433
|
+
} else if (quote === "'") {
|
|
434
|
+
text += char;
|
|
435
|
+
syntax += " ";
|
|
436
|
+
if (char === quote) quote = undefined;
|
|
437
|
+
} else if (char === "'" && !quote) {
|
|
438
|
+
quote = char;
|
|
439
|
+
text += char;
|
|
440
|
+
syntax += " ";
|
|
441
|
+
} else if (char === '"') {
|
|
442
|
+
quote = quote ? undefined : char;
|
|
443
|
+
text += char;
|
|
444
|
+
syntax += " ";
|
|
445
|
+
} else if (char === "$") {
|
|
446
|
+
const match = /^\$\{([A-Za-z_]\w*)\}|^\$([A-Za-z_]\w*)/.exec(command.slice(i));
|
|
447
|
+
if (!match || (match[1] ?? match[2]) !== name) return undefined;
|
|
448
|
+
text += value;
|
|
449
|
+
syntax += quote ? " ".repeat(value.length) : value;
|
|
450
|
+
i += match[0].length - 1;
|
|
451
|
+
} else {
|
|
452
|
+
if (char === "`" || (!quote && /[(){}*?[\]#]/.test(char))) return undefined;
|
|
453
|
+
text += char;
|
|
454
|
+
syntax += quote ? " " : char;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return quote ? undefined : { text, syntax };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** These single-letter binders cannot name shell lookup, splitting or generated variables. */
|
|
461
|
+
function isLiteralBinder(name: string): boolean {
|
|
462
|
+
return /^(?:[a-z]|T)$/.test(name);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function unwrapProofCommand(words: string[], marker: string): string[] | undefined {
|
|
466
|
+
let index = 0;
|
|
467
|
+
while (words[index] === "rtk" || words[index] === "command") {
|
|
468
|
+
if (words[index++] === "rtk") {
|
|
469
|
+
if (words[index++] !== "proxy") return undefined;
|
|
470
|
+
} else if (words[index] === "--") index++;
|
|
471
|
+
}
|
|
472
|
+
if (words.slice(0, index + 1).some((word) => word.includes(marker))) return undefined;
|
|
473
|
+
const argv = words.slice(index);
|
|
474
|
+
if (!argv.length || /^[A-Za-z_]\w*=/.test(argv[0] ?? "")) return undefined;
|
|
475
|
+
return argv;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function isLiteralLoopCommand(words: string[], marker: string, value: string): boolean {
|
|
479
|
+
const marked = unwrapProofCommand(words, marker);
|
|
480
|
+
if (!marked) return false;
|
|
481
|
+
const argv = marked.map((word) => word.replaceAll(marker, value));
|
|
482
|
+
const name = argv[0];
|
|
483
|
+
const uses = marked.flatMap((word, index) => (word.includes(marker) ? [index] : []));
|
|
484
|
+
if (name === "cat")
|
|
485
|
+
return (
|
|
486
|
+
argv.slice(1).every((word) => word === "--" || !word.startsWith("-")) &&
|
|
487
|
+
uses.every((index) => index > 0 && !argv[index]?.startsWith("-"))
|
|
488
|
+
);
|
|
489
|
+
if (name === "echo") return uses.every((index) => index > 0 && !argv[index]?.startsWith("-"));
|
|
490
|
+
if (name === "printf") {
|
|
491
|
+
const format = passivePrintfFormatIndex(argv);
|
|
492
|
+
return format !== undefined && uses.every((index) => index > format);
|
|
493
|
+
}
|
|
494
|
+
if (name === "herdr")
|
|
495
|
+
return (
|
|
496
|
+
argv.length === 4 &&
|
|
497
|
+
argv[1] === "agent" &&
|
|
498
|
+
argv[2] === "get" &&
|
|
499
|
+
/^[A-Za-z0-9_.:-]+$/.test(argv[3] ?? "") &&
|
|
500
|
+
uses.every((index) => index === 3)
|
|
501
|
+
);
|
|
502
|
+
if (name === "bun")
|
|
503
|
+
return (
|
|
504
|
+
argv.length === 9 &&
|
|
505
|
+
argv.slice(0, 6).join(" ") === "bun run gh-tool pr review-triage --repo" &&
|
|
506
|
+
/^[A-Za-z0-9_.-]+$/.test(argv[6] ?? "") &&
|
|
507
|
+
argv[7] === "--pr" &&
|
|
508
|
+
/^\d+$/.test(argv[8] ?? "") &&
|
|
509
|
+
uses.every((index) => index === 8)
|
|
510
|
+
);
|
|
511
|
+
return (
|
|
512
|
+
["grep", "head", "tail"].includes(name ?? "") && uses.length === 0 && isPassiveTextCommand(argv)
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
type LiteralShellProof = { command: string; paths: string[] };
|
|
517
|
+
|
|
518
|
+
/** Bounded observed shell forms only; failure leaves the original command under normal policy. */
|
|
519
|
+
function materializeLiteralShell(command: string): LiteralShellProof | undefined {
|
|
520
|
+
if (new TextEncoder().encode(command).byteLength > 65_536) return undefined;
|
|
521
|
+
const marker = "GUARDLITERALBINDING";
|
|
522
|
+
if (command.includes(marker)) return undefined;
|
|
523
|
+
let script = command.trim();
|
|
524
|
+
const wrapped = parseStaticShellCommands(script);
|
|
525
|
+
if (
|
|
526
|
+
wrapped &&
|
|
527
|
+
typeof wrapped !== "string" &&
|
|
528
|
+
wrapped.pipelines.length === 1 &&
|
|
529
|
+
wrapped.pipelines[0]?.length === 1 &&
|
|
530
|
+
wrapped.redirects.length === 0
|
|
531
|
+
) {
|
|
532
|
+
const argv = unwrapProofCommand(wrapped.pipelines[0]?.[0] ?? [], marker);
|
|
533
|
+
if (argv?.[0] === "sh" && argv[1] === "-c" && argv.length === 3) script = argv[2] ?? "";
|
|
534
|
+
}
|
|
535
|
+
const loop =
|
|
536
|
+
/^for[ \t]+([A-Za-z_]\w*)[ \t]+in[ \t]+([^;\n]+);[ \t\r\n]*do[ \t\r\n]+([\s\S]*);[ \t\r\n]*done[ \t\r\n]*$/.exec(
|
|
537
|
+
script,
|
|
538
|
+
);
|
|
539
|
+
if (loop) {
|
|
540
|
+
const name = loop[1] ?? "";
|
|
541
|
+
if (!isLiteralBinder(name)) return undefined;
|
|
542
|
+
const items = loop[2] ?? "";
|
|
543
|
+
const rawValues = items.trim().split(/[ \t]+/);
|
|
544
|
+
if (
|
|
545
|
+
rawValues.some(
|
|
546
|
+
(item) =>
|
|
547
|
+
!/^(?:[A-Za-z0-9_./:][A-Za-z0-9_./:-]*|'[A-Za-z0-9_./:][A-Za-z0-9_./:-]*'|"[A-Za-z0-9_./:][A-Za-z0-9_./:-]*")$/.test(
|
|
548
|
+
item,
|
|
549
|
+
),
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
return undefined;
|
|
553
|
+
const values = rawValues.map((item) => item.replace(/^['"]|['"]$/g, ""));
|
|
554
|
+
if (!values.length || values.length > 16) return undefined;
|
|
555
|
+
const marked = literalBindingText(loop[3] ?? "", name, marker);
|
|
556
|
+
if (!marked || /&&|\|\||[<>]|&/.test(marked.syntax.replace(/\b2>&1\b/g, ""))) return undefined;
|
|
557
|
+
const parsed = parseStaticShellCommands(marked.text);
|
|
558
|
+
if (
|
|
559
|
+
!parsed ||
|
|
560
|
+
typeof parsed === "string" ||
|
|
561
|
+
parsed.redirects.length ||
|
|
562
|
+
parsed.pipelines.flat().length > 16 ||
|
|
563
|
+
!parsed.pipelines.flat().length
|
|
564
|
+
)
|
|
565
|
+
return undefined;
|
|
566
|
+
if (
|
|
567
|
+
!values.every((value) =>
|
|
568
|
+
parsed.pipelines.flat().every((words) => isLiteralLoopCommand(words, marker, value)),
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
return undefined;
|
|
572
|
+
const size = values.reduce(
|
|
573
|
+
(total, value) =>
|
|
574
|
+
total +
|
|
575
|
+
new TextEncoder().encode(marked.text).byteLength +
|
|
576
|
+
(marked.text.split(marker).length - 1) * (value.length - marker.length) +
|
|
577
|
+
2,
|
|
578
|
+
0,
|
|
579
|
+
);
|
|
580
|
+
if (size > 65_536) return undefined;
|
|
581
|
+
return {
|
|
582
|
+
command: values.map((value) => marked.text.replaceAll(marker, value)).join(";\n"),
|
|
583
|
+
paths: [],
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
const assignment =
|
|
587
|
+
/^cd[ \t]+(~?\/?[A-Za-z0-9_./-]+)[ \t]+&&[ \t]+([A-Za-z_]\w*)=([A-Za-z0-9_./][A-Za-z0-9_./-]*)[ \t]+&&[ \t]+/.exec(
|
|
588
|
+
command,
|
|
589
|
+
);
|
|
590
|
+
if (assignment && isLiteralBinder(assignment[2] ?? "")) {
|
|
591
|
+
const rest = literalBindingText(
|
|
592
|
+
command.slice(assignment[0].length),
|
|
593
|
+
assignment[2] ?? "",
|
|
594
|
+
marker,
|
|
595
|
+
);
|
|
596
|
+
if (!rest) return undefined;
|
|
597
|
+
const end = rest.syntax.search(/[;&|\r\n]/);
|
|
598
|
+
const use = rest.text.slice(0, end < 0 ? undefined : end);
|
|
599
|
+
const suffix = end < 0 ? "" : rest.text.slice(end);
|
|
600
|
+
if (suffix.includes(marker)) return undefined;
|
|
601
|
+
const parsed = parseStaticShellCommands(use);
|
|
602
|
+
const argv =
|
|
603
|
+
parsed &&
|
|
604
|
+
typeof parsed !== "string" &&
|
|
605
|
+
parsed.pipelines.length === 1 &&
|
|
606
|
+
parsed.pipelines[0]?.length === 1 &&
|
|
607
|
+
parsed.redirects.length === 0
|
|
608
|
+
? parsed.pipelines[0]?.[0]
|
|
609
|
+
: undefined;
|
|
610
|
+
if (
|
|
611
|
+
!argv ||
|
|
612
|
+
argv.length < 4 ||
|
|
613
|
+
argv.slice(0, 3).join(" ") !== "git add --" ||
|
|
614
|
+
argv.slice(3).some((word) => !/^[A-Za-z0-9_./-]+$/.test(word))
|
|
615
|
+
)
|
|
616
|
+
return undefined;
|
|
617
|
+
const value = assignment[3] ?? "";
|
|
618
|
+
const size =
|
|
619
|
+
new TextEncoder().encode(rest.text).byteLength +
|
|
620
|
+
(use.split(marker).length - 1) * (value.length - marker.length) +
|
|
621
|
+
(assignment[1]?.length ?? 0) +
|
|
622
|
+
7;
|
|
623
|
+
if (size > 65_536) return undefined;
|
|
624
|
+
const paths = argv.slice(3).map((word) => word.replaceAll(marker, value));
|
|
625
|
+
return { command: `cd ${assignment[1]} && ${use.replaceAll(marker, value)}${suffix}`, paths };
|
|
626
|
+
}
|
|
627
|
+
// Only the observed rg -n PATTERN PATH form; marker position proves a real file operand.
|
|
628
|
+
const brace =
|
|
629
|
+
/(?:^|\s)([A-Za-z0-9_./-]*\/[A-Za-z0-9_./-]*\{[A-Za-z_]\w*(?:,[A-Za-z_]\w*){1,7}\}[A-Za-z0-9_./-]*)$/.exec(
|
|
630
|
+
command,
|
|
631
|
+
);
|
|
632
|
+
if (!brace) return undefined;
|
|
633
|
+
const path = brace[1] ?? "";
|
|
634
|
+
const start = command.length - path.length;
|
|
635
|
+
const marked = command.slice(0, start) + marker;
|
|
636
|
+
const lexical = literalBindingText(marked, "", "");
|
|
637
|
+
const parsed = parseStaticShellCommands(marked);
|
|
638
|
+
if (
|
|
639
|
+
!lexical ||
|
|
640
|
+
/[;&|<>\r\n]/.test(lexical.syntax) ||
|
|
641
|
+
!parsed ||
|
|
642
|
+
typeof parsed === "string" ||
|
|
643
|
+
parsed.redirects.length ||
|
|
644
|
+
parsed.pipelines.length !== 1 ||
|
|
645
|
+
parsed.pipelines[0]?.length !== 1
|
|
646
|
+
)
|
|
647
|
+
return undefined;
|
|
648
|
+
const argv = unwrapProofCommand(parsed.pipelines[0]?.[0] ?? [], marker);
|
|
649
|
+
if (
|
|
650
|
+
!argv ||
|
|
651
|
+
argv.length !== 4 ||
|
|
652
|
+
argv[0] !== "rg" ||
|
|
653
|
+
argv[1] !== "-n" ||
|
|
654
|
+
argv[2]?.startsWith("-") ||
|
|
655
|
+
argv[3] !== marker
|
|
656
|
+
)
|
|
657
|
+
return undefined;
|
|
658
|
+
const members = /\{([^{}]+)\}/.exec(path);
|
|
659
|
+
if (!members) return undefined;
|
|
660
|
+
const values = (members[1] ?? "").split(",");
|
|
661
|
+
const size =
|
|
662
|
+
new TextEncoder().encode(command.slice(0, start)).byteLength +
|
|
663
|
+
values.reduce((total, value) => total + path.length - members[0].length + value.length + 1, -1);
|
|
664
|
+
if (size > 65_536) return undefined;
|
|
665
|
+
const paths = values.map((member) => path.replace(members[0], member));
|
|
666
|
+
return { command: command.slice(0, start) + paths.join(" "), paths };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Complete package metadata loops: no prelude, loader shadowing, mutation or arbitrary suffix. */
|
|
670
|
+
function staticPackageInventory(program: string): string[] | undefined {
|
|
671
|
+
if (program.length > 65_536) return undefined;
|
|
672
|
+
const prefix =
|
|
673
|
+
/^for\s*\(\s*const\s+([a-z])\s+of\s+(\[\s*(?:"[A-Za-z0-9_./@-]+"|'[A-Za-z0-9_./@-]+')(?:\s*,\s*(?:"[A-Za-z0-9_./@-]+"|'[A-Za-z0-9_./@-]+'))*\s*\])\s*\)\s*\{\s*(try\s*\{\s*)?const\s+([a-z])\s*=\s*require\s*\(\s*(["'])\.\/\5\s*\+\s*\1\s*\)\s*;\s*/.exec(
|
|
674
|
+
program.trim(),
|
|
675
|
+
);
|
|
676
|
+
if (!prefix || prefix[1] === prefix[4]) return undefined;
|
|
677
|
+
const paths = [...(prefix[2] ?? "").matchAll(/["']([^"']+)["']/g)].map(
|
|
678
|
+
(match) => "./" + (match[1] ?? ""),
|
|
679
|
+
);
|
|
680
|
+
if (
|
|
681
|
+
!paths.length ||
|
|
682
|
+
paths.length > 16 ||
|
|
683
|
+
// Four bytes per character bounds encoded text and shell quote escaping before allocation.
|
|
684
|
+
(program.length + Math.max(...paths.map((path) => path.length)) + 64) * paths.length * 4 >
|
|
685
|
+
65_536 ||
|
|
686
|
+
paths.some((path) => !/(?:^|\/)package\.json$/.test(path))
|
|
687
|
+
)
|
|
688
|
+
return undefined;
|
|
689
|
+
const name = prefix[1];
|
|
690
|
+
const result = prefix[4];
|
|
691
|
+
const end = prefix[3] ? String.raw`\s*\}\s*catch\s*\{\s*\}\s*\}` : String.raw`\s*\}`;
|
|
692
|
+
const suffix = program.trim().slice(prefix[0].length);
|
|
693
|
+
const bin = new RegExp(
|
|
694
|
+
String.raw`^console\.log\(\s*${name}\s*,\s*${result}\.bin\s*\)\s*;?${end}\s*$`,
|
|
695
|
+
);
|
|
696
|
+
const scripts = new RegExp(
|
|
697
|
+
String.raw`^console\.log\(\s*${name}\s*,\s*Object\.entries\(\s*${result}\.scripts\s*\|\|\s*\{\}\s*\)\.filter\(\(\[n\]\)=>n\.endsWith\("-tool"\)\|\|n\.endsWith\(":cli"\)\|\|n\.endsWith\(":auth"\)\|\|n==="ui:check"\)\s*\)\s*;?${end}\s*$`,
|
|
698
|
+
);
|
|
699
|
+
return bin.test(suffix) || scripts.test(suffix) ? paths : undefined;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Shared identity for refusal paths, including versioned/free-threaded Python and Windows names. */
|
|
703
|
+
function inlineRuntimeKind(executable: string | undefined): "python" | "node" | "bun" | undefined {
|
|
704
|
+
const name =
|
|
705
|
+
executable
|
|
706
|
+
?.split(/[/\\]/)
|
|
707
|
+
.at(-1)
|
|
708
|
+
?.replace(/\.exe$/i, "") ?? "";
|
|
709
|
+
if (/^python(?:\d+(?:\.\d+)*)?t?$/i.test(name)) return "python";
|
|
710
|
+
if (/^node$/i.test(name)) return "node";
|
|
711
|
+
if (/^bun$/i.test(name)) return "bun";
|
|
712
|
+
return undefined;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function mentionsInlineRuntime(command: string): boolean {
|
|
716
|
+
return command.split(/[\s'"`()<>;&|]+/).some((word) => inlineRuntimeKind(word) !== undefined);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Locate code only in an actual inline-program operand, after supported runtime flags. */
|
|
720
|
+
function inlineProgramIndex(argv: string[]): number | undefined {
|
|
721
|
+
const executable = argv[0]?.split("/").at(-1) ?? "";
|
|
722
|
+
const name = inlineRuntimeKind(argv[0]);
|
|
723
|
+
// Other recognized runtime spellings retain conservative refusal; this exception is bounded.
|
|
724
|
+
if (!name || !["python", "python3", "node", "bun"].includes(executable)) return undefined;
|
|
725
|
+
const python = name === "python";
|
|
726
|
+
const flags = python ? ["-u", "-B", "-I", "-S"] : name === "bun" ? ["--no-env-file"] : [];
|
|
727
|
+
let index = 1;
|
|
728
|
+
while (flags.includes(argv[index] ?? "")) index++;
|
|
729
|
+
if (
|
|
730
|
+
!(python ? argv[index] === "-c" : ["-e", "--eval"].includes(argv[index] ?? "")) ||
|
|
731
|
+
argv[index + 1] === undefined
|
|
732
|
+
)
|
|
733
|
+
return undefined;
|
|
734
|
+
// Python consumes the remaining words as sys.argv. Node can still parse startup options
|
|
735
|
+
// after eval, until -- or an ordinary positional operand. Bun suffixes require explicit --.
|
|
736
|
+
const suffix = argv[index + 2];
|
|
737
|
+
if (name === "bun" && suffix !== undefined && suffix !== "--") return undefined;
|
|
738
|
+
if (name === "node" && suffix?.startsWith("-") && suffix !== "--") return undefined;
|
|
739
|
+
return index + 1;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function isUnsupportedInlineRuntime(argv: string[]): boolean {
|
|
743
|
+
if (!inlineRuntimeKind(argv[0])) return false;
|
|
744
|
+
if (inlineProgramIndex(argv) !== undefined) return false;
|
|
745
|
+
return argv
|
|
746
|
+
.slice(1)
|
|
747
|
+
.some((arg) =>
|
|
748
|
+
/^(?:-[A-Za-z]*[cep]|-[rmp]|--(?:eval|print|require|import|loader|preload|experimental-loader))(?:$|=|[^-])/.test(
|
|
749
|
+
arg,
|
|
750
|
+
),
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Move a complete, literal Python stdin program into the existing inline argv boundary. */
|
|
755
|
+
function normalizeProgramHeredocs(command: string): string {
|
|
756
|
+
const heredoc = boundedHeredoc(command);
|
|
757
|
+
if (!heredoc || !heredoc.closed || (!heredoc.quoted && /[$`\\]/.test(heredoc.body)))
|
|
758
|
+
return command;
|
|
759
|
+
const parsed = parseStaticShellCommands(heredoc.header);
|
|
760
|
+
if (!parsed || typeof parsed === "string") return command;
|
|
761
|
+
const last = parsed.pipelines.at(-1)?.at(-1);
|
|
762
|
+
const argv = last ? unwrapStaticCommand(last) : [];
|
|
763
|
+
if (
|
|
764
|
+
inlineRuntimeKind(argv[0]) !== "python" ||
|
|
765
|
+
inlineProgramIndex([argv[0] ?? "", "-c", ""]) === undefined ||
|
|
766
|
+
argv.length !== 2 ||
|
|
767
|
+
argv[1] !== "-"
|
|
768
|
+
)
|
|
769
|
+
return command;
|
|
770
|
+
if (!/-\s*$/.test(heredoc.header)) return command;
|
|
771
|
+
const quoted = "'" + heredoc.body.replaceAll("'", "'\"'\"'") + "'";
|
|
772
|
+
return (
|
|
773
|
+
heredoc.header.replace(/-\s*$/, "-c " + quoted) +
|
|
774
|
+
"\n" +
|
|
775
|
+
normalizeProgramHeredocs(heredoc.following)
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Strings are data in these languages; retain their values separately for path checks. */
|
|
780
|
+
function programParts(
|
|
781
|
+
program: string,
|
|
782
|
+
python: boolean,
|
|
783
|
+
):
|
|
784
|
+
| { code: string; literals: string[]; pathLiterals: string[]; loadedModules: string[] }
|
|
785
|
+
| undefined {
|
|
786
|
+
const literals: string[] = [];
|
|
787
|
+
const pathLiterals: string[] = [];
|
|
788
|
+
const loadedModules: string[] = [];
|
|
789
|
+
let code = "";
|
|
790
|
+
for (let i = 0; i < program.length; i++) {
|
|
791
|
+
const char = program[i] ?? "";
|
|
792
|
+
if ((python && char === "#") || (!python && program.startsWith("//", i))) {
|
|
793
|
+
const terminator = python ? /[\r\n]/ : /[\r\n\u2028\u2029]/;
|
|
794
|
+
while (i < program.length && !terminator.test(program[i] ?? "")) i++;
|
|
795
|
+
code += "\n";
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
if (!python && program.startsWith("/*", i)) {
|
|
799
|
+
const end = program.indexOf("*/", i + 2);
|
|
800
|
+
if (end < 0) return undefined;
|
|
801
|
+
i = end + 1;
|
|
802
|
+
code += " ";
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (!python && char === "/") {
|
|
806
|
+
// Recognize regex only at an expression start; ambiguous slash syntax stays closed.
|
|
807
|
+
if (!/[=(,:!&|?;[{]\s*$/.test(code) && !/\breturn\s+$/.test(code)) return undefined;
|
|
808
|
+
let inClass = false;
|
|
809
|
+
let closed = false;
|
|
810
|
+
for (i++; i < program.length; i++) {
|
|
811
|
+
const next = program[i];
|
|
812
|
+
if (next === "\\") {
|
|
813
|
+
i++;
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
if (next === "\n") return undefined;
|
|
817
|
+
if (next === "[") inClass = true;
|
|
818
|
+
else if (next === "]") inClass = false;
|
|
819
|
+
else if (next === "/" && !inClass) {
|
|
820
|
+
closed = true;
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (!closed) return undefined;
|
|
825
|
+
code += " REGEX ";
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (char !== "'" && char !== '"' && char !== "`") {
|
|
829
|
+
code += char;
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
// Bun.write(PATH, TEXT) differs from file-object write(TEXT).
|
|
833
|
+
const literalTextArgument =
|
|
834
|
+
(/\.(?:write_text|write)\s*\(\s*$/.test(code) && !/\bBun\s*\.\s*write\s*\(\s*$/.test(code)) ||
|
|
835
|
+
/\bBun\s*\.\s*write\s*\(\s*STRING\s*,\s*$/.test(code);
|
|
836
|
+
const interpolated = python && /[fF]/.test(/[rRuUbBfF]+$/.exec(program.slice(0, i))?.[0] ?? "");
|
|
837
|
+
const delimiter = program.startsWith(char.repeat(3), i) ? char.repeat(3) : char;
|
|
838
|
+
i += delimiter.length;
|
|
839
|
+
let value = "";
|
|
840
|
+
let expressionDepth = 0;
|
|
841
|
+
while (i < program.length && !program.startsWith(delimiter, i)) {
|
|
842
|
+
if (program[i] === "\\") {
|
|
843
|
+
value += program[i] + (program[++i] ?? "");
|
|
844
|
+
i++;
|
|
845
|
+
} else {
|
|
846
|
+
const current = program[i];
|
|
847
|
+
if (expressionDepth > 0 && (current === "'" || current === '"')) {
|
|
848
|
+
if (program.startsWith(current.repeat(3), i)) return undefined;
|
|
849
|
+
let end = i + 1;
|
|
850
|
+
while (end < program.length && program[end] !== current) {
|
|
851
|
+
if (program[end] === "\\") end++;
|
|
852
|
+
end++;
|
|
853
|
+
}
|
|
854
|
+
if (end >= program.length) return undefined;
|
|
855
|
+
value += program.slice(i, end + 1);
|
|
856
|
+
i = end + 1;
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
if (
|
|
860
|
+
interpolated &&
|
|
861
|
+
expressionDepth === 0 &&
|
|
862
|
+
(program.startsWith("{{", i) || program.startsWith("}}", i))
|
|
863
|
+
) {
|
|
864
|
+
value += program.slice(i, i + 2);
|
|
865
|
+
i += 2;
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
if (
|
|
869
|
+
(interpolated && current === "{") ||
|
|
870
|
+
(char === "`" && current === "{" && (expressionDepth > 0 || program[i - 1] === "$"))
|
|
871
|
+
)
|
|
872
|
+
expressionDepth++;
|
|
873
|
+
if ((interpolated || char === "`") && current === "}" && expressionDepth > 0)
|
|
874
|
+
expressionDepth--;
|
|
875
|
+
value += program[i++];
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (i >= program.length || expressionDepth !== 0) return undefined;
|
|
879
|
+
// Path values need exact decoding. Other escapes (including continuations and
|
|
880
|
+
// identity escapes) remain unproved; payload strings do not require this proof.
|
|
881
|
+
if (
|
|
882
|
+
/\b(?:open|Path|readFile|readFileSync|writeFile|writeFileSync|file|Bun\s*\.\s*write)\s*\(\s*$/.test(
|
|
883
|
+
code,
|
|
884
|
+
) &&
|
|
885
|
+
value.replace(/\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4})/g, "").includes("\\")
|
|
886
|
+
)
|
|
887
|
+
return undefined;
|
|
888
|
+
value = value.replace(
|
|
889
|
+
/\\(?:x([0-9a-fA-F]{2})|u([0-9a-fA-F]{4})|([0-7]{1,3}))/g,
|
|
890
|
+
(_match, hex: string | undefined, unicode: string | undefined, octal: string | undefined) =>
|
|
891
|
+
String.fromCharCode(Number.parseInt(hex ?? unicode ?? octal ?? "0", octal ? 8 : 16)),
|
|
892
|
+
);
|
|
893
|
+
if (
|
|
894
|
+
!python &&
|
|
895
|
+
/\b(?:require|import)\s*\(\s*$|\bimport\s*$|\b(?:import|export)\s+[^;()\n]*\bfrom\s*$/.test(
|
|
896
|
+
code,
|
|
897
|
+
)
|
|
898
|
+
)
|
|
899
|
+
loadedModules.push(value);
|
|
900
|
+
literals.push(value);
|
|
901
|
+
if (!literalTextArgument) pathLiterals.push(value);
|
|
902
|
+
// Template expressions remain executable; do not use their quoted boundary as an exemption.
|
|
903
|
+
if (interpolated || (char === "`" && value.includes("${"))) code += value;
|
|
904
|
+
code += " STRING ";
|
|
905
|
+
i += delimiter.length - 1;
|
|
906
|
+
}
|
|
907
|
+
return { code, literals, pathLiterals, loadedModules };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** One immutable literal argv list passed once to subprocess.run, without a shell. */
|
|
911
|
+
function staticSubprocessCommand(program: string): string | undefined {
|
|
912
|
+
// The binding and use must be adjacent top-level statements at the start of the program.
|
|
913
|
+
// A literal-looking assignment in a string, branch, or imported scope proves nothing.
|
|
914
|
+
const prefix =
|
|
915
|
+
/^import subprocess[ \t]*\r?\n([A-Za-z_]\w*)[ \t]*=[ \t]*(\[\s*(?:"[^"\\]*"|'[^'\\]*')(?:\s*,\s*(?:"[^"\\]*"|'[^'\\]*'))*\s*,?\s*\])[ \t]*\r?\n(?:[A-Za-z_]\w*[ \t]*=[ \t]*)?subprocess\.run\(/.exec(
|
|
916
|
+
program.trimStart(),
|
|
917
|
+
);
|
|
918
|
+
if (!prefix) return undefined;
|
|
919
|
+
const variable = prefix[1] ?? "";
|
|
920
|
+
const rest = program.trimStart().slice(prefix[0].length);
|
|
921
|
+
const call = new RegExp(
|
|
922
|
+
`^${variable}\\s*(?:,\\s*(?:cwd\\s*=\\s*(?:"[^"\\\\]*"|'[^'\\\\]*')|(?:capture_output|text|check)\\s*=\\s*(?:True|False)|timeout\\s*=\\s*\\d+))*\\s*\\)`,
|
|
923
|
+
).exec(rest);
|
|
924
|
+
if (!call) return undefined;
|
|
925
|
+
const parts = programParts(program, true);
|
|
926
|
+
if (
|
|
927
|
+
!parts ||
|
|
928
|
+
(parts.code.match(new RegExp(`\\b${variable}\\b`, "g"))?.length ?? 0) !== 2 ||
|
|
929
|
+
(parts.code.match(/\bsubprocess\b/g)?.length ?? 0) !== 2
|
|
930
|
+
)
|
|
931
|
+
return undefined;
|
|
932
|
+
const values = [...(prefix[2] ?? "").matchAll(/"([^"\\]*)"|'([^'\\]*)'/g)].map(
|
|
933
|
+
(match) => match[1] ?? match[2] ?? "",
|
|
934
|
+
);
|
|
935
|
+
if (!values.length) return undefined;
|
|
936
|
+
return values.map((value) => "'" + value.replaceAll("'", "'\"'\"'") + "'").join(" ");
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function inlineSubprocessCommands(command: string): string[] {
|
|
940
|
+
const parsed = parseStaticShellCommands(command);
|
|
941
|
+
if (!parsed || typeof parsed === "string") return [];
|
|
942
|
+
return parsed.pipelines.flat().flatMap((words) => {
|
|
943
|
+
const argv = unwrapStaticCommand(words);
|
|
944
|
+
const index = inlineProgramIndex(argv);
|
|
945
|
+
const nested = index === undefined ? undefined : staticSubprocessCommand(argv[index] ?? "");
|
|
946
|
+
return nested ? [nested] : [];
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** The complete invocation must preserve the package loader's startup and lookup identity. */
|
|
951
|
+
function hasProvedPackageInventoryInvocation(command: string): boolean {
|
|
952
|
+
if (hasEnvironmentMutationPrefix(command)) return false;
|
|
953
|
+
const parsed = parseStaticShellCommands(command);
|
|
954
|
+
if (!parsed || typeof parsed === "string") return false;
|
|
955
|
+
return parsed.pipelines.flat().every((words) => {
|
|
956
|
+
const argv = unwrapStaticCommand(words);
|
|
957
|
+
if (
|
|
958
|
+
argv[0]?.split("/").at(-1) === "cat" ||
|
|
959
|
+
isPassiveTextCommand(argv) ||
|
|
960
|
+
isLiteralInlineWriter(argv)
|
|
961
|
+
)
|
|
962
|
+
return true;
|
|
963
|
+
const index = inlineProgramIndex(argv);
|
|
964
|
+
return (
|
|
965
|
+
inlineRuntimeKind(argv[0]) === "node" &&
|
|
966
|
+
index !== undefined &&
|
|
967
|
+
staticPackageInventory(argv[index] ?? "") !== undefined
|
|
968
|
+
);
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** Replay each proved JSON loader operand through ordinary command policies as well. */
|
|
973
|
+
function inlinePackageCommands(command: string, allowInventory: boolean): string[] {
|
|
974
|
+
if (!allowInventory) return [];
|
|
975
|
+
const parsed = parseStaticShellCommands(command);
|
|
976
|
+
if (!parsed || typeof parsed === "string") return [];
|
|
977
|
+
return parsed.pipelines.flat().flatMap((words) => {
|
|
978
|
+
const argv = unwrapStaticCommand(words);
|
|
979
|
+
const index = inlineProgramIndex(argv);
|
|
980
|
+
if (index === undefined || inlineRuntimeKind(argv[0]) !== "node") return [];
|
|
981
|
+
const program = argv[index] ?? "";
|
|
982
|
+
const paths = staticPackageInventory(program);
|
|
983
|
+
return (paths ?? []).map((path) =>
|
|
984
|
+
["node", "-e", program]
|
|
985
|
+
.map((word, position) => {
|
|
986
|
+
const materialized =
|
|
987
|
+
position === 2
|
|
988
|
+
? program.replace(
|
|
989
|
+
/\brequire\s*\(\s*(["'])\.\/\1\s*\+\s*[a-z]\s*\)/,
|
|
990
|
+
`require(${JSON.stringify(path)})`,
|
|
991
|
+
)
|
|
992
|
+
: word;
|
|
993
|
+
return "'" + materialized.replaceAll("'", "'\"'\"'") + "'";
|
|
994
|
+
})
|
|
995
|
+
.join(" "),
|
|
996
|
+
);
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function hasInlineProgramExecution(
|
|
1001
|
+
program: string,
|
|
1002
|
+
python: boolean,
|
|
1003
|
+
allowInventory = false,
|
|
1004
|
+
): boolean {
|
|
1005
|
+
const parts = programParts(program, python);
|
|
1006
|
+
if (!parts) return true;
|
|
1007
|
+
if (parts.loadedModules.some((name) => /^(?:node:)?vm$/.test(name))) return true;
|
|
1008
|
+
// Dynamic loading and reflective access cannot establish a non-executing code operand.
|
|
1009
|
+
if (
|
|
1010
|
+
(/\b(?:require|import)\s*\((?!\s*STRING\s*\))/.test(parts.code) &&
|
|
1011
|
+
(!allowInventory || !staticPackageInventory(program))) ||
|
|
1012
|
+
/\[\s*STRING\s*\+/.test(parts.code) ||
|
|
1013
|
+
/\b(?:process|os)\s*\[/.test(parts.code)
|
|
1014
|
+
)
|
|
1015
|
+
return true;
|
|
1016
|
+
const code = staticSubprocessCommand(program)
|
|
1017
|
+
? parts.code.replace(/\bsubprocess\b/g, "")
|
|
1018
|
+
: parts.code;
|
|
1019
|
+
return (
|
|
1020
|
+
/\b(?:subprocess|child_process|execSync|execFile|execFileSync|spawn|spawnSync|eval|exec|Function|Reflect|getattr|setattr|globals|locals|vars|compile|__import__)\b|\bos\s*\.\s*(?:system|popen)|\bBun\s*\.\s*(?:spawn|spawnSync)|\bDeno\s*\.\s*Command/.test(
|
|
1021
|
+
code,
|
|
1022
|
+
) || parts.literals.some((value) => /^(?:node:)?child_process$/.test(value))
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
417
1026
|
function mentionsEnvironmentRead(text: string): boolean {
|
|
418
1027
|
// `--env dev` is a flag of another command, not the env command.
|
|
419
1028
|
return /(?<![\w.-])printenv\b(?!-)|(?<![\w.-])-\w*Oprintenv\b|(?<![\w.-])\benv\b(?!-)/i.test(
|
|
@@ -487,6 +1096,7 @@ function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
|
|
|
487
1096
|
)
|
|
488
1097
|
return false;
|
|
489
1098
|
return (argv[0]?.split("/").at(-1) === "awk" && !isAwkExecution(argv)) ||
|
|
1099
|
+
inlineProgramIndex(argv) !== undefined ||
|
|
490
1100
|
isJqObjectConstruction(argv)
|
|
491
1101
|
? false
|
|
492
1102
|
: hasArgumentBraceExpansion(argv.join(" "));
|
|
@@ -540,6 +1150,11 @@ function isJqObjectConstruction(argv: string[]): boolean {
|
|
|
540
1150
|
if (argv[index] === "--") index++;
|
|
541
1151
|
const filter = argv[index] ?? "";
|
|
542
1152
|
if (argv.slice(index + 1).some((arg) => arg.startsWith("-"))) return false;
|
|
1153
|
+
const expression = filter
|
|
1154
|
+
.replace(/\.[A-Za-z_][A-Za-z0-9_.-]*/g, "FIELD")
|
|
1155
|
+
.replace(/\b[A-Za-z_][A-Za-z0-9_-]*\s*:/g, ":");
|
|
1156
|
+
if (/^\{[\s\S]*\}$/.test(filter) && /^(?:FIELD|length|map|[\s{}():,|>0-9])+$/.test(expression))
|
|
1157
|
+
return true;
|
|
543
1158
|
return (
|
|
544
1159
|
/^\{\s*[A-Za-z_][A-Za-z0-9_-]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_-]*)*\s*\}$/.test(filter) ||
|
|
545
1160
|
/^\{\s*[A-Za-z_][A-Za-z0-9_-]*\s*:\s*\.[A-Za-z_][A-Za-z0-9_.-]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_-]*\s*:\s*\.[A-Za-z_][A-Za-z0-9_.-]*)*\s*\}$/.test(
|
|
@@ -694,7 +1309,7 @@ function isPassiveTextCommand(argv: string[]): boolean {
|
|
|
694
1309
|
const args = argv.slice(1);
|
|
695
1310
|
// These commands consume literal text. Execution options are not exceptions.
|
|
696
1311
|
if (name === "printf") return passivePrintfFormatIndex(argv) !== undefined;
|
|
697
|
-
if (["echo", "grep", "head"].includes(name)) {
|
|
1312
|
+
if (["echo", "grep", "head", "tail"].includes(name)) {
|
|
698
1313
|
return true;
|
|
699
1314
|
}
|
|
700
1315
|
if (name === "rg") return !hasExecutionOption(args, ["pre", "hostname-bin"]);
|
|
@@ -708,7 +1323,11 @@ function isPassiveTextCommand(argv: string[]): boolean {
|
|
|
708
1323
|
);
|
|
709
1324
|
}
|
|
710
1325
|
|
|
711
|
-
function hasStaticEnvironmentRead(
|
|
1326
|
+
function hasStaticEnvironmentRead(
|
|
1327
|
+
argv: string[],
|
|
1328
|
+
allowedNames: Set<string>,
|
|
1329
|
+
allowInventory = false,
|
|
1330
|
+
): boolean {
|
|
712
1331
|
const name = argv[0]?.split("/").at(-1);
|
|
713
1332
|
if (
|
|
714
1333
|
["sh", "bash", "zsh"].includes(name ?? "") &&
|
|
@@ -717,17 +1336,49 @@ function hasStaticEnvironmentRead(argv: string[], allowedNames: Set<string>): bo
|
|
|
717
1336
|
) {
|
|
718
1337
|
return true;
|
|
719
1338
|
}
|
|
1339
|
+
if (
|
|
1340
|
+
name === "docker" &&
|
|
1341
|
+
argv.some((arg) => ["sh", "bash", "zsh"].includes(arg)) &&
|
|
1342
|
+
argv.some((arg) => /[$`]/.test(arg))
|
|
1343
|
+
)
|
|
1344
|
+
return true;
|
|
720
1345
|
if (name === "awk" && argv.slice(1).some((arg) => /\bENVIRON\b/.test(arg))) return true;
|
|
721
1346
|
if (name === "printenv") return !isAllowedEnvironmentRead(argv, allowedNames);
|
|
722
1347
|
if (name === "env") {
|
|
723
1348
|
const wrapped = unwrapEnvironmentCommand(argv);
|
|
724
1349
|
return wrapped === null
|
|
725
1350
|
? isEnvironmentListing(argv)
|
|
726
|
-
: hasStaticEnvironmentRead(unwrapStaticCommand(wrapped), allowedNames);
|
|
1351
|
+
: hasStaticEnvironmentRead(unwrapStaticCommand(wrapped), allowedNames, allowInventory);
|
|
727
1352
|
}
|
|
1353
|
+
if (isUnsupportedInlineRuntime(argv)) return true;
|
|
1354
|
+
const programIndex = inlineProgramIndex(argv);
|
|
1355
|
+
if (programIndex !== undefined)
|
|
1356
|
+
return hasInlineProgramExecution(
|
|
1357
|
+
argv[programIndex] ?? "",
|
|
1358
|
+
inlineRuntimeKind(argv[0]) === "python",
|
|
1359
|
+
allowInventory && inlineRuntimeKind(argv[0]) === "node",
|
|
1360
|
+
);
|
|
728
1361
|
if (isPassiveTextCommand(argv)) return false;
|
|
729
1362
|
// Any command may re-parse an argument as shell code (trap, find -exec, awk system()).
|
|
730
|
-
return
|
|
1363
|
+
return (
|
|
1364
|
+
hasCommandArgumentBraceExpansion(argv) ||
|
|
1365
|
+
([
|
|
1366
|
+
"sh",
|
|
1367
|
+
"bash",
|
|
1368
|
+
"zsh",
|
|
1369
|
+
"eval",
|
|
1370
|
+
"source",
|
|
1371
|
+
"find",
|
|
1372
|
+
"xargs",
|
|
1373
|
+
"trap",
|
|
1374
|
+
"awk",
|
|
1375
|
+
"git",
|
|
1376
|
+
"sort",
|
|
1377
|
+
"jq",
|
|
1378
|
+
"docker",
|
|
1379
|
+
].includes(name ?? "") &&
|
|
1380
|
+
mentionsEnvironmentRead(argv.join(" ")))
|
|
1381
|
+
);
|
|
731
1382
|
}
|
|
732
1383
|
|
|
733
1384
|
function isStaticHerdrPrompt(command: string): boolean {
|
|
@@ -751,7 +1402,11 @@ function isHerdrPrompt(command: string): boolean {
|
|
|
751
1402
|
return /^herdr\s+agent\s+prompt\b/.test(command.trim());
|
|
752
1403
|
}
|
|
753
1404
|
|
|
754
|
-
function hasEnvironmentRead(
|
|
1405
|
+
function hasEnvironmentRead(
|
|
1406
|
+
command: string,
|
|
1407
|
+
allowedNames: Set<string>,
|
|
1408
|
+
allowInventory = hasProvedPackageInventoryInvocation(command),
|
|
1409
|
+
): boolean {
|
|
755
1410
|
if (isStaticHerdrPrompt(command)) return false;
|
|
756
1411
|
if (isHerdrPrompt(command)) return true;
|
|
757
1412
|
if (isLiteralTextWrite(command)) return false;
|
|
@@ -789,23 +1444,24 @@ function hasEnvironmentRead(command: string, allowedNames: Set<string>): boolean
|
|
|
789
1444
|
) {
|
|
790
1445
|
return true;
|
|
791
1446
|
}
|
|
792
|
-
if (unwrapped.some((argv) => hasStaticEnvironmentRead(argv, allowedNames))) {
|
|
1447
|
+
if (unwrapped.some((argv) => hasStaticEnvironmentRead(argv, allowedNames, allowInventory))) {
|
|
793
1448
|
return true;
|
|
794
1449
|
}
|
|
795
1450
|
|
|
796
1451
|
// A literal producer can feed executable text to a shell, xargs, or an unknown consumer.
|
|
797
|
-
const result = pipelines.some(
|
|
798
|
-
(
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
(argv) =>
|
|
802
|
-
isAllowedEnvironmentRead(argv, allowedNames) ||
|
|
1452
|
+
const result = pipelines.some((pipeline) =>
|
|
1453
|
+
pipeline.some(
|
|
1454
|
+
(argv, index) =>
|
|
1455
|
+
(isAllowedEnvironmentRead(argv, allowedNames) ||
|
|
803
1456
|
(isLiteralTextProducer(argv) &&
|
|
804
|
-
(mentionsEnvironmentRead(argv.join(" ")) || hasCommandArgumentBraceExpansion(argv)))
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1457
|
+
(mentionsEnvironmentRead(argv.join(" ")) || hasCommandArgumentBraceExpansion(argv)))) &&
|
|
1458
|
+
pipeline
|
|
1459
|
+
.slice(index + 1)
|
|
1460
|
+
.some(
|
|
1461
|
+
(consumer) =>
|
|
1462
|
+
!isPassiveTextCommand(consumer) && !isAllowedEnvironmentRead(consumer, allowedNames),
|
|
1463
|
+
),
|
|
1464
|
+
),
|
|
809
1465
|
);
|
|
810
1466
|
return result;
|
|
811
1467
|
}
|
|
@@ -897,8 +1553,21 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
897
1553
|
const isSensitivePath = (text: string) =>
|
|
898
1554
|
sensitivePath.test(text) ||
|
|
899
1555
|
sensitiveCamelCase.test(text) ||
|
|
900
|
-
(sensitiveName.test(text) &&
|
|
1556
|
+
(sensitiveName.test(text) &&
|
|
1557
|
+
!/(?:^|\/)credential-guard(?:(?:-[^/]+)?\.(?:ts|md|js)|\*|\/(?:index\.(?:ts|js)))?$/.test(
|
|
1558
|
+
text,
|
|
1559
|
+
));
|
|
901
1560
|
if (/(?:^|[\s;&|])--env-file(?:=|\s)[^;&|]*\.env\b/i.test(command)) return true;
|
|
1561
|
+
const heredoc = boundedHeredoc(command);
|
|
1562
|
+
if (
|
|
1563
|
+
heredoc &&
|
|
1564
|
+
(isLiteralTextWrite(command) ||
|
|
1565
|
+
(isLiteralTextWrite(command.slice(0, command.length - heredoc.following.length).trimEnd()) &&
|
|
1566
|
+
!mentionsInlineRuntime(heredoc.following) &&
|
|
1567
|
+
!/\b(?:sh|bash|zsh|eval|source|xargs)\b/.test(heredoc.following)))
|
|
1568
|
+
) {
|
|
1569
|
+
return hasSensitiveFileRead(heredoc.header + "\n" + heredoc.following, isPathBlocked);
|
|
1570
|
+
}
|
|
902
1571
|
if (/\bcat\b[^;&|]*<<-?\s*\S+[\s\S]*\b(?:secret|credential)\b/i.test(command)) return true;
|
|
903
1572
|
const parsed = parseStaticShellCommands(command);
|
|
904
1573
|
const readers =
|
|
@@ -922,13 +1591,47 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
922
1591
|
return path !== undefined && (isPathBlocked(path) || isSensitivePath(path));
|
|
923
1592
|
});
|
|
924
1593
|
}
|
|
1594
|
+
const programIndex = inlineProgramIndex(inspected);
|
|
1595
|
+
if (programIndex !== undefined) {
|
|
1596
|
+
const parts = programParts(
|
|
1597
|
+
inspected[programIndex] ?? "",
|
|
1598
|
+
inlineRuntimeKind(inspected[0]) === "python",
|
|
1599
|
+
);
|
|
1600
|
+
if (!parts) return true;
|
|
1601
|
+
const inventory = staticPackageInventory(inspected[programIndex] ?? "");
|
|
1602
|
+
if (inventory?.some((path) => isPathBlocked(path) || isSensitivePath(path))) return true;
|
|
1603
|
+
if (parts.code.split(/[\s'"`()<>;&|]+/).some(isPathBlocked)) return true;
|
|
1604
|
+
const accessesFiles =
|
|
1605
|
+
/\b(?:open|Path|read_text|read_bytes|readFile|readFileSync|file|write_text|writeFile|writeFileSync)\b/.test(
|
|
1606
|
+
parts.code,
|
|
1607
|
+
) || /\bBun\s*\.\s*write\s*\(/.test(parts.code);
|
|
1608
|
+
if (
|
|
1609
|
+
accessesFiles &&
|
|
1610
|
+
/\b(?:sys|process)\s*\.\s*argv\b/.test(parts.code) &&
|
|
1611
|
+
inspected
|
|
1612
|
+
.slice(programIndex + 1)
|
|
1613
|
+
.some((arg) => isPathBlocked(arg) || isSensitivePath(arg))
|
|
1614
|
+
)
|
|
1615
|
+
return true;
|
|
1616
|
+
// Interpolation expressions remain in code; inspect their nested literal operands too.
|
|
1617
|
+
const expressionParts = /['"]/.test(parts.code)
|
|
1618
|
+
? programParts(parts.code, inlineRuntimeKind(inspected[0]) === "python")
|
|
1619
|
+
: { pathLiterals: [] };
|
|
1620
|
+
if (!expressionParts) return true;
|
|
1621
|
+
return [...parts.pathLiterals, ...expressionParts.pathLiterals].some(
|
|
1622
|
+
(value) =>
|
|
1623
|
+
(accessesFiles && isPathBlocked(value)) ||
|
|
1624
|
+
(!/\s/.test(value) && /[/.]/.test(value) && isSensitivePath(value)),
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
925
1627
|
if (!readers.test(name)) return false;
|
|
926
1628
|
if (name === "rg" || name === "grep") {
|
|
927
1629
|
const args = inspected.slice(1);
|
|
928
1630
|
const paths: string[] = [];
|
|
929
1631
|
let hasPattern = false;
|
|
930
1632
|
let options = true;
|
|
931
|
-
|
|
1633
|
+
const enumeratesFiles = name === "rg" && args.includes("--files");
|
|
1634
|
+
let filesOnly = enumeratesFiles;
|
|
932
1635
|
for (let i = 0; i < args.length; i++) {
|
|
933
1636
|
const arg = args[i] ?? "";
|
|
934
1637
|
if (options && arg === "--") {
|
|
@@ -966,7 +1669,11 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
966
1669
|
: arg.startsWith("--glob=")
|
|
967
1670
|
? arg.slice(7)
|
|
968
1671
|
: arg.slice(2);
|
|
969
|
-
if (
|
|
1672
|
+
if (
|
|
1673
|
+
!enumeratesFiles &&
|
|
1674
|
+
(isSensitivePath(glob) || isPathBlocked(glob.replaceAll("*", "")))
|
|
1675
|
+
)
|
|
1676
|
+
paths.push(glob);
|
|
970
1677
|
continue;
|
|
971
1678
|
}
|
|
972
1679
|
if (
|
|
@@ -977,8 +1684,9 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
977
1684
|
if (arg === "-f" || arg === "--file") paths.push(value ?? "");
|
|
978
1685
|
if (
|
|
979
1686
|
(arg === "-g" || arg === "--glob" || arg === "--iglob" || arg === "--include") &&
|
|
1687
|
+
!enumeratesFiles &&
|
|
980
1688
|
value &&
|
|
981
|
-
isSensitivePath(value)
|
|
1689
|
+
(isSensitivePath(value) || isPathBlocked(value.replaceAll("*", "")))
|
|
982
1690
|
)
|
|
983
1691
|
paths.push(value);
|
|
984
1692
|
if (arg === "-e" || arg === "--regexp" || arg === "-f" || arg === "--file")
|
|
@@ -1029,7 +1737,7 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
1029
1737
|
return true;
|
|
1030
1738
|
if (readers.test(command) && command.split(/[\s'"`()<>;&|]+/).some(isPathBlocked)) return true;
|
|
1031
1739
|
const unquoted = command.replace(/'(?:\\.|[^'])*'/g, " ").replace(/"(?:\\.|[^"$`])*"/g, " ");
|
|
1032
|
-
if (
|
|
1740
|
+
if ((mentionsInlineRuntime(command) || /\bruby\b/i.test(command)) && isSensitivePath(command)) {
|
|
1033
1741
|
return true;
|
|
1034
1742
|
}
|
|
1035
1743
|
return (
|
|
@@ -1051,7 +1759,7 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
1051
1759
|
command = [heredoc.header, heredoc.following].join("\n");
|
|
1052
1760
|
heredoc = boundedHeredoc(command);
|
|
1053
1761
|
}
|
|
1054
|
-
if (!command.includes("$")) return false;
|
|
1762
|
+
if (!command.includes("$") && !command.includes("`")) return false;
|
|
1055
1763
|
let invalidExpansion = false;
|
|
1056
1764
|
const assignments = getLeadingLiteralAssignments(command);
|
|
1057
1765
|
const localValues = new Map<string, string>();
|
|
@@ -1087,6 +1795,7 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
1087
1795
|
quote = quote === '"' ? undefined : '"';
|
|
1088
1796
|
continue;
|
|
1089
1797
|
}
|
|
1798
|
+
if (char === "`") return true;
|
|
1090
1799
|
if (char !== "$") {
|
|
1091
1800
|
normalized += char;
|
|
1092
1801
|
continue;
|
|
@@ -1184,13 +1893,129 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
1184
1893
|
return hasSensitiveFileRead(materialized, isPathBlocked);
|
|
1185
1894
|
}
|
|
1186
1895
|
|
|
1187
|
-
function
|
|
1188
|
-
return (
|
|
1189
|
-
|
|
1190
|
-
/\b(?:python3?|node|bun)\b/.test(command)
|
|
1896
|
+
function mentionsEnvironmentSource(command: string): boolean {
|
|
1897
|
+
return /\b(?:os\s*\.\s*(?:environ|getenv)|process\s*\.\s*env|Environment\s*\.\s*GetEnvironmentVariable)\b/.test(
|
|
1898
|
+
command,
|
|
1191
1899
|
);
|
|
1192
1900
|
}
|
|
1193
1901
|
|
|
1902
|
+
/** Closed raw-source grammar: literal display/write only, without projection or evaluation. */
|
|
1903
|
+
function isLiteralInlineWriter(argv: string[]): boolean {
|
|
1904
|
+
const index = inlineProgramIndex(argv);
|
|
1905
|
+
if (index === undefined || argv.length !== index + 1) return false;
|
|
1906
|
+
const kind = inlineRuntimeKind(argv[0]);
|
|
1907
|
+
const python = kind === "python";
|
|
1908
|
+
const program = (argv[index] ?? "").trim();
|
|
1909
|
+
let position = 0;
|
|
1910
|
+
const take = (pattern: RegExp): boolean => {
|
|
1911
|
+
const match = pattern.exec(program.slice(position));
|
|
1912
|
+
if (!match) return false;
|
|
1913
|
+
position += match[0].length;
|
|
1914
|
+
return true;
|
|
1915
|
+
};
|
|
1916
|
+
const literal = (): string | undefined => {
|
|
1917
|
+
take(/^\s*/);
|
|
1918
|
+
const start = position;
|
|
1919
|
+
const quote = program[position];
|
|
1920
|
+
if (quote !== "'" && quote !== '"') return undefined;
|
|
1921
|
+
const delimiter =
|
|
1922
|
+
python && program.startsWith(quote.repeat(3), position) ? quote.repeat(3) : quote;
|
|
1923
|
+
position += delimiter.length;
|
|
1924
|
+
while (position < program.length) {
|
|
1925
|
+
if (program.startsWith(delimiter, position)) {
|
|
1926
|
+
position += delimiter.length;
|
|
1927
|
+
return program.slice(start, position);
|
|
1928
|
+
}
|
|
1929
|
+
if (program[position] === "\\") position += 2;
|
|
1930
|
+
else if (delimiter.length === 1 && /[\r\n\u2028\u2029]/.test(program[position] ?? ""))
|
|
1931
|
+
return undefined;
|
|
1932
|
+
else position++;
|
|
1933
|
+
}
|
|
1934
|
+
return undefined;
|
|
1935
|
+
};
|
|
1936
|
+
let prefix: RegExp;
|
|
1937
|
+
let middle: RegExp | undefined;
|
|
1938
|
+
let mode = false;
|
|
1939
|
+
if (python && /^print\s*\(/.test(program)) prefix = /^print\s*\(\s*/;
|
|
1940
|
+
else if (!python && /^console\s*\.\s*log\s*\(/.test(program))
|
|
1941
|
+
prefix = /^console\s*\.\s*log\s*\(\s*/;
|
|
1942
|
+
else if (python && /^open\s*\(/.test(program)) {
|
|
1943
|
+
prefix = /^open\s*\(\s*/;
|
|
1944
|
+
mode = true;
|
|
1945
|
+
middle = /^\s*\)\s*\.\s*write\s*\(\s*/;
|
|
1946
|
+
} else if (kind === "bun" && /^Bun\s*\.\s*write\s*\(/.test(program)) {
|
|
1947
|
+
prefix = /^Bun\s*\.\s*write\s*\(\s*/;
|
|
1948
|
+
middle = /^\s*,\s*/;
|
|
1949
|
+
} else if (python) {
|
|
1950
|
+
prefix = /^from[ \t]+pathlib[ \t]+import[ \t]+Path[ \t]*(?:;|\r?\n)\s*Path\s*\(\s*/;
|
|
1951
|
+
middle = /^\s*\)\s*\.\s*write_text\s*\(\s*/;
|
|
1952
|
+
} else return false;
|
|
1953
|
+
if (!take(prefix) || literal() === undefined) return false;
|
|
1954
|
+
if (mode) {
|
|
1955
|
+
if (!take(/^\s*,\s*/)) return false;
|
|
1956
|
+
const value = literal();
|
|
1957
|
+
if (value !== "'w'" && value !== '"w"') return false;
|
|
1958
|
+
}
|
|
1959
|
+
if (middle && (!take(middle) || literal() === undefined)) return false;
|
|
1960
|
+
return take(/^\s*\)\s*;?\s*$/) && position === program.length;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/** Inert command identity is unproved after an explicit environment mutation. */
|
|
1964
|
+
function hasEnvironmentMutationPrefix(command: string): boolean {
|
|
1965
|
+
const heredoc = boundedHeredoc(command);
|
|
1966
|
+
if (heredoc) {
|
|
1967
|
+
if (!heredoc.closed) return true;
|
|
1968
|
+
return hasEnvironmentMutationPrefix(heredoc.header + "\n" + heredoc.following);
|
|
1969
|
+
}
|
|
1970
|
+
const parsed = parseStaticShellCommands(command);
|
|
1971
|
+
if (!parsed || typeof parsed === "string") return true;
|
|
1972
|
+
return parsed.pipelines.flat().some((words) => {
|
|
1973
|
+
let index = 0;
|
|
1974
|
+
while (index < words.length) {
|
|
1975
|
+
const word = words[index] ?? "";
|
|
1976
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) return true;
|
|
1977
|
+
const name = word.split("/").at(-1);
|
|
1978
|
+
if (name === "env") {
|
|
1979
|
+
index++;
|
|
1980
|
+
if (words[index] === "--") index++;
|
|
1981
|
+
if (words[index]?.startsWith("-")) return true;
|
|
1982
|
+
} else if (name === "rtk" || name === "command") {
|
|
1983
|
+
index++;
|
|
1984
|
+
if ((name === "rtk" && words[index] === "proxy") || words[index] === "--") index++;
|
|
1985
|
+
} else return false;
|
|
1986
|
+
}
|
|
1987
|
+
return false;
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
function hasEnvironmentSourceAccess(command: string, rawTrigger: boolean): boolean {
|
|
1992
|
+
if (rawTrigger && hasEnvironmentMutationPrefix(command)) return true;
|
|
1993
|
+
if (isLiteralTextWrite(command) || isStaticHerdrPrompt(command)) return false;
|
|
1994
|
+
const parsed = parseStaticShellCommands(command);
|
|
1995
|
+
if (!parsed || typeof parsed === "string") return rawTrigger;
|
|
1996
|
+
const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
|
|
1997
|
+
if (rawTrigger) {
|
|
1998
|
+
// Original invocation-wide evidence survives split writers and inline quote projection.
|
|
1999
|
+
return !commands.every(
|
|
2000
|
+
(argv) =>
|
|
2001
|
+
argv[0]?.split("/").at(-1) === "cat" ||
|
|
2002
|
+
isPassiveTextCommand(argv) ||
|
|
2003
|
+
isLiteralInlineWriter(argv),
|
|
2004
|
+
);
|
|
2005
|
+
}
|
|
2006
|
+
return commands.some((argv) => {
|
|
2007
|
+
const index = inlineProgramIndex(argv);
|
|
2008
|
+
if (index === undefined) return false;
|
|
2009
|
+
const parts = programParts(argv[index] ?? "", inlineRuntimeKind(argv[0]) === "python");
|
|
2010
|
+
if (!parts) return true;
|
|
2011
|
+
return (
|
|
2012
|
+
mentionsEnvironmentSource(parts.code) ||
|
|
2013
|
+
(/\b(?:os|process)\s*\[/.test(parts.code) &&
|
|
2014
|
+
parts.literals.some((value) => /^(?:env|environ|getenv)$/.test(value)))
|
|
2015
|
+
);
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
|
|
1194
2019
|
/** Extract file path from hook arguments. */
|
|
1195
2020
|
export function extractFilePath(args: Record<string, unknown>): string {
|
|
1196
2021
|
return (args.filePath as string) || (args.file_path as string) || (args.path as string) || "";
|
|
@@ -1294,23 +2119,51 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
1294
2119
|
}
|
|
1295
2120
|
|
|
1296
2121
|
function getDangerousBashReason(command: string): string | null {
|
|
2122
|
+
const originalCommand = command;
|
|
2123
|
+
const allowPackageInventory = hasProvedPackageInventoryInvocation(originalCommand);
|
|
2124
|
+
const rawEnvironmentSource =
|
|
2125
|
+
mentionsEnvironmentSource(command) && mentionsInlineRuntime(command);
|
|
2126
|
+
if (rawEnvironmentSource && hasEnvironmentMutationPrefix(originalCommand))
|
|
2127
|
+
return "blocked by the environment/execution policy after an unproved environment mutation";
|
|
2128
|
+
command = normalizeProgramHeredocs(command);
|
|
2129
|
+
const proof = materializeLiteralShell(command);
|
|
2130
|
+
if (!proof && /^\s*for\b/.test(command)) return "cannot prove a bounded literal shell loop";
|
|
2131
|
+
if (proof) {
|
|
2132
|
+
if (proof.paths.some((path) => hasSensitiveFileRead("cat " + path, isPathBlocked)))
|
|
2133
|
+
return "accesses a sensitive file path";
|
|
2134
|
+
command = proof.command;
|
|
2135
|
+
if (getBlockedCliTool(command)) return "invokes a blocked CLI from a literal expansion";
|
|
2136
|
+
}
|
|
2137
|
+
for (const nested of [
|
|
2138
|
+
...inlineSubprocessCommands(command),
|
|
2139
|
+
...inlinePackageCommands(command, allowPackageInventory),
|
|
2140
|
+
]) {
|
|
2141
|
+
const reason = getDangerousBashReason(nested);
|
|
2142
|
+
if (reason || getBlockedCliTool(nested))
|
|
2143
|
+
return reason ?? "invokes a blocked CLI from a script";
|
|
2144
|
+
}
|
|
1297
2145
|
if (
|
|
1298
2146
|
hasSensitiveFileRead(command, isPathBlocked) ||
|
|
1299
2147
|
hasSensitivePathRedirect(command, isPathBlocked)
|
|
1300
|
-
)
|
|
1301
|
-
return "
|
|
1302
|
-
}
|
|
2148
|
+
)
|
|
2149
|
+
return "blocked by the file-access policy; a sensitive path matched or this command form could not be verified";
|
|
1303
2150
|
if (
|
|
1304
2151
|
!isLiteralTextWrite(command) &&
|
|
1305
2152
|
hasEnvironmentVariableExpansionRead(command, allowedEnvironmentVariables, isPathBlocked)
|
|
1306
2153
|
) {
|
|
1307
2154
|
return "expands an unapproved or executable environment variable value";
|
|
1308
2155
|
}
|
|
1309
|
-
if (
|
|
1310
|
-
|
|
1311
|
-
|
|
2156
|
+
if (
|
|
2157
|
+
hasEnvironmentSourceAccess(command, rawEnvironmentSource) ||
|
|
2158
|
+
hasEnvironmentRead(command, allowedEnvironmentVariables, allowPackageInventory)
|
|
2159
|
+
) {
|
|
2160
|
+
return "blocked by the environment/execution policy; permitted access or non-execution could not be established for this command form";
|
|
1312
2161
|
}
|
|
1313
|
-
if (
|
|
2162
|
+
if (
|
|
2163
|
+
dangerousBashPatterns.some(
|
|
2164
|
+
(pattern) => pattern.test(originalCommand) || pattern.test(command),
|
|
2165
|
+
)
|
|
2166
|
+
) {
|
|
1314
2167
|
return "matches a configured dangerous-command pattern";
|
|
1315
2168
|
}
|
|
1316
2169
|
return null;
|
|
@@ -1341,7 +2194,21 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
1341
2194
|
return null;
|
|
1342
2195
|
}
|
|
1343
2196
|
|
|
1344
|
-
function getBlockedCliTool(
|
|
2197
|
+
function getBlockedCliTool(
|
|
2198
|
+
command: string,
|
|
2199
|
+
expand = true,
|
|
2200
|
+
): { name: string; wrapper: string } | null {
|
|
2201
|
+
command = normalizeProgramHeredocs(command);
|
|
2202
|
+
const proof = expand ? materializeLiteralShell(command) : undefined;
|
|
2203
|
+
if (proof) {
|
|
2204
|
+
const originalBlocked = getBlockedCliTool(command, false);
|
|
2205
|
+
if (originalBlocked) return originalBlocked;
|
|
2206
|
+
command = proof.command;
|
|
2207
|
+
}
|
|
2208
|
+
for (const nested of inlineSubprocessCommands(command)) {
|
|
2209
|
+
const blocked = getBlockedCliTool(nested);
|
|
2210
|
+
if (blocked) return blocked;
|
|
2211
|
+
}
|
|
1345
2212
|
const staticCommands = parseStaticShellCommands(command);
|
|
1346
2213
|
const parsed = staticCommands ?? parseStaticShellCommands(command, true);
|
|
1347
2214
|
if (parsed && typeof parsed !== "string") {
|
|
@@ -1439,7 +2306,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
1439
2306
|
throw new Error(
|
|
1440
2307
|
`\u{1F6AB} Command blocked: ${dangerousReason}; this command might expose secrets.\n\n` +
|
|
1441
2308
|
`Command: ${command}\n\n` +
|
|
1442
|
-
`
|
|
2309
|
+
`Use a supported command form or the appropriate wrapper tool.\n\n` +
|
|
1443
2310
|
`Think this is wrong? Review the project repository, adjust the patterns, and submit a PR.`,
|
|
1444
2311
|
);
|
|
1445
2312
|
}
|