@blogic-cz/agent-tools 1.5.7 → 1.5.9
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 +21 -8
- package/dist/az-tool/service.d.ts.map +1 -1
- package/dist/azdo-tool/service.d.ts.map +1 -1
- package/dist/credential-guard/index.d.ts +2 -0
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/dist/db-tool/service.d.ts.map +1 -1
- package/dist/gh-tool/gist.d.ts +13 -0
- package/dist/gh-tool/gist.d.ts.map +1 -1
- package/dist/gh-tool/pr/core.d.ts.map +1 -1
- package/dist/gh-tool/pr/review.d.ts.map +1 -1
- package/dist/gh-tool/release.d.ts +46 -1
- package/dist/gh-tool/release.d.ts.map +1 -1
- package/dist/gh-tool/text-input.d.ts +5 -3
- package/dist/gh-tool/text-input.d.ts.map +1 -1
- package/dist/k8s-tool/service.d.ts.map +1 -1
- package/dist/logs-tool/main.d.ts +1 -2
- package/dist/logs-tool/main.d.ts.map +1 -1
- package/dist/observability-tool/shared.d.ts.map +1 -1
- package/dist/session-tool/main.d.ts +1 -2
- package/dist/session-tool/main.d.ts.map +1 -1
- package/dist/shared/audit.d.ts.map +1 -1
- package/dist/shared/content-security.d.ts +6 -0
- package/dist/shared/content-security.d.ts.map +1 -0
- package/dist/shared/credential-patterns.d.ts +11 -0
- package/dist/shared/credential-patterns.d.ts.map +1 -0
- package/dist/shared/effect-version.d.ts.map +1 -1
- package/dist/shared/error-renderer.d.ts.map +1 -1
- package/dist/shared/format.d.ts.map +1 -1
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/output-boundary.d.ts +3 -0
- package/dist/shared/output-boundary.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/audit-tool/main.ts +3 -1
- package/src/az-tool/main.ts +3 -1
- package/src/az-tool/service.ts +9 -0
- package/src/azdo-tool/main.ts +3 -1
- package/src/azdo-tool/service.ts +17 -0
- package/src/credential-guard/claude-hook.ts +2 -1
- package/src/credential-guard/index.ts +936 -87
- package/src/db-tool/main.ts +3 -1
- package/src/db-tool/service.ts +25 -2
- package/src/gh-tool/gist.ts +63 -64
- package/src/gh-tool/main.ts +3 -1
- package/src/gh-tool/pr/core.ts +10 -3
- package/src/gh-tool/pr/review.ts +199 -12
- package/src/gh-tool/release.ts +104 -29
- package/src/gh-tool/text-input.ts +19 -38
- package/src/k8s-tool/main.ts +3 -1
- package/src/k8s-tool/service.ts +25 -0
- package/src/logs-tool/main.ts +7 -2
- package/src/observability-tool/main.ts +5 -7
- package/src/observability-tool/shared.ts +41 -1
- package/src/session-tool/main.ts +18 -3
- package/src/shared/audit.ts +11 -10
- package/src/shared/content-security.ts +91 -0
- package/src/shared/credential-patterns.ts +62 -0
- package/src/shared/effect-version.ts +2 -1
- package/src/shared/error-renderer.ts +15 -2
- package/src/shared/format.ts +33 -6
- package/src/shared/index.ts +5 -0
- package/src/shared/output-boundary.ts +29 -0
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { CliToolOverride, CredentialGuardConfig } from "#config/types";
|
|
18
|
+
import { redactSensitiveText } from "#shared/content-security";
|
|
19
|
+
import { SECRET_PATTERNS } from "#shared/credential-patterns";
|
|
20
|
+
|
|
21
|
+
export { findSecretMatches } from "#shared/credential-patterns";
|
|
22
|
+
export { redactSensitiveText } from "#shared/content-security";
|
|
18
23
|
|
|
19
24
|
// ============================================================================
|
|
20
25
|
// TYPES
|
|
@@ -84,61 +89,6 @@ const DEFAULT_ALLOWED_PATH_PATTERNS: RegExp[] = [
|
|
|
84
89
|
/\.env\.sample$/,
|
|
85
90
|
];
|
|
86
91
|
|
|
87
|
-
/** Patterns to detect secrets in content. */
|
|
88
|
-
const SECRET_PATTERNS = [
|
|
89
|
-
{
|
|
90
|
-
name: "AWS Access Key",
|
|
91
|
-
pattern: /(?:AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}/,
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
name: "GitHub Token",
|
|
95
|
-
pattern: /gh[ps]_[A-Za-z0-9]{36}/,
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
name: "GitHub PAT",
|
|
99
|
-
pattern: /github_pat_[A-Za-z0-9]{22}_[A-Za-z0-9]{59}/,
|
|
100
|
-
},
|
|
101
|
-
{ name: "OpenAI Key", pattern: /sk-[A-Za-z0-9]{48}/ },
|
|
102
|
-
{
|
|
103
|
-
name: "Generic API Key",
|
|
104
|
-
pattern: /(?:api[_-]?key|apikey)["\s:=]+["']?([A-Za-z0-9_-]{20,})["']?/i,
|
|
105
|
-
},
|
|
106
|
-
{
|
|
107
|
-
name: "Generic Secret",
|
|
108
|
-
pattern:
|
|
109
|
-
/(?:secret|token|password|passwd|pwd)[" \t:=]+["']?(?!\$\{|process\.env|z\.|generate|create|read|get|fetch|import|export|const|function|return|Schema)[^\s"']{32,}["']?/i,
|
|
110
|
-
},
|
|
111
|
-
{
|
|
112
|
-
// eslint-disable-next-line eslint/no-useless-concat -- intentionally split to avoid credential guard self-detection
|
|
113
|
-
name: "Priv" + "ate Key",
|
|
114
|
-
pattern: new RegExp("-----BEGIN.*PRIVATE KEY-----"),
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
name: "JWT Token",
|
|
118
|
-
pattern: /(?:["'=:\s]|^)eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/,
|
|
119
|
-
},
|
|
120
|
-
{
|
|
121
|
-
name: "Azure SAS Token",
|
|
122
|
-
pattern: /[?&]sig=[A-Za-z0-9%+/=]{20,}/,
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
name: "GCP Service Account Key",
|
|
126
|
-
pattern: /"type"\s*:\s*"service_account"/,
|
|
127
|
-
},
|
|
128
|
-
{
|
|
129
|
-
name: "Slack Webhook URL",
|
|
130
|
-
pattern: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/,
|
|
131
|
-
},
|
|
132
|
-
{
|
|
133
|
-
name: "Discord Webhook URL",
|
|
134
|
-
pattern: /https:\/\/discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/,
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
name: "Database URL",
|
|
138
|
-
pattern: /(?:postgres(?:ql)?|mysql|mongodb):\/\/(?!\$\{)[^:]+:(?!\$\{)[^@]+@/,
|
|
139
|
-
},
|
|
140
|
-
];
|
|
141
|
-
|
|
142
92
|
/**
|
|
143
93
|
* CLI tools that must use wrapper tools for security and audit.
|
|
144
94
|
*/
|
|
@@ -464,6 +414,628 @@ function parseStaticShellCommands(
|
|
|
464
414
|
return { pipelines, redirects };
|
|
465
415
|
}
|
|
466
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
|
+
/** JSON values selected by arbitrary keys or printed wholesale have no metadata proof. */
|
|
1001
|
+
function hasUnverifiedJsonValueOutput(code: string): boolean {
|
|
1002
|
+
if (!/\bjson\.loads?\s*\(/.test(code) || !/\bprint\s*\(/.test(code)) return false;
|
|
1003
|
+
if (/\.items\s*\(/.test(code) || /\bprint\s*\(\s*json\.loads?\s*\(/.test(code)) return true;
|
|
1004
|
+
const names = [...code.matchAll(/\b([A-Za-z_]\w*)\s*=\s*json\.loads?\s*\(/g)].map(
|
|
1005
|
+
(match) => match[1] ?? "",
|
|
1006
|
+
);
|
|
1007
|
+
return names.some((name) =>
|
|
1008
|
+
new RegExp(`\\bprint\\s*\\(\\s*(?:json\\.dumps\\s*\\(\\s*)?${name}\\s*[,)]`).test(code),
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function hasInlineProgramExecution(
|
|
1013
|
+
program: string,
|
|
1014
|
+
python: boolean,
|
|
1015
|
+
allowInventory = false,
|
|
1016
|
+
): boolean {
|
|
1017
|
+
const parts = programParts(program, python);
|
|
1018
|
+
if (!parts) return true;
|
|
1019
|
+
if (python && hasUnverifiedJsonValueOutput(parts.code)) return true;
|
|
1020
|
+
if (parts.loadedModules.some((name) => /^(?:node:)?vm$/.test(name))) return true;
|
|
1021
|
+
// Dynamic loading and reflective access cannot establish a non-executing code operand.
|
|
1022
|
+
if (
|
|
1023
|
+
(/\b(?:require|import)\s*\((?!\s*STRING\s*\))/.test(parts.code) &&
|
|
1024
|
+
(!allowInventory || !staticPackageInventory(program))) ||
|
|
1025
|
+
/\[\s*STRING\s*\+/.test(parts.code) ||
|
|
1026
|
+
/\b(?:process|os)\s*\[/.test(parts.code)
|
|
1027
|
+
)
|
|
1028
|
+
return true;
|
|
1029
|
+
const code = staticSubprocessCommand(program)
|
|
1030
|
+
? parts.code.replace(/\bsubprocess\b/g, "")
|
|
1031
|
+
: parts.code;
|
|
1032
|
+
return (
|
|
1033
|
+
/\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(
|
|
1034
|
+
code,
|
|
1035
|
+
) || parts.literals.some((value) => /^(?:node:)?child_process$/.test(value))
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
467
1039
|
function mentionsEnvironmentRead(text: string): boolean {
|
|
468
1040
|
// `--env dev` is a flag of another command, not the env command.
|
|
469
1041
|
return /(?<![\w.-])printenv\b(?!-)|(?<![\w.-])-\w*Oprintenv\b|(?<![\w.-])\benv\b(?!-)/i.test(
|
|
@@ -537,6 +1109,7 @@ function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
|
|
|
537
1109
|
)
|
|
538
1110
|
return false;
|
|
539
1111
|
return (argv[0]?.split("/").at(-1) === "awk" && !isAwkExecution(argv)) ||
|
|
1112
|
+
inlineProgramIndex(argv) !== undefined ||
|
|
540
1113
|
isJqObjectConstruction(argv)
|
|
541
1114
|
? false
|
|
542
1115
|
: hasArgumentBraceExpansion(argv.join(" "));
|
|
@@ -590,6 +1163,11 @@ function isJqObjectConstruction(argv: string[]): boolean {
|
|
|
590
1163
|
if (argv[index] === "--") index++;
|
|
591
1164
|
const filter = argv[index] ?? "";
|
|
592
1165
|
if (argv.slice(index + 1).some((arg) => arg.startsWith("-"))) return false;
|
|
1166
|
+
const expression = filter
|
|
1167
|
+
.replace(/\.[A-Za-z_][A-Za-z0-9_.-]*/g, "FIELD")
|
|
1168
|
+
.replace(/\b[A-Za-z_][A-Za-z0-9_-]*\s*:/g, ":");
|
|
1169
|
+
if (/^\{[\s\S]*\}$/.test(filter) && /^(?:FIELD|length|map|[\s{}():,|>0-9])+$/.test(expression))
|
|
1170
|
+
return true;
|
|
593
1171
|
return (
|
|
594
1172
|
/^\{\s*[A-Za-z_][A-Za-z0-9_-]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_-]*)*\s*\}$/.test(filter) ||
|
|
595
1173
|
/^\{\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(
|
|
@@ -744,7 +1322,7 @@ function isPassiveTextCommand(argv: string[]): boolean {
|
|
|
744
1322
|
const args = argv.slice(1);
|
|
745
1323
|
// These commands consume literal text. Execution options are not exceptions.
|
|
746
1324
|
if (name === "printf") return passivePrintfFormatIndex(argv) !== undefined;
|
|
747
|
-
if (["echo", "grep", "head"].includes(name)) {
|
|
1325
|
+
if (["echo", "grep", "head", "tail"].includes(name)) {
|
|
748
1326
|
return true;
|
|
749
1327
|
}
|
|
750
1328
|
if (name === "rg") return !hasExecutionOption(args, ["pre", "hostname-bin"]);
|
|
@@ -758,7 +1336,11 @@ function isPassiveTextCommand(argv: string[]): boolean {
|
|
|
758
1336
|
);
|
|
759
1337
|
}
|
|
760
1338
|
|
|
761
|
-
function hasStaticEnvironmentRead(
|
|
1339
|
+
function hasStaticEnvironmentRead(
|
|
1340
|
+
argv: string[],
|
|
1341
|
+
allowedNames: Set<string>,
|
|
1342
|
+
allowInventory = false,
|
|
1343
|
+
): boolean {
|
|
762
1344
|
const name = argv[0]?.split("/").at(-1);
|
|
763
1345
|
if (
|
|
764
1346
|
["sh", "bash", "zsh"].includes(name ?? "") &&
|
|
@@ -767,17 +1349,49 @@ function hasStaticEnvironmentRead(argv: string[], allowedNames: Set<string>): bo
|
|
|
767
1349
|
) {
|
|
768
1350
|
return true;
|
|
769
1351
|
}
|
|
1352
|
+
if (
|
|
1353
|
+
name === "docker" &&
|
|
1354
|
+
argv.some((arg) => ["sh", "bash", "zsh"].includes(arg)) &&
|
|
1355
|
+
argv.some((arg) => /[$`]/.test(arg))
|
|
1356
|
+
)
|
|
1357
|
+
return true;
|
|
770
1358
|
if (name === "awk" && argv.slice(1).some((arg) => /\bENVIRON\b/.test(arg))) return true;
|
|
771
1359
|
if (name === "printenv") return !isAllowedEnvironmentRead(argv, allowedNames);
|
|
772
1360
|
if (name === "env") {
|
|
773
1361
|
const wrapped = unwrapEnvironmentCommand(argv);
|
|
774
1362
|
return wrapped === null
|
|
775
1363
|
? isEnvironmentListing(argv)
|
|
776
|
-
: hasStaticEnvironmentRead(unwrapStaticCommand(wrapped), allowedNames);
|
|
1364
|
+
: hasStaticEnvironmentRead(unwrapStaticCommand(wrapped), allowedNames, allowInventory);
|
|
777
1365
|
}
|
|
1366
|
+
if (isUnsupportedInlineRuntime(argv)) return true;
|
|
1367
|
+
const programIndex = inlineProgramIndex(argv);
|
|
1368
|
+
if (programIndex !== undefined)
|
|
1369
|
+
return hasInlineProgramExecution(
|
|
1370
|
+
argv[programIndex] ?? "",
|
|
1371
|
+
inlineRuntimeKind(argv[0]) === "python",
|
|
1372
|
+
allowInventory && inlineRuntimeKind(argv[0]) === "node",
|
|
1373
|
+
);
|
|
778
1374
|
if (isPassiveTextCommand(argv)) return false;
|
|
779
1375
|
// Any command may re-parse an argument as shell code (trap, find -exec, awk system()).
|
|
780
|
-
return
|
|
1376
|
+
return (
|
|
1377
|
+
hasCommandArgumentBraceExpansion(argv) ||
|
|
1378
|
+
([
|
|
1379
|
+
"sh",
|
|
1380
|
+
"bash",
|
|
1381
|
+
"zsh",
|
|
1382
|
+
"eval",
|
|
1383
|
+
"source",
|
|
1384
|
+
"find",
|
|
1385
|
+
"xargs",
|
|
1386
|
+
"trap",
|
|
1387
|
+
"awk",
|
|
1388
|
+
"git",
|
|
1389
|
+
"sort",
|
|
1390
|
+
"jq",
|
|
1391
|
+
"docker",
|
|
1392
|
+
].includes(name ?? "") &&
|
|
1393
|
+
mentionsEnvironmentRead(argv.join(" ")))
|
|
1394
|
+
);
|
|
781
1395
|
}
|
|
782
1396
|
|
|
783
1397
|
function isStaticHerdrPrompt(command: string): boolean {
|
|
@@ -801,7 +1415,11 @@ function isHerdrPrompt(command: string): boolean {
|
|
|
801
1415
|
return /^herdr\s+agent\s+prompt\b/.test(command.trim());
|
|
802
1416
|
}
|
|
803
1417
|
|
|
804
|
-
function hasEnvironmentRead(
|
|
1418
|
+
function hasEnvironmentRead(
|
|
1419
|
+
command: string,
|
|
1420
|
+
allowedNames: Set<string>,
|
|
1421
|
+
allowInventory = hasProvedPackageInventoryInvocation(command),
|
|
1422
|
+
): boolean {
|
|
805
1423
|
if (isStaticHerdrPrompt(command)) return false;
|
|
806
1424
|
if (isHerdrPrompt(command)) return true;
|
|
807
1425
|
if (isLiteralTextWrite(command)) return false;
|
|
@@ -839,23 +1457,24 @@ function hasEnvironmentRead(command: string, allowedNames: Set<string>): boolean
|
|
|
839
1457
|
) {
|
|
840
1458
|
return true;
|
|
841
1459
|
}
|
|
842
|
-
if (unwrapped.some((argv) => hasStaticEnvironmentRead(argv, allowedNames))) {
|
|
1460
|
+
if (unwrapped.some((argv) => hasStaticEnvironmentRead(argv, allowedNames, allowInventory))) {
|
|
843
1461
|
return true;
|
|
844
1462
|
}
|
|
845
1463
|
|
|
846
1464
|
// A literal producer can feed executable text to a shell, xargs, or an unknown consumer.
|
|
847
|
-
const result = pipelines.some(
|
|
848
|
-
(
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
(argv) =>
|
|
852
|
-
isAllowedEnvironmentRead(argv, allowedNames) ||
|
|
1465
|
+
const result = pipelines.some((pipeline) =>
|
|
1466
|
+
pipeline.some(
|
|
1467
|
+
(argv, index) =>
|
|
1468
|
+
(isAllowedEnvironmentRead(argv, allowedNames) ||
|
|
853
1469
|
(isLiteralTextProducer(argv) &&
|
|
854
|
-
(mentionsEnvironmentRead(argv.join(" ")) || hasCommandArgumentBraceExpansion(argv)))
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1470
|
+
(mentionsEnvironmentRead(argv.join(" ")) || hasCommandArgumentBraceExpansion(argv)))) &&
|
|
1471
|
+
pipeline
|
|
1472
|
+
.slice(index + 1)
|
|
1473
|
+
.some(
|
|
1474
|
+
(consumer) =>
|
|
1475
|
+
!isPassiveTextCommand(consumer) && !isAllowedEnvironmentRead(consumer, allowedNames),
|
|
1476
|
+
),
|
|
1477
|
+
),
|
|
859
1478
|
);
|
|
860
1479
|
return result;
|
|
861
1480
|
}
|
|
@@ -947,8 +1566,21 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
947
1566
|
const isSensitivePath = (text: string) =>
|
|
948
1567
|
sensitivePath.test(text) ||
|
|
949
1568
|
sensitiveCamelCase.test(text) ||
|
|
950
|
-
(sensitiveName.test(text) &&
|
|
1569
|
+
(sensitiveName.test(text) &&
|
|
1570
|
+
!/(?:^|\/)credential-guard(?:(?:-[^/]+)?\.(?:ts|md|js)|\*|\/(?:index\.(?:ts|js)))?$/.test(
|
|
1571
|
+
text,
|
|
1572
|
+
));
|
|
951
1573
|
if (/(?:^|[\s;&|])--env-file(?:=|\s)[^;&|]*\.env\b/i.test(command)) return true;
|
|
1574
|
+
const heredoc = boundedHeredoc(command);
|
|
1575
|
+
if (
|
|
1576
|
+
heredoc &&
|
|
1577
|
+
(isLiteralTextWrite(command) ||
|
|
1578
|
+
(isLiteralTextWrite(command.slice(0, command.length - heredoc.following.length).trimEnd()) &&
|
|
1579
|
+
!mentionsInlineRuntime(heredoc.following) &&
|
|
1580
|
+
!/\b(?:sh|bash|zsh|eval|source|xargs)\b/.test(heredoc.following)))
|
|
1581
|
+
) {
|
|
1582
|
+
return hasSensitiveFileRead(heredoc.header + "\n" + heredoc.following, isPathBlocked);
|
|
1583
|
+
}
|
|
952
1584
|
if (/\bcat\b[^;&|]*<<-?\s*\S+[\s\S]*\b(?:secret|credential)\b/i.test(command)) return true;
|
|
953
1585
|
const parsed = parseStaticShellCommands(command);
|
|
954
1586
|
const readers =
|
|
@@ -972,13 +1604,47 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
972
1604
|
return path !== undefined && (isPathBlocked(path) || isSensitivePath(path));
|
|
973
1605
|
});
|
|
974
1606
|
}
|
|
1607
|
+
const programIndex = inlineProgramIndex(inspected);
|
|
1608
|
+
if (programIndex !== undefined) {
|
|
1609
|
+
const parts = programParts(
|
|
1610
|
+
inspected[programIndex] ?? "",
|
|
1611
|
+
inlineRuntimeKind(inspected[0]) === "python",
|
|
1612
|
+
);
|
|
1613
|
+
if (!parts) return true;
|
|
1614
|
+
const inventory = staticPackageInventory(inspected[programIndex] ?? "");
|
|
1615
|
+
if (inventory?.some((path) => isPathBlocked(path) || isSensitivePath(path))) return true;
|
|
1616
|
+
if (parts.code.split(/[\s'"`()<>;&|]+/).some(isPathBlocked)) return true;
|
|
1617
|
+
const accessesFiles =
|
|
1618
|
+
/\b(?:open|Path|read_text|read_bytes|readFile|readFileSync|file|write_text|writeFile|writeFileSync)\b/.test(
|
|
1619
|
+
parts.code,
|
|
1620
|
+
) || /\bBun\s*\.\s*write\s*\(/.test(parts.code);
|
|
1621
|
+
if (
|
|
1622
|
+
accessesFiles &&
|
|
1623
|
+
/\b(?:sys|process)\s*\.\s*argv\b/.test(parts.code) &&
|
|
1624
|
+
inspected
|
|
1625
|
+
.slice(programIndex + 1)
|
|
1626
|
+
.some((arg) => isPathBlocked(arg) || isSensitivePath(arg))
|
|
1627
|
+
)
|
|
1628
|
+
return true;
|
|
1629
|
+
// Interpolation expressions remain in code; inspect their nested literal operands too.
|
|
1630
|
+
const expressionParts = /['"]/.test(parts.code)
|
|
1631
|
+
? programParts(parts.code, inlineRuntimeKind(inspected[0]) === "python")
|
|
1632
|
+
: { pathLiterals: [] };
|
|
1633
|
+
if (!expressionParts) return true;
|
|
1634
|
+
return [...parts.pathLiterals, ...expressionParts.pathLiterals].some(
|
|
1635
|
+
(value) =>
|
|
1636
|
+
(accessesFiles && isPathBlocked(value)) ||
|
|
1637
|
+
(!/\s/.test(value) && /[/.]/.test(value) && isSensitivePath(value)),
|
|
1638
|
+
);
|
|
1639
|
+
}
|
|
975
1640
|
if (!readers.test(name)) return false;
|
|
976
1641
|
if (name === "rg" || name === "grep") {
|
|
977
1642
|
const args = inspected.slice(1);
|
|
978
1643
|
const paths: string[] = [];
|
|
979
1644
|
let hasPattern = false;
|
|
980
1645
|
let options = true;
|
|
981
|
-
|
|
1646
|
+
const enumeratesFiles = name === "rg" && args.includes("--files");
|
|
1647
|
+
let filesOnly = enumeratesFiles;
|
|
982
1648
|
for (let i = 0; i < args.length; i++) {
|
|
983
1649
|
const arg = args[i] ?? "";
|
|
984
1650
|
if (options && arg === "--") {
|
|
@@ -1016,7 +1682,11 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
1016
1682
|
: arg.startsWith("--glob=")
|
|
1017
1683
|
? arg.slice(7)
|
|
1018
1684
|
: arg.slice(2);
|
|
1019
|
-
if (
|
|
1685
|
+
if (
|
|
1686
|
+
!enumeratesFiles &&
|
|
1687
|
+
(isSensitivePath(glob) || isPathBlocked(glob.replaceAll("*", "")))
|
|
1688
|
+
)
|
|
1689
|
+
paths.push(glob);
|
|
1020
1690
|
continue;
|
|
1021
1691
|
}
|
|
1022
1692
|
if (
|
|
@@ -1027,8 +1697,9 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
1027
1697
|
if (arg === "-f" || arg === "--file") paths.push(value ?? "");
|
|
1028
1698
|
if (
|
|
1029
1699
|
(arg === "-g" || arg === "--glob" || arg === "--iglob" || arg === "--include") &&
|
|
1700
|
+
!enumeratesFiles &&
|
|
1030
1701
|
value &&
|
|
1031
|
-
isSensitivePath(value)
|
|
1702
|
+
(isSensitivePath(value) || isPathBlocked(value.replaceAll("*", "")))
|
|
1032
1703
|
)
|
|
1033
1704
|
paths.push(value);
|
|
1034
1705
|
if (arg === "-e" || arg === "--regexp" || arg === "-f" || arg === "--file")
|
|
@@ -1079,7 +1750,7 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
1079
1750
|
return true;
|
|
1080
1751
|
if (readers.test(command) && command.split(/[\s'"`()<>;&|]+/).some(isPathBlocked)) return true;
|
|
1081
1752
|
const unquoted = command.replace(/'(?:\\.|[^'])*'/g, " ").replace(/"(?:\\.|[^"$`])*"/g, " ");
|
|
1082
|
-
if (
|
|
1753
|
+
if ((mentionsInlineRuntime(command) || /\bruby\b/i.test(command)) && isSensitivePath(command)) {
|
|
1083
1754
|
return true;
|
|
1084
1755
|
}
|
|
1085
1756
|
return (
|
|
@@ -1101,7 +1772,7 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
1101
1772
|
command = [heredoc.header, heredoc.following].join("\n");
|
|
1102
1773
|
heredoc = boundedHeredoc(command);
|
|
1103
1774
|
}
|
|
1104
|
-
if (!command.includes("$")) return false;
|
|
1775
|
+
if (!command.includes("$") && !command.includes("`")) return false;
|
|
1105
1776
|
let invalidExpansion = false;
|
|
1106
1777
|
const assignments = getLeadingLiteralAssignments(command);
|
|
1107
1778
|
const localValues = new Map<string, string>();
|
|
@@ -1137,6 +1808,7 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
1137
1808
|
quote = quote === '"' ? undefined : '"';
|
|
1138
1809
|
continue;
|
|
1139
1810
|
}
|
|
1811
|
+
if (char === "`") return true;
|
|
1140
1812
|
if (char !== "$") {
|
|
1141
1813
|
normalized += char;
|
|
1142
1814
|
continue;
|
|
@@ -1234,13 +1906,129 @@ function hasEnvironmentVariableExpansionRead(
|
|
|
1234
1906
|
return hasSensitiveFileRead(materialized, isPathBlocked);
|
|
1235
1907
|
}
|
|
1236
1908
|
|
|
1237
|
-
function
|
|
1238
|
-
return (
|
|
1239
|
-
|
|
1240
|
-
/\b(?:python3?|node|bun)\b/.test(command)
|
|
1909
|
+
function mentionsEnvironmentSource(command: string): boolean {
|
|
1910
|
+
return /\b(?:os\s*\.\s*(?:environ|getenv)|process\s*\.\s*env|Environment\s*\.\s*GetEnvironmentVariable)\b/.test(
|
|
1911
|
+
command,
|
|
1241
1912
|
);
|
|
1242
1913
|
}
|
|
1243
1914
|
|
|
1915
|
+
/** Closed raw-source grammar: literal display/write only, without projection or evaluation. */
|
|
1916
|
+
function isLiteralInlineWriter(argv: string[]): boolean {
|
|
1917
|
+
const index = inlineProgramIndex(argv);
|
|
1918
|
+
if (index === undefined || argv.length !== index + 1) return false;
|
|
1919
|
+
const kind = inlineRuntimeKind(argv[0]);
|
|
1920
|
+
const python = kind === "python";
|
|
1921
|
+
const program = (argv[index] ?? "").trim();
|
|
1922
|
+
let position = 0;
|
|
1923
|
+
const take = (pattern: RegExp): boolean => {
|
|
1924
|
+
const match = pattern.exec(program.slice(position));
|
|
1925
|
+
if (!match) return false;
|
|
1926
|
+
position += match[0].length;
|
|
1927
|
+
return true;
|
|
1928
|
+
};
|
|
1929
|
+
const literal = (): string | undefined => {
|
|
1930
|
+
take(/^\s*/);
|
|
1931
|
+
const start = position;
|
|
1932
|
+
const quote = program[position];
|
|
1933
|
+
if (quote !== "'" && quote !== '"') return undefined;
|
|
1934
|
+
const delimiter =
|
|
1935
|
+
python && program.startsWith(quote.repeat(3), position) ? quote.repeat(3) : quote;
|
|
1936
|
+
position += delimiter.length;
|
|
1937
|
+
while (position < program.length) {
|
|
1938
|
+
if (program.startsWith(delimiter, position)) {
|
|
1939
|
+
position += delimiter.length;
|
|
1940
|
+
return program.slice(start, position);
|
|
1941
|
+
}
|
|
1942
|
+
if (program[position] === "\\") position += 2;
|
|
1943
|
+
else if (delimiter.length === 1 && /[\r\n\u2028\u2029]/.test(program[position] ?? ""))
|
|
1944
|
+
return undefined;
|
|
1945
|
+
else position++;
|
|
1946
|
+
}
|
|
1947
|
+
return undefined;
|
|
1948
|
+
};
|
|
1949
|
+
let prefix: RegExp;
|
|
1950
|
+
let middle: RegExp | undefined;
|
|
1951
|
+
let mode = false;
|
|
1952
|
+
if (python && /^print\s*\(/.test(program)) prefix = /^print\s*\(\s*/;
|
|
1953
|
+
else if (!python && /^console\s*\.\s*log\s*\(/.test(program))
|
|
1954
|
+
prefix = /^console\s*\.\s*log\s*\(\s*/;
|
|
1955
|
+
else if (python && /^open\s*\(/.test(program)) {
|
|
1956
|
+
prefix = /^open\s*\(\s*/;
|
|
1957
|
+
mode = true;
|
|
1958
|
+
middle = /^\s*\)\s*\.\s*write\s*\(\s*/;
|
|
1959
|
+
} else if (kind === "bun" && /^Bun\s*\.\s*write\s*\(/.test(program)) {
|
|
1960
|
+
prefix = /^Bun\s*\.\s*write\s*\(\s*/;
|
|
1961
|
+
middle = /^\s*,\s*/;
|
|
1962
|
+
} else if (python) {
|
|
1963
|
+
prefix = /^from[ \t]+pathlib[ \t]+import[ \t]+Path[ \t]*(?:;|\r?\n)\s*Path\s*\(\s*/;
|
|
1964
|
+
middle = /^\s*\)\s*\.\s*write_text\s*\(\s*/;
|
|
1965
|
+
} else return false;
|
|
1966
|
+
if (!take(prefix) || literal() === undefined) return false;
|
|
1967
|
+
if (mode) {
|
|
1968
|
+
if (!take(/^\s*,\s*/)) return false;
|
|
1969
|
+
const value = literal();
|
|
1970
|
+
if (value !== "'w'" && value !== '"w"') return false;
|
|
1971
|
+
}
|
|
1972
|
+
if (middle && (!take(middle) || literal() === undefined)) return false;
|
|
1973
|
+
return take(/^\s*\)\s*;?\s*$/) && position === program.length;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/** Inert command identity is unproved after an explicit environment mutation. */
|
|
1977
|
+
function hasEnvironmentMutationPrefix(command: string): boolean {
|
|
1978
|
+
const heredoc = boundedHeredoc(command);
|
|
1979
|
+
if (heredoc) {
|
|
1980
|
+
if (!heredoc.closed) return true;
|
|
1981
|
+
return hasEnvironmentMutationPrefix(heredoc.header + "\n" + heredoc.following);
|
|
1982
|
+
}
|
|
1983
|
+
const parsed = parseStaticShellCommands(command);
|
|
1984
|
+
if (!parsed || typeof parsed === "string") return true;
|
|
1985
|
+
return parsed.pipelines.flat().some((words) => {
|
|
1986
|
+
let index = 0;
|
|
1987
|
+
while (index < words.length) {
|
|
1988
|
+
const word = words[index] ?? "";
|
|
1989
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) return true;
|
|
1990
|
+
const name = word.split("/").at(-1);
|
|
1991
|
+
if (name === "env") {
|
|
1992
|
+
index++;
|
|
1993
|
+
if (words[index] === "--") index++;
|
|
1994
|
+
if (words[index]?.startsWith("-")) return true;
|
|
1995
|
+
} else if (name === "rtk" || name === "command") {
|
|
1996
|
+
index++;
|
|
1997
|
+
if ((name === "rtk" && words[index] === "proxy") || words[index] === "--") index++;
|
|
1998
|
+
} else return false;
|
|
1999
|
+
}
|
|
2000
|
+
return false;
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
function hasEnvironmentSourceAccess(command: string, rawTrigger: boolean): boolean {
|
|
2005
|
+
if (rawTrigger && hasEnvironmentMutationPrefix(command)) return true;
|
|
2006
|
+
if (isLiteralTextWrite(command) || isStaticHerdrPrompt(command)) return false;
|
|
2007
|
+
const parsed = parseStaticShellCommands(command);
|
|
2008
|
+
if (!parsed || typeof parsed === "string") return rawTrigger;
|
|
2009
|
+
const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
|
|
2010
|
+
if (rawTrigger) {
|
|
2011
|
+
// Original invocation-wide evidence survives split writers and inline quote projection.
|
|
2012
|
+
return !commands.every(
|
|
2013
|
+
(argv) =>
|
|
2014
|
+
argv[0]?.split("/").at(-1) === "cat" ||
|
|
2015
|
+
isPassiveTextCommand(argv) ||
|
|
2016
|
+
isLiteralInlineWriter(argv),
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
return commands.some((argv) => {
|
|
2020
|
+
const index = inlineProgramIndex(argv);
|
|
2021
|
+
if (index === undefined) return false;
|
|
2022
|
+
const parts = programParts(argv[index] ?? "", inlineRuntimeKind(argv[0]) === "python");
|
|
2023
|
+
if (!parts) return true;
|
|
2024
|
+
return (
|
|
2025
|
+
mentionsEnvironmentSource(parts.code) ||
|
|
2026
|
+
(/\b(?:os|process)\s*\[/.test(parts.code) &&
|
|
2027
|
+
parts.literals.some((value) => /^(?:env|environ|getenv)$/.test(value)))
|
|
2028
|
+
);
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
|
|
1244
2032
|
/** Extract file path from hook arguments. */
|
|
1245
2033
|
export function extractFilePath(args: Record<string, unknown>): string {
|
|
1246
2034
|
return (args.filePath as string) || (args.file_path as string) || (args.path as string) || "";
|
|
@@ -1344,6 +2132,29 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
1344
2132
|
}
|
|
1345
2133
|
|
|
1346
2134
|
function getDangerousBashReason(command: string): string | null {
|
|
2135
|
+
const originalCommand = command;
|
|
2136
|
+
const allowPackageInventory = hasProvedPackageInventoryInvocation(originalCommand);
|
|
2137
|
+
const rawEnvironmentSource =
|
|
2138
|
+
mentionsEnvironmentSource(command) && mentionsInlineRuntime(command);
|
|
2139
|
+
if (rawEnvironmentSource && hasEnvironmentMutationPrefix(originalCommand))
|
|
2140
|
+
return "reads process environment after an unproved environment mutation";
|
|
2141
|
+
command = normalizeProgramHeredocs(command);
|
|
2142
|
+
const proof = materializeLiteralShell(command);
|
|
2143
|
+
if (!proof && /^\s*for\b/.test(command)) return "cannot prove a bounded literal shell loop";
|
|
2144
|
+
if (proof) {
|
|
2145
|
+
if (proof.paths.some((path) => hasSensitiveFileRead("cat " + path, isPathBlocked)))
|
|
2146
|
+
return "accesses a sensitive file path";
|
|
2147
|
+
command = proof.command;
|
|
2148
|
+
if (getBlockedCliTool(command)) return "invokes a blocked CLI from a literal expansion";
|
|
2149
|
+
}
|
|
2150
|
+
for (const nested of [
|
|
2151
|
+
...inlineSubprocessCommands(command),
|
|
2152
|
+
...inlinePackageCommands(command, allowPackageInventory),
|
|
2153
|
+
]) {
|
|
2154
|
+
const reason = getDangerousBashReason(nested);
|
|
2155
|
+
if (reason || getBlockedCliTool(nested))
|
|
2156
|
+
return reason ?? "invokes a blocked CLI from a script";
|
|
2157
|
+
}
|
|
1347
2158
|
if (
|
|
1348
2159
|
hasSensitiveFileRead(command, isPathBlocked) ||
|
|
1349
2160
|
hasSensitivePathRedirect(command, isPathBlocked)
|
|
@@ -1356,11 +2167,16 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
1356
2167
|
) {
|
|
1357
2168
|
return "expands an unapproved or executable environment variable value";
|
|
1358
2169
|
}
|
|
1359
|
-
if (hasEnvironmentSourceAccess(command))
|
|
1360
|
-
|
|
2170
|
+
if (hasEnvironmentSourceAccess(command, rawEnvironmentSource))
|
|
2171
|
+
return "reads process environment from a script";
|
|
2172
|
+
if (hasEnvironmentRead(command, allowedEnvironmentVariables, allowPackageInventory)) {
|
|
1361
2173
|
return "reads environment variables or passes them to an executor";
|
|
1362
2174
|
}
|
|
1363
|
-
if (
|
|
2175
|
+
if (
|
|
2176
|
+
dangerousBashPatterns.some(
|
|
2177
|
+
(pattern) => pattern.test(originalCommand) || pattern.test(command),
|
|
2178
|
+
)
|
|
2179
|
+
) {
|
|
1364
2180
|
return "matches a configured dangerous-command pattern";
|
|
1365
2181
|
}
|
|
1366
2182
|
return null;
|
|
@@ -1391,7 +2207,21 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
1391
2207
|
return null;
|
|
1392
2208
|
}
|
|
1393
2209
|
|
|
1394
|
-
function getBlockedCliTool(
|
|
2210
|
+
function getBlockedCliTool(
|
|
2211
|
+
command: string,
|
|
2212
|
+
expand = true,
|
|
2213
|
+
): { name: string; wrapper: string } | null {
|
|
2214
|
+
command = normalizeProgramHeredocs(command);
|
|
2215
|
+
const proof = expand ? materializeLiteralShell(command) : undefined;
|
|
2216
|
+
if (proof) {
|
|
2217
|
+
const originalBlocked = getBlockedCliTool(command, false);
|
|
2218
|
+
if (originalBlocked) return originalBlocked;
|
|
2219
|
+
command = proof.command;
|
|
2220
|
+
}
|
|
2221
|
+
for (const nested of inlineSubprocessCommands(command)) {
|
|
2222
|
+
const blocked = getBlockedCliTool(nested);
|
|
2223
|
+
if (blocked) return blocked;
|
|
2224
|
+
}
|
|
1395
2225
|
const staticCommands = parseStaticShellCommands(command);
|
|
1396
2226
|
const parsed = staticCommands ?? parseStaticShellCommands(command, true);
|
|
1397
2227
|
if (parsed && typeof parsed !== "string") {
|
|
@@ -1425,6 +2255,25 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
1425
2255
|
}
|
|
1426
2256
|
|
|
1427
2257
|
function handleToolExecuteBefore(input: HookInput, output: HookOutput): void {
|
|
2258
|
+
try {
|
|
2259
|
+
enforceToolExecuteBefore(input, output);
|
|
2260
|
+
} catch (error) {
|
|
2261
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2262
|
+
const sanitized = redactSensitiveText(message);
|
|
2263
|
+
if (error instanceof Error) {
|
|
2264
|
+
if (sanitized !== message) {
|
|
2265
|
+
error.message = sanitized;
|
|
2266
|
+
if (error.stack) error.stack = error.stack.replace(message, sanitized);
|
|
2267
|
+
}
|
|
2268
|
+
throw error;
|
|
2269
|
+
}
|
|
2270
|
+
// Do not preserve an unsanitized non-Error value as the new error's cause.
|
|
2271
|
+
// eslint-disable-next-line eslint/preserve-caught-error
|
|
2272
|
+
throw new Error(sanitized);
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
function enforceToolExecuteBefore(input: HookInput, output: HookOutput): void {
|
|
1428
2277
|
// Normalize tool name across platforms:
|
|
1429
2278
|
// - AI coding agents pass capitalized: "Bash", "Read", "Write", "Edit"
|
|
1430
2279
|
// - OpenCode MCP tools pass prefixed: "mcp_bash", "mcp_read", "mcp_write", "mcp_edit"
|
|
@@ -1453,7 +2302,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
1453
2302
|
if (detected) {
|
|
1454
2303
|
throw new Error(
|
|
1455
2304
|
`\u{1F6AB} Secret detected: Potential ${detected.name} found in content.\n\n` +
|
|
1456
|
-
`Matched:
|
|
2305
|
+
`Matched: [REDACTED]\n\n` +
|
|
1457
2306
|
`Never commit secrets to code. Use environment variables or secret managers.\n\n` +
|
|
1458
2307
|
`Think this is a false positive? Review the project repository, fix the pattern, and submit a PR.`,
|
|
1459
2308
|
);
|