@blogic-cz/agent-tools 1.6.17 → 1.6.19
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 +32 -2
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/dist/credential-guard/passive-jq.d.ts +5 -0
- package/dist/credential-guard/passive-jq.d.ts.map +1 -0
- package/dist/gh-tool/pr/commands.d.ts +1 -0
- package/dist/gh-tool/pr/commands.d.ts.map +1 -1
- package/dist/gh-tool/pr/core.d.ts +1 -0
- package/dist/gh-tool/pr/core.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/credential-guard/index.ts +590 -74
- package/src/credential-guard/passive-jq.ts +159 -0
- package/src/gh-tool/pr/commands.ts +6 -1
- package/src/gh-tool/pr/core.ts +60 -8
|
@@ -18,6 +18,7 @@ import type { CliToolOverride, CredentialGuardConfig } from "#config/types";
|
|
|
18
18
|
import { redactSensitiveText } from "#shared/content-security";
|
|
19
19
|
import { SECRET_PATTERNS } from "#shared/credential-patterns";
|
|
20
20
|
import { posix } from "node:path";
|
|
21
|
+
import { passiveJqProgramIndex } from "./passive-jq";
|
|
21
22
|
|
|
22
23
|
export { findSecretMatches } from "#shared/credential-patterns";
|
|
23
24
|
export { redactSensitiveText } from "#shared/content-security";
|
|
@@ -76,7 +77,8 @@ const DEFAULT_BLOCKED_PATH_PATTERNS: RegExp[] = [
|
|
|
76
77
|
/\.aws\//,
|
|
77
78
|
/\.ssh\//,
|
|
78
79
|
/\.kube\//,
|
|
79
|
-
|
|
80
|
+
// Every kubeconfig segment is protected except a final non-prefix Markdown prose basename.
|
|
81
|
+
/(?:^|\/)(?:kubeconfig[^/]*|(?![^/]+\.md(?![\s\S]))[^/]*kubeconfig[^/]*)(?:\/|$)/i,
|
|
80
82
|
/\.sentryclirc$/,
|
|
81
83
|
];
|
|
82
84
|
|
|
@@ -273,6 +275,14 @@ function isSafeLocalAssignmentCommand(words: string[]): boolean {
|
|
|
273
275
|
);
|
|
274
276
|
}
|
|
275
277
|
|
|
278
|
+
function isChecksumCommand(argv: string[]): boolean {
|
|
279
|
+
return (
|
|
280
|
+
["shasum", "sha1sum", "sha256sum", "sha512sum", "md5sum", "cksum"].includes(
|
|
281
|
+
argv[0]?.split("/").at(-1) ?? "",
|
|
282
|
+
) && !hasExecutionOption(argv.slice(1), ["check"], "c")
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
276
286
|
function hasSensitivePathRedirect(
|
|
277
287
|
command: string,
|
|
278
288
|
isPathBlocked: (path: string) => boolean,
|
|
@@ -311,8 +321,13 @@ function escapeRegex(s: string): string {
|
|
|
311
321
|
function parseStaticShellCommands(
|
|
312
322
|
command: string,
|
|
313
323
|
literalOperandsOnly = false,
|
|
324
|
+
onUnterminatedQuote?: () => void,
|
|
314
325
|
):
|
|
315
|
-
| {
|
|
326
|
+
| {
|
|
327
|
+
pipelines: string[][][];
|
|
328
|
+
redirects: { target: string; dynamic: boolean; pipeline: number }[];
|
|
329
|
+
pathnameExpansion: boolean;
|
|
330
|
+
}
|
|
316
331
|
| "brace-expansion"
|
|
317
332
|
| undefined {
|
|
318
333
|
const pipelines: string[][][] = [];
|
|
@@ -324,6 +339,7 @@ function parseStaticShellCommands(
|
|
|
324
339
|
let word = "";
|
|
325
340
|
let started = false;
|
|
326
341
|
let quote: "'" | '"' | undefined;
|
|
342
|
+
let pathnameExpansion = false;
|
|
327
343
|
|
|
328
344
|
const finishWord = () => {
|
|
329
345
|
if (started) {
|
|
@@ -406,15 +422,19 @@ function parseStaticShellCommands(
|
|
|
406
422
|
} else if (/\s/.test(char)) {
|
|
407
423
|
finishWord();
|
|
408
424
|
} else {
|
|
425
|
+
if (/[*?[\]]/.test(char) || (char === "~" && !started)) pathnameExpansion = true;
|
|
409
426
|
word += char;
|
|
410
427
|
started = true;
|
|
411
428
|
}
|
|
412
429
|
}
|
|
413
430
|
|
|
414
|
-
if (quote && !literalOperandsOnly)
|
|
431
|
+
if (quote && !literalOperandsOnly) {
|
|
432
|
+
onUnterminatedQuote?.();
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
415
435
|
finishPipeline();
|
|
416
436
|
if (redirect) return undefined;
|
|
417
|
-
return { pipelines, redirects };
|
|
437
|
+
return { pipelines, redirects, pathnameExpansion };
|
|
418
438
|
}
|
|
419
439
|
|
|
420
440
|
/** A lexical binding proof, preserving shell quotes and rejecting every other expansion. */
|
|
@@ -492,6 +512,11 @@ function isLiteralLoopCommand(words: string[], marker: string, value: string): b
|
|
|
492
512
|
const argv = marked.map((word) => word.replaceAll(marker, () => value));
|
|
493
513
|
const name = argv[0];
|
|
494
514
|
const uses = marked.flatMap((word, index) => (word.includes(marker) ? [index] : []));
|
|
515
|
+
const jqProgram = passiveJqProgramIndex(argv);
|
|
516
|
+
if (jqProgram !== undefined)
|
|
517
|
+
return uses.every(
|
|
518
|
+
(index) => index > jqProgram && /^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(argv[index] ?? ""),
|
|
519
|
+
);
|
|
495
520
|
if (name === "cat")
|
|
496
521
|
return (
|
|
497
522
|
argv.slice(1).every((word) => word === "--" || !word.startsWith("-")) &&
|
|
@@ -503,15 +528,33 @@ function isLiteralLoopCommand(words: string[], marker: string, value: string): b
|
|
|
503
528
|
return format !== undefined && uses.every((index) => index > format);
|
|
504
529
|
}
|
|
505
530
|
if (name === "herdr") return isLiteralHerdrRead(argv) && uses.every((index) => index === 3);
|
|
506
|
-
if (name === "bun")
|
|
531
|
+
if (name === "bun") {
|
|
532
|
+
if (argv.slice(0, 4).join(" ") !== "bun run gh-tool pr") return false;
|
|
533
|
+
if (!["checks", "checks-failed", "info", "review-triage"].includes(argv[4] ?? "")) return false;
|
|
534
|
+
let selector = -1;
|
|
535
|
+
for (let index = 5; index < argv.length; index += 2) {
|
|
536
|
+
if (argv[index] === "--repo" && /^[A-Za-z0-9_./-]+$/.test(argv[index + 1] ?? "")) continue;
|
|
537
|
+
if (argv[index] !== "--pr" || selector !== -1 || !/^\d+$/.test(argv[index + 1] ?? ""))
|
|
538
|
+
return false;
|
|
539
|
+
selector = index + 1;
|
|
540
|
+
}
|
|
541
|
+
return selector !== -1 && uses.every((index) => index === selector && marked[index] === marker);
|
|
542
|
+
}
|
|
543
|
+
if (name === "git") {
|
|
544
|
+
const boundary = argv.indexOf("--", 2);
|
|
507
545
|
return (
|
|
508
|
-
argv
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
546
|
+
argv[1] === "diff" &&
|
|
547
|
+
(boundary === 2 || boundary === 3) &&
|
|
548
|
+
argv
|
|
549
|
+
.slice(2, boundary)
|
|
550
|
+
.every((revision) => /^[A-Za-z0-9_][A-Za-z0-9_./-]*$/.test(revision)) &&
|
|
551
|
+
argv.length > boundary + 1 &&
|
|
552
|
+
argv.slice(boundary + 1).every((file) => /^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(file)) &&
|
|
553
|
+
uses.every(
|
|
554
|
+
(index) => index > boundary && /^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(argv[index] ?? ""),
|
|
555
|
+
)
|
|
514
556
|
);
|
|
557
|
+
}
|
|
515
558
|
return (
|
|
516
559
|
uses.length === 0 &&
|
|
517
560
|
((["grep", "head", "tail"].includes(name ?? "") && isPassiveTextCommand(argv)) ||
|
|
@@ -587,7 +630,7 @@ function literalShellBodies(command: string): string[] | undefined {
|
|
|
587
630
|
(/^--?[A-Za-z0-9-]+=/.test(word) && isShellWord(word.slice(word.indexOf("=") + 1)))),
|
|
588
631
|
) &&
|
|
589
632
|
!hasProvedShellDataOperands(argv) &&
|
|
590
|
-
!isStaticHerdrPrompt(command)
|
|
633
|
+
!(isLiteralHerdrPrompt(argv) && isStaticHerdrPrompt(command))
|
|
591
634
|
)
|
|
592
635
|
return undefined;
|
|
593
636
|
}
|
|
@@ -637,7 +680,6 @@ function isProvedLiteralShellBody(
|
|
|
637
680
|
return true;
|
|
638
681
|
}
|
|
639
682
|
if (!hasProvedNavigationSequence(materialized)) return false;
|
|
640
|
-
if (isStaticHerdrPrompt(materialized)) return true;
|
|
641
683
|
return parsed.pipelines.flat().every((words) => {
|
|
642
684
|
const argv = unwrapProofCommand(words, "\0");
|
|
643
685
|
if (!argv) return false;
|
|
@@ -659,6 +701,7 @@ function isProvedLiteralShellBody(
|
|
|
659
701
|
(name === "bun" && isSafeLocalAssignmentCommand(argv) && !isUnsupportedInlineRuntime(argv)) ||
|
|
660
702
|
isStaticNavigationCommand(argv) ||
|
|
661
703
|
isLiteralHerdrRead(argv) ||
|
|
704
|
+
(isLiteralHerdrPrompt(argv) && isStaticHerdrPrompt(materialized)) ||
|
|
662
705
|
(argv.length === 1 && ["true", "false", "pwd"].includes(name))
|
|
663
706
|
);
|
|
664
707
|
});
|
|
@@ -687,6 +730,11 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
|
|
|
687
730
|
const marker = "GUARDLITERALBINDING";
|
|
688
731
|
if (command.includes(marker)) return undefined;
|
|
689
732
|
let script = command.replace(/^[ \t\n]+|[ \t\n]+$/g, "");
|
|
733
|
+
// Quote removal is safe only for this literal ASCII path value, never a shell expansion.
|
|
734
|
+
script = script.replace(
|
|
735
|
+
/^([A-Za-z])=(["'])([A-Za-z0-9_./][A-Za-z0-9_./-]*)\2(?=[ \t]*(?:;|\n|&&))/,
|
|
736
|
+
"$1=$3",
|
|
737
|
+
);
|
|
690
738
|
const wrapped = parseStaticShellCommands(script);
|
|
691
739
|
if (
|
|
692
740
|
wrapped &&
|
|
@@ -784,20 +832,35 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
|
|
|
784
832
|
return undefined;
|
|
785
833
|
const value = assignment[4] ?? "";
|
|
786
834
|
const paths: string[] = [];
|
|
835
|
+
// One local directory value may occupy only the first gated cd operand.
|
|
836
|
+
const navigation = parsed.pipelines[0];
|
|
837
|
+
const navigationArgv = unwrapProofCommand(navigation?.[0] ?? [], marker);
|
|
838
|
+
const directoryBinding =
|
|
839
|
+
!assignment[1] &&
|
|
840
|
+
navigation?.length === 1 &&
|
|
841
|
+
navigationArgv?.[0] === "cd" &&
|
|
842
|
+
navigationArgv[navigationArgv[1] === "--" ? 2 : 1] === marker;
|
|
843
|
+
if (directoryBinding && !hasProvedNavigationSequence(rest.text.replaceAll(marker, () => value)))
|
|
844
|
+
return undefined;
|
|
787
845
|
for (const words of parsed.pipelines.flat()) {
|
|
788
846
|
const marked = unwrapProofCommand(words, marker);
|
|
789
847
|
if (!marked) return undefined;
|
|
790
848
|
const argv = marked.map((word) => word.replaceAll(marker, () => value));
|
|
791
849
|
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
792
850
|
const uses = marked.flatMap((word, index) => (word.includes(marker) ? [index] : []));
|
|
793
|
-
const checksum =
|
|
794
|
-
|
|
795
|
-
!hasExecutionOption(argv.slice(1), ["check"], "c");
|
|
851
|
+
const checksum = isChecksumCommand(argv);
|
|
852
|
+
if (directoryBinding && uses.length && words !== navigation?.[0]) return undefined;
|
|
796
853
|
if (!uses.length) {
|
|
797
854
|
if (
|
|
798
855
|
!isSafeLocalAssignmentCommand(argv) &&
|
|
799
856
|
inlineProgramIndex(argv) === undefined &&
|
|
800
857
|
!(argv[0] === "git" && argv[1] === "commit") &&
|
|
858
|
+
!(
|
|
859
|
+
directoryBinding &&
|
|
860
|
+
argv[0] === "git" &&
|
|
861
|
+
argv[1] === "clone" &&
|
|
862
|
+
!hasExecutionOption(argv.slice(2), ["config", "upload-pack"], "c")
|
|
863
|
+
) &&
|
|
801
864
|
!checksum
|
|
802
865
|
)
|
|
803
866
|
return undefined;
|
|
@@ -814,7 +877,9 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
|
|
|
814
877
|
const reader =
|
|
815
878
|
["cat", "ls", "head", "tail", "wc", "uniq", "cut", "sort", "grep", "rg"].includes(name) &&
|
|
816
879
|
isSafeLocalAssignmentCommand(argv);
|
|
817
|
-
|
|
880
|
+
const directory =
|
|
881
|
+
directoryBinding && words === navigation?.[0] && isStaticNavigationCommand(argv);
|
|
882
|
+
if (!gitAdd && !reader && !checksum && !directory) return undefined;
|
|
818
883
|
paths.push(...uses.map((index) => argv[index] ?? ""));
|
|
819
884
|
}
|
|
820
885
|
const prefix = assignment[1] ? `cd ${assignment[1]}${assignment[2]} ` : "";
|
|
@@ -981,6 +1046,7 @@ function inlineProgramIndex(argv: string[]): number | undefined {
|
|
|
981
1046
|
function isUnsupportedInlineRuntime(argv: string[]): boolean {
|
|
982
1047
|
const runtime = inlineRuntimeKind(argv[0]);
|
|
983
1048
|
if (!runtime) return false;
|
|
1049
|
+
if (pythonCompileFiles(argv)) return false;
|
|
984
1050
|
if (inlineProgramIndex(argv) !== undefined) return false;
|
|
985
1051
|
let index = 1;
|
|
986
1052
|
for (; index < argv.length; index++) {
|
|
@@ -1016,11 +1082,39 @@ function isUnsupportedInlineRuntime(argv: string[]): boolean {
|
|
|
1016
1082
|
return true;
|
|
1017
1083
|
}
|
|
1018
1084
|
if (runtime === "bun" && ["exec", "repl"].includes(argv[index] ?? "")) return true;
|
|
1019
|
-
//
|
|
1085
|
+
// A separate -- after a literal task/file is an argv boundary, not proof of task contents.
|
|
1086
|
+
let boundary = argv.length;
|
|
1087
|
+
const target = argv[index] === "run" ? index + 1 : index;
|
|
1088
|
+
if (
|
|
1089
|
+
runtime === "bun" &&
|
|
1090
|
+
/^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(argv[target] ?? "") &&
|
|
1091
|
+
![
|
|
1092
|
+
"exec",
|
|
1093
|
+
"repl",
|
|
1094
|
+
"x",
|
|
1095
|
+
"run",
|
|
1096
|
+
"test",
|
|
1097
|
+
"build",
|
|
1098
|
+
"install",
|
|
1099
|
+
"add",
|
|
1100
|
+
"remove",
|
|
1101
|
+
"update",
|
|
1102
|
+
"pm",
|
|
1103
|
+
"create",
|
|
1104
|
+
"link",
|
|
1105
|
+
"unlink",
|
|
1106
|
+
"init",
|
|
1107
|
+
"upgrade",
|
|
1108
|
+
].includes(argv[target] ?? "")
|
|
1109
|
+
) {
|
|
1110
|
+
const separator = argv.indexOf("--", target + 1);
|
|
1111
|
+
if (separator !== -1) boundary = separator;
|
|
1112
|
+
}
|
|
1113
|
+
// Before that boundary, unknown eval/loader controls retain conservative refusal.
|
|
1020
1114
|
return (
|
|
1021
1115
|
runtime === "bun" &&
|
|
1022
1116
|
argv
|
|
1023
|
-
.slice(index)
|
|
1117
|
+
.slice(index, boundary)
|
|
1024
1118
|
.some((arg) =>
|
|
1025
1119
|
/^(?:-[A-Za-z]*[ep]|-[rmp]|--(?:eval|print|require|import|loader|preload|experimental-loader))(?:$|=|[^-])/.test(
|
|
1026
1120
|
arg,
|
|
@@ -1029,8 +1123,60 @@ function isUnsupportedInlineRuntime(argv: string[]): boolean {
|
|
|
1029
1123
|
);
|
|
1030
1124
|
}
|
|
1031
1125
|
|
|
1126
|
+
/** -I excludes cwd/user imports and PYTHON* startup controls; -S disables site hooks. */
|
|
1127
|
+
function pythonCompileFiles(argv: string[]): string[] | undefined {
|
|
1128
|
+
if (!["python", "python3"].includes(argv[0]?.split("/").at(-1) ?? "")) return undefined;
|
|
1129
|
+
let index = 1;
|
|
1130
|
+
let isolation = "";
|
|
1131
|
+
while (/^-[ISB]+$/.test(argv[index] ?? "")) isolation += argv[index++]?.slice(1);
|
|
1132
|
+
if (!isolation.includes("I") || !isolation.includes("S")) return undefined;
|
|
1133
|
+
if (argv[index] !== "-m" || argv[index + 1] !== "py_compile") return undefined;
|
|
1134
|
+
const files = argv.slice(index + 2);
|
|
1135
|
+
return files.length > 0 &&
|
|
1136
|
+
files.length <= 32 &&
|
|
1137
|
+
files.every((file) => /^[A-Za-z0-9_./][A-Za-z0-9_./ -]*\.py$/.test(file))
|
|
1138
|
+
? files
|
|
1139
|
+
: undefined;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function isPythonCompileModule(argv: string[]): boolean {
|
|
1143
|
+
return (
|
|
1144
|
+
inlineRuntimeKind(argv[0]) === "python" &&
|
|
1145
|
+
argv
|
|
1146
|
+
.slice(1)
|
|
1147
|
+
.some(
|
|
1148
|
+
(arg, index) =>
|
|
1149
|
+
(arg === "-m" && argv[index + 2] === "py_compile") || arg === "-mpy_compile",
|
|
1150
|
+
)
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** Preserve the startup proof through the entire invocation, without inspecting target code. */
|
|
1155
|
+
function hasProvedPythonCompileInvocation(command: string): boolean {
|
|
1156
|
+
if (new TextEncoder().encode(command).byteLength > 65_536 || !literalBindingText(command, "", ""))
|
|
1157
|
+
return false;
|
|
1158
|
+
const parsed = parseStaticShellCommands(command);
|
|
1159
|
+
if (
|
|
1160
|
+
!parsed ||
|
|
1161
|
+
typeof parsed === "string" ||
|
|
1162
|
+
parsed.pipelines.flat().length > 32 ||
|
|
1163
|
+
!hasProvedNavigationSequence(command)
|
|
1164
|
+
)
|
|
1165
|
+
return false;
|
|
1166
|
+
return parsed.pipelines.every((pipeline) =>
|
|
1167
|
+
pipeline.every((words, index) => {
|
|
1168
|
+
const argv = unwrapProofCommand(words, "\0");
|
|
1169
|
+
if (!argv) return false;
|
|
1170
|
+
if (pythonCompileFiles(argv)) return index === 0;
|
|
1171
|
+
if (isStaticNavigationCommand(argv)) return index === 0 && pipeline.length === 1;
|
|
1172
|
+
return isReadonlyJqConsumer(argv);
|
|
1173
|
+
}),
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1032
1177
|
/** Move a complete, literal Python stdin program into the existing inline argv boundary. */
|
|
1033
|
-
function normalizeProgramHeredocs(command: string): string {
|
|
1178
|
+
function normalizeProgramHeredocs(command: string, depth = 0): string {
|
|
1179
|
+
if (depth >= 32 || new TextEncoder().encode(command).byteLength > 65_536) return command;
|
|
1034
1180
|
const heredoc = boundedHeredoc(command);
|
|
1035
1181
|
if (!heredoc || !heredoc.closed || (!heredoc.quoted && /[$`\\]/.test(heredoc.body)))
|
|
1036
1182
|
return command;
|
|
@@ -1038,6 +1184,29 @@ function normalizeProgramHeredocs(command: string): string {
|
|
|
1038
1184
|
if (!parsed || typeof parsed === "string") return command;
|
|
1039
1185
|
const last = parsed.pipelines.at(-1)?.at(-1);
|
|
1040
1186
|
const argv = last ? unwrapStaticCommand(last) : [];
|
|
1187
|
+
const name = argv[0]?.split("/").at(-1);
|
|
1188
|
+
if (
|
|
1189
|
+
depth > 0 &&
|
|
1190
|
+
((name === "cat" && argv.slice(1).every((arg) => arg === "--" || !arg.startsWith("-"))) ||
|
|
1191
|
+
(name === "tee" &&
|
|
1192
|
+
argv
|
|
1193
|
+
.slice(1)
|
|
1194
|
+
.every((arg) => ["-a", "--append", "--"].includes(arg) || !arg.startsWith("-"))))
|
|
1195
|
+
) {
|
|
1196
|
+
const following = normalizeProgramHeredocs(heredoc.following, depth + 1);
|
|
1197
|
+
const tail = parseStaticShellCommands(following);
|
|
1198
|
+
if (
|
|
1199
|
+
!tail ||
|
|
1200
|
+
typeof tail === "string" ||
|
|
1201
|
+
!tail.pipelines.flat().every((words) => {
|
|
1202
|
+
const next = unwrapProofCommand(words, "\0");
|
|
1203
|
+
return next !== undefined && isReadonlyJqConsumer(next);
|
|
1204
|
+
})
|
|
1205
|
+
)
|
|
1206
|
+
return command;
|
|
1207
|
+
// Literal writer bodies are data. Every header, file operand and following command is retained.
|
|
1208
|
+
return heredoc.header + "\n" + following;
|
|
1209
|
+
}
|
|
1041
1210
|
if (
|
|
1042
1211
|
inlineRuntimeKind(argv[0]) !== "python" ||
|
|
1043
1212
|
inlineProgramIndex([argv[0] ?? "", "-c", ""]) === undefined ||
|
|
@@ -1050,10 +1219,22 @@ function normalizeProgramHeredocs(command: string): string {
|
|
|
1050
1219
|
return (
|
|
1051
1220
|
heredoc.header.replace(/-\s*$/, () => "-c " + quoted) +
|
|
1052
1221
|
"\n" +
|
|
1053
|
-
normalizeProgramHeredocs(heredoc.following)
|
|
1222
|
+
normalizeProgramHeredocs(heredoc.following, depth + 1)
|
|
1054
1223
|
);
|
|
1055
1224
|
}
|
|
1056
1225
|
|
|
1226
|
+
function hasUnboundedHeredoc(command: string): boolean {
|
|
1227
|
+
if (!boundedHeredoc(command)) return false;
|
|
1228
|
+
if (new TextEncoder().encode(command).byteLength > 65_536) return true;
|
|
1229
|
+
for (let count = 0; count <= 32; count++) {
|
|
1230
|
+
const heredoc = boundedHeredoc(command);
|
|
1231
|
+
if (!heredoc) return false;
|
|
1232
|
+
if (!heredoc.closed || count === 32) return true;
|
|
1233
|
+
command = heredoc.header + "\n" + heredoc.following;
|
|
1234
|
+
}
|
|
1235
|
+
return true;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1057
1238
|
/** Strings are data in these languages; retain their values separately for path checks. */
|
|
1058
1239
|
function programParts(
|
|
1059
1240
|
program: string,
|
|
@@ -1435,7 +1616,7 @@ function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
|
|
|
1435
1616
|
return argv.some((arg, index) => index !== sql && hasArgumentBraceExpansion(arg));
|
|
1436
1617
|
return (argv[0]?.split("/").at(-1) === "awk" && !isAwkExecution(argv)) ||
|
|
1437
1618
|
inlineProgramIndex(argv) !== undefined ||
|
|
1438
|
-
|
|
1619
|
+
isPassiveJqCommand(argv)
|
|
1439
1620
|
? false
|
|
1440
1621
|
: hasArgumentBraceExpansion(argv.join(" "));
|
|
1441
1622
|
}
|
|
@@ -1506,42 +1687,48 @@ function isAwkExecution(argv: string[]): boolean {
|
|
|
1506
1687
|
);
|
|
1507
1688
|
}
|
|
1508
1689
|
|
|
1509
|
-
function
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
const flatObject = String.raw`\{\s*${flatEntry}(?:\s*,\s*${flatEntry})*\s*\}`;
|
|
1522
|
-
const mapped = String.raw`${field}\s*\|\s*map\(\s*${flatObject}\s*\)`;
|
|
1523
|
-
const value = String.raw`(?:${field}|${mapped}|\(\s*${mapped}\s*\))`;
|
|
1524
|
-
const entry = String.raw`${key}(?:\s*:\s*${value})?`;
|
|
1525
|
-
const object = String.raw`\{\s*${entry}(?:\s*,\s*${entry})*\s*\}`;
|
|
1526
|
-
// JSON string escapes exclude jq interpolation, which starts with \(.
|
|
1527
|
-
const string = String.raw`"(?:[^"\\\x00-\x1f]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*"`;
|
|
1528
|
-
const selector = String.raw`select\(\s*${field}\s*\|\s*contains\(\s*${string}\s*\)\s*\)`;
|
|
1529
|
-
const traversal = String.raw`${field}(?:\[\])?\s*\|\s*(?:${selector}\s*\|\s*)?`;
|
|
1530
|
-
if (new RegExp(String.raw`^(?:${traversal})?${object}$`).test(filter)) return true;
|
|
1531
|
-
const expression = filter
|
|
1532
|
-
.replace(/\.[A-Za-z_][A-Za-z0-9_.-]*/g, "FIELD")
|
|
1533
|
-
.replace(/\b[A-Za-z_][A-Za-z0-9_-]*\s*:/g, ":");
|
|
1534
|
-
if (/^\{[\s\S]*\}$/.test(filter) && /^(?:FIELD|length|map|[\s{}():,|>0-9])+$/.test(expression))
|
|
1535
|
-
return true;
|
|
1536
|
-
return (
|
|
1537
|
-
/^\{\s*[A-Za-z_][A-Za-z0-9_-]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_-]*)*\s*\}$/.test(filter) ||
|
|
1538
|
-
/^\{\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(
|
|
1539
|
-
filter,
|
|
1690
|
+
function isPassiveJqCommand(argv: string[]): boolean {
|
|
1691
|
+
return passiveJqProgramIndex(argv) !== undefined;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/** Preserve explicit startup and cwd context across every command in a jq compound. */
|
|
1695
|
+
function hasProvedJqContext(words: string[]): boolean {
|
|
1696
|
+
let index = 0;
|
|
1697
|
+
while (["rtk", "command"].includes(words[index]?.split("/").at(-1) ?? "")) {
|
|
1698
|
+
const wrapper = words[index++]?.split("/").at(-1);
|
|
1699
|
+
if (
|
|
1700
|
+
(wrapper === "rtk" && words[index] === "proxy") ||
|
|
1701
|
+
(wrapper === "command" && words[index] === "--")
|
|
1540
1702
|
)
|
|
1703
|
+
index++;
|
|
1704
|
+
}
|
|
1705
|
+
let argv = words.slice(index);
|
|
1706
|
+
if (argv[0]?.split("/").at(-1) === "env") {
|
|
1707
|
+
if (argv[1] !== "HOME=/dev/null") return false;
|
|
1708
|
+
argv = argv.slice(2);
|
|
1709
|
+
return isPassiveJqCommand(argv);
|
|
1710
|
+
}
|
|
1711
|
+
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
1712
|
+
if (/^[A-Za-z_]\w*=/.test(argv[0] ?? "")) return false;
|
|
1713
|
+
return (
|
|
1714
|
+
isReadonlyJqConsumer(argv) ||
|
|
1715
|
+
isProvedCwdSubprocessRole(argv) ||
|
|
1716
|
+
isStaticNavigationCommand(argv) ||
|
|
1717
|
+
isSafeLocalAssignmentCommand(argv) ||
|
|
1718
|
+
isLiteralHerdrRead(argv) ||
|
|
1719
|
+
isLiteralHerdrPrompt(argv) ||
|
|
1720
|
+
isLiteralInlineWriter(argv) ||
|
|
1721
|
+
isChecksumCommand(argv) ||
|
|
1722
|
+
pythonCompileFiles(argv) !== undefined ||
|
|
1723
|
+
DEFAULT_BLOCKED_CLI_TOOLS.some((tool) => tool.wrapper === name) ||
|
|
1724
|
+
(argv.length === 1 && ["true", "false", "pwd"].includes(name))
|
|
1541
1725
|
);
|
|
1542
1726
|
}
|
|
1543
1727
|
|
|
1544
1728
|
function jqFileArguments(argv: string[]): string[] {
|
|
1729
|
+
const program = passiveJqProgramIndex(argv);
|
|
1730
|
+
if (program !== undefined) return argv.slice(program + 1);
|
|
1731
|
+
// Unsupported forms retain conservative path evidence, including loader operands.
|
|
1545
1732
|
const files: string[] = [];
|
|
1546
1733
|
let index = 1;
|
|
1547
1734
|
for (; index < argv.length; index++) {
|
|
@@ -1566,6 +1753,153 @@ function jqFileArguments(argv: string[]): string[] {
|
|
|
1566
1753
|
return [...files, ...argv.slice(index + 1)];
|
|
1567
1754
|
}
|
|
1568
1755
|
|
|
1756
|
+
/** File roles only, never evaluation or repository/cwd attestation. Unknown syntax fails closed. */
|
|
1757
|
+
function gitDiffFileArguments(args: string[]): string[] | undefined {
|
|
1758
|
+
const flags = [
|
|
1759
|
+
"patch",
|
|
1760
|
+
"no-patch",
|
|
1761
|
+
"raw",
|
|
1762
|
+
"patch-with-raw",
|
|
1763
|
+
"patch-with-stat",
|
|
1764
|
+
"numstat",
|
|
1765
|
+
"shortstat",
|
|
1766
|
+
"summary",
|
|
1767
|
+
"name-only",
|
|
1768
|
+
"name-status",
|
|
1769
|
+
"cached",
|
|
1770
|
+
"staged",
|
|
1771
|
+
"merge-base",
|
|
1772
|
+
"check",
|
|
1773
|
+
"full-index",
|
|
1774
|
+
"binary",
|
|
1775
|
+
"no-abbrev",
|
|
1776
|
+
"no-renames",
|
|
1777
|
+
"find-copies-harder",
|
|
1778
|
+
"text",
|
|
1779
|
+
"reverse",
|
|
1780
|
+
"exit-code",
|
|
1781
|
+
"quiet",
|
|
1782
|
+
"no-ext-diff",
|
|
1783
|
+
"no-textconv",
|
|
1784
|
+
"no-index",
|
|
1785
|
+
"no-color",
|
|
1786
|
+
"no-relative",
|
|
1787
|
+
"no-prefix",
|
|
1788
|
+
"default-prefix",
|
|
1789
|
+
"ignore-all-space",
|
|
1790
|
+
"ignore-space-change",
|
|
1791
|
+
"ignore-space-at-eol",
|
|
1792
|
+
"ignore-cr-at-eol",
|
|
1793
|
+
"ignore-blank-lines",
|
|
1794
|
+
"minimal",
|
|
1795
|
+
"patience",
|
|
1796
|
+
"histogram",
|
|
1797
|
+
"indent-heuristic",
|
|
1798
|
+
"no-indent-heuristic",
|
|
1799
|
+
"cc",
|
|
1800
|
+
"function-context",
|
|
1801
|
+
"pickaxe-all",
|
|
1802
|
+
"pickaxe-regex",
|
|
1803
|
+
];
|
|
1804
|
+
const data = [
|
|
1805
|
+
"src-prefix",
|
|
1806
|
+
"dst-prefix",
|
|
1807
|
+
"line-prefix",
|
|
1808
|
+
"word-diff-regex",
|
|
1809
|
+
"anchored",
|
|
1810
|
+
"diff-algorithm",
|
|
1811
|
+
"inter-hunk-context",
|
|
1812
|
+
"find-object",
|
|
1813
|
+
"skip-to",
|
|
1814
|
+
"rotate-to",
|
|
1815
|
+
"ignore-matching-lines",
|
|
1816
|
+
"stat-width",
|
|
1817
|
+
"stat-name-width",
|
|
1818
|
+
"stat-graph-width",
|
|
1819
|
+
"stat-count",
|
|
1820
|
+
"diff-filter",
|
|
1821
|
+
"output-indicator-new",
|
|
1822
|
+
"output-indicator-old",
|
|
1823
|
+
"output-indicator-context",
|
|
1824
|
+
"ws-error-highlight",
|
|
1825
|
+
"color-moved-ws",
|
|
1826
|
+
];
|
|
1827
|
+
const optional = [
|
|
1828
|
+
"stat",
|
|
1829
|
+
"unified",
|
|
1830
|
+
"color",
|
|
1831
|
+
"color-moved",
|
|
1832
|
+
"relative",
|
|
1833
|
+
"abbrev",
|
|
1834
|
+
"word-diff",
|
|
1835
|
+
"submodule",
|
|
1836
|
+
"dirstat",
|
|
1837
|
+
"dirstat-by-file",
|
|
1838
|
+
"find-renames",
|
|
1839
|
+
"find-copies",
|
|
1840
|
+
"break-rewrites",
|
|
1841
|
+
"ignore-submodules",
|
|
1842
|
+
];
|
|
1843
|
+
const names = [...flags, ...data, ...optional, "output"];
|
|
1844
|
+
const files: string[] = [];
|
|
1845
|
+
for (let index = 0; index < args.length; index++) {
|
|
1846
|
+
const arg = args[index] ?? "";
|
|
1847
|
+
if (arg === "--") return [...files, ...args.slice(index + 1)];
|
|
1848
|
+
if (!arg.startsWith("-") || arg === "-") {
|
|
1849
|
+
files.push(arg);
|
|
1850
|
+
continue;
|
|
1851
|
+
}
|
|
1852
|
+
if (arg.startsWith("--")) {
|
|
1853
|
+
const equals = arg.indexOf("=");
|
|
1854
|
+
const spelling = arg.slice(2, equals === -1 ? undefined : equals);
|
|
1855
|
+
const candidates = names.includes(spelling)
|
|
1856
|
+
? [spelling]
|
|
1857
|
+
: names.filter((name) => name.startsWith(spelling));
|
|
1858
|
+
if (!spelling || candidates.length !== 1) return undefined;
|
|
1859
|
+
const name = candidates[0] ?? "";
|
|
1860
|
+
if (flags.includes(name)) {
|
|
1861
|
+
if (equals !== -1) return undefined;
|
|
1862
|
+
continue;
|
|
1863
|
+
}
|
|
1864
|
+
if (optional.includes(name)) continue; // Optional values attach with =, never consume the next path.
|
|
1865
|
+
const value = equals === -1 ? args[++index] : arg.slice(equals + 1);
|
|
1866
|
+
if (value === undefined || (equals === -1 && value === "--")) return undefined;
|
|
1867
|
+
if (name === "output") {
|
|
1868
|
+
if (!value) return undefined;
|
|
1869
|
+
files.push(value);
|
|
1870
|
+
}
|
|
1871
|
+
continue;
|
|
1872
|
+
}
|
|
1873
|
+
for (let cursor = 1; cursor < arg.length; cursor++) {
|
|
1874
|
+
const option = arg[cursor] ?? "";
|
|
1875
|
+
if ("puszawbcRDW".includes(option)) continue;
|
|
1876
|
+
if ("UMCB".includes(option)) break; // Optional attached data occupies the rest of this word.
|
|
1877
|
+
if (!"OSGIl".includes(option)) return undefined;
|
|
1878
|
+
const attached = arg.slice(cursor + 1);
|
|
1879
|
+
const value = attached || args[++index];
|
|
1880
|
+
if (value === undefined || (!attached && value === "--")) return undefined;
|
|
1881
|
+
if (option === "O") {
|
|
1882
|
+
if (!value) return undefined;
|
|
1883
|
+
files.push(value);
|
|
1884
|
+
}
|
|
1885
|
+
break; // Required data consumes its attached/separate value, including option-looking patterns.
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
return files;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
function hasUnsupportedGitDiffArguments(command: string): boolean {
|
|
1892
|
+
const parsed = parseStaticShellCommands(command);
|
|
1893
|
+
if (!parsed || typeof parsed === "string") return false;
|
|
1894
|
+
return parsed.pipelines.flat().some((words) => {
|
|
1895
|
+
const argv = unwrapStaticCommand(words);
|
|
1896
|
+
if (argv[0]?.split("/").at(-1) !== "git") return false;
|
|
1897
|
+
let index = 1;
|
|
1898
|
+
while (argv[index] === "-C" && argv[index + 1]) index += 2;
|
|
1899
|
+
return argv[index] === "diff" && gitDiffFileArguments(argv.slice(index + 1)) === undefined;
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1569
1903
|
/** Index in `args` where the command wrapped by `env` starts, or null when `env` only lists. */
|
|
1570
1904
|
function environmentCommandStart(args: string[]): number | null {
|
|
1571
1905
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -2011,7 +2345,7 @@ function isPassiveTextCommand(argv: string[]): boolean {
|
|
|
2011
2345
|
if (name === "rg") return !hasExecutionOption(args, ["pre", "hostname-bin"]);
|
|
2012
2346
|
if (name === "awk") return !isAwkExecution(argv);
|
|
2013
2347
|
if (name === "sed") return isReadonlySedPrint(argv);
|
|
2014
|
-
if (name === "jq") return
|
|
2348
|
+
if (name === "jq") return isPassiveJqCommand(argv);
|
|
2015
2349
|
return (
|
|
2016
2350
|
(name === "git" && args[0] === "status") ||
|
|
2017
2351
|
(name === "git" &&
|
|
@@ -2084,6 +2418,7 @@ function hasStaticEnvironmentRead(
|
|
|
2084
2418
|
allowInventory = false,
|
|
2085
2419
|
): boolean {
|
|
2086
2420
|
const name = argv[0]?.split("/").at(-1);
|
|
2421
|
+
if (isLiteralHerdrPrompt(argv)) return false;
|
|
2087
2422
|
if (
|
|
2088
2423
|
["sh", "bash", "zsh"].includes(name ?? "") &&
|
|
2089
2424
|
hasExecutionOption(argv.slice(1), ["command"], "c") &&
|
|
@@ -2105,6 +2440,7 @@ function hasStaticEnvironmentRead(
|
|
|
2105
2440
|
? isEnvironmentListing(argv)
|
|
2106
2441
|
: hasStaticEnvironmentRead(unwrapStaticCommand(wrapped), allowedNames, allowInventory);
|
|
2107
2442
|
}
|
|
2443
|
+
if (name === "jq") return !isPassiveJqCommand(argv);
|
|
2108
2444
|
if (isUnsupportedInlineRuntime(argv)) return true;
|
|
2109
2445
|
const programIndex = inlineProgramIndex(argv);
|
|
2110
2446
|
if (programIndex !== undefined)
|
|
@@ -2136,6 +2472,10 @@ function hasStaticEnvironmentRead(
|
|
|
2136
2472
|
);
|
|
2137
2473
|
}
|
|
2138
2474
|
|
|
2475
|
+
function isLiteralHerdrPrompt(argv: string[]): boolean {
|
|
2476
|
+
return argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt" && argv.length >= 5;
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2139
2479
|
function isStaticHerdrPrompt(command: string): boolean {
|
|
2140
2480
|
// Preserve shell word boundaries and executable identity before proving composition.
|
|
2141
2481
|
const literal = literalBindingText(command, "", "");
|
|
@@ -2143,18 +2483,22 @@ function isStaticHerdrPrompt(command: string): boolean {
|
|
|
2143
2483
|
const parsed = parseStaticShellCommands(command);
|
|
2144
2484
|
if (!parsed || typeof parsed === "string") return false;
|
|
2145
2485
|
const commands = parsed.pipelines.flat().map((words) => unwrapProofCommand(words, "\0"));
|
|
2146
|
-
const prompts = commands.filter(
|
|
2147
|
-
|
|
2148
|
-
);
|
|
2149
|
-
if (!hasProvedNavigationSequence(command)) return false;
|
|
2486
|
+
const prompts = commands.filter((argv) => argv && isLiteralHerdrPrompt(argv));
|
|
2487
|
+
if (!hasProvedNavigationSequence(command) || hasEnvironmentMutationPrefix(command)) return false;
|
|
2150
2488
|
return (
|
|
2151
2489
|
prompts.length > 0 &&
|
|
2152
|
-
parsed.pipelines.every((pipeline) =>
|
|
2153
|
-
pipeline.every((words, index) => {
|
|
2490
|
+
parsed.pipelines.every((pipeline) => {
|
|
2491
|
+
return pipeline.every((words, index) => {
|
|
2154
2492
|
const argv = unwrapProofCommand(words, "\0");
|
|
2155
2493
|
if (!argv) return false;
|
|
2156
|
-
if (argv
|
|
2157
|
-
|
|
2494
|
+
if (isLiteralHerdrPrompt(argv)) return index === 0;
|
|
2495
|
+
if (argv[0] === "herdr" && argv[1] === "pane" && argv[2] === "close")
|
|
2496
|
+
return (
|
|
2497
|
+
index === 0 &&
|
|
2498
|
+
pipeline.length === 1 &&
|
|
2499
|
+
argv.length === 4 &&
|
|
2500
|
+
/^[A-Za-z0-9_.:-]+$/.test(argv[3] ?? "")
|
|
2501
|
+
);
|
|
2158
2502
|
// cat is a reader at the start of an independent pipeline, never a consumer.
|
|
2159
2503
|
if (argv[0] === "cat")
|
|
2160
2504
|
return (
|
|
@@ -2173,9 +2517,16 @@ function isStaticHerdrPrompt(command: string): boolean {
|
|
|
2173
2517
|
);
|
|
2174
2518
|
if (isStaticNavigationCommand(argv))
|
|
2175
2519
|
return index === 0 && (argv[0] !== "cd" || pipeline.length === 1);
|
|
2520
|
+
// Literal child scripts keep the existing execution boundary; their source is not attested.
|
|
2521
|
+
if (
|
|
2522
|
+
argv[0] === "bun" &&
|
|
2523
|
+
inlineProgramIndex(argv) === undefined &&
|
|
2524
|
+
!isUnsupportedInlineRuntime(argv)
|
|
2525
|
+
)
|
|
2526
|
+
return index === 0 && /^(?:~\/)?[A-Za-z0-9_./-]+\.(?:[cm]?[jt]s)$/.test(argv[1] ?? "");
|
|
2176
2527
|
return false;
|
|
2177
|
-
})
|
|
2178
|
-
)
|
|
2528
|
+
});
|
|
2529
|
+
})
|
|
2179
2530
|
);
|
|
2180
2531
|
}
|
|
2181
2532
|
|
|
@@ -2195,8 +2546,7 @@ function hasEnvironmentRead(
|
|
|
2195
2546
|
allowInventory = hasProvedPackageInventoryInvocation(command),
|
|
2196
2547
|
replayedShellBodies: string[] = [],
|
|
2197
2548
|
): boolean {
|
|
2198
|
-
if (isStaticHerdrPrompt(command)) return
|
|
2199
|
-
if (isHerdrPrompt(command)) return true;
|
|
2549
|
+
if (isHerdrPrompt(command) && !isStaticHerdrPrompt(command)) return true;
|
|
2200
2550
|
if (isLiteralTextWrite(command)) return false;
|
|
2201
2551
|
// ponytail: complex shell syntax stays conservative; use a shell AST if more exceptions are needed.
|
|
2202
2552
|
const parsed = parseStaticShellCommands(command);
|
|
@@ -2241,7 +2591,7 @@ function hasEnvironmentRead(
|
|
|
2241
2591
|
(argv, index) =>
|
|
2242
2592
|
(literalSqlArgumentIndex(argv) !== undefined &&
|
|
2243
2593
|
pipeline.slice(index + 1).some((consumer) => !isPassiveTextCommand(consumer))) ||
|
|
2244
|
-
(
|
|
2594
|
+
(isPassiveJqCommand(argv) &&
|
|
2245
2595
|
pipeline.slice(index + 1).some((consumer) => !isReadonlyJqConsumer(consumer))),
|
|
2246
2596
|
),
|
|
2247
2597
|
)
|
|
@@ -2365,7 +2715,7 @@ function isLiteralTextWrite(command: string): boolean {
|
|
|
2365
2715
|
commands.every((argv, index) => {
|
|
2366
2716
|
if (index === writerIndexes[0]) return true;
|
|
2367
2717
|
const name = argv[0]?.split("/").at(-1) ?? "";
|
|
2368
|
-
return name === "cd" || isSafeLiteralConsumer(argv);
|
|
2718
|
+
return name === "cd" || (name === "pwd" && argv.length === 1) || isSafeLiteralConsumer(argv);
|
|
2369
2719
|
})
|
|
2370
2720
|
);
|
|
2371
2721
|
}
|
|
@@ -2383,7 +2733,6 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
2383
2733
|
!/(?:^|\/)credential-guard(?:(?:-[^/]+)?\.(?:ts|md|js)|\*|\/(?:index\.(?:ts|js)))?$/.test(
|
|
2384
2734
|
text,
|
|
2385
2735
|
));
|
|
2386
|
-
if (/(?:^|[\s;&|])--env-file(?:=|\s)[^;&|]*\.env\b/i.test(command)) return true;
|
|
2387
2736
|
const heredoc = boundedHeredoc(command);
|
|
2388
2737
|
if (
|
|
2389
2738
|
heredoc &&
|
|
@@ -2397,7 +2746,7 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
2397
2746
|
if (/\bcat\b[^;&|]*<<-?\s*\S+[\s\S]*\b(?:secret|credential)\b/i.test(command)) return true;
|
|
2398
2747
|
const parsed = parseStaticShellCommands(command);
|
|
2399
2748
|
const readers =
|
|
2400
|
-
/\b(?:cat|cp|grep|rg|sed|awk|jq|head|tail|less|more|source|ls|sort|uniq|cut|wc|mv|find|shasum|sha1sum|sha256sum|sha512sum|md5sum|cksum)\b/i;
|
|
2749
|
+
/\b(?:cat|tee|cp|grep|rg|sed|awk|jq|head|tail|less|more|source|ls|sort|uniq|cut|wc|mv|find|shasum|sha1sum|sha256sum|sha512sum|md5sum|cksum)\b/i;
|
|
2401
2750
|
if (parsed && parsed !== "brace-expansion") {
|
|
2402
2751
|
let directory = "";
|
|
2403
2752
|
const trackDirectory = hasProvedNavigationSequence(command);
|
|
@@ -2419,6 +2768,19 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
2419
2768
|
[path, inDirectory(path)].some(
|
|
2420
2769
|
(value) => isPathBlocked(value) || isPathBlocked(value + "/"),
|
|
2421
2770
|
);
|
|
2771
|
+
const program = inlineProgramIndex(inspected);
|
|
2772
|
+
// Runtime file options are operands; known text/prompt/program data is not an option.
|
|
2773
|
+
if (
|
|
2774
|
+
!isPassiveTextCommand(inspected) &&
|
|
2775
|
+
!(name === "herdr" && inspected[1] === "agent" && inspected[2] === "prompt") &&
|
|
2776
|
+
inspected.some(
|
|
2777
|
+
(arg, index) =>
|
|
2778
|
+
index !== program &&
|
|
2779
|
+
((arg === "--env-file" && blockedOperand(inspected[index + 1] ?? "")) ||
|
|
2780
|
+
(arg.startsWith("--env-file=") && blockedOperand(arg.slice("--env-file=".length)))),
|
|
2781
|
+
)
|
|
2782
|
+
)
|
|
2783
|
+
return true;
|
|
2422
2784
|
if (name === "cd") {
|
|
2423
2785
|
const path = inspected[inspected[1] === "--" ? 2 : 1];
|
|
2424
2786
|
if (path === undefined) return false;
|
|
@@ -2450,6 +2812,9 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
2450
2812
|
if (inspected[index] === "grep") {
|
|
2451
2813
|
inspected = ["grep", ...inspected.slice(index + 1)];
|
|
2452
2814
|
name = "grep";
|
|
2815
|
+
} else if (inspected[index] === "diff") {
|
|
2816
|
+
const files = gitDiffFileArguments(inspected.slice(index + 1));
|
|
2817
|
+
return files === undefined || files.some(blockedOperand);
|
|
2453
2818
|
}
|
|
2454
2819
|
}
|
|
2455
2820
|
if (name === "bun" && inspected[1] === "run" && inspected[2] === "gh-tool") {
|
|
@@ -2464,6 +2829,14 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
2464
2829
|
});
|
|
2465
2830
|
}
|
|
2466
2831
|
const programIndex = inlineProgramIndex(inspected);
|
|
2832
|
+
const compileFiles = pythonCompileFiles(inspected);
|
|
2833
|
+
if (isPythonCompileModule(inspected)) {
|
|
2834
|
+
return (compileFiles ?? inspected.slice(1)).some(
|
|
2835
|
+
(file) =>
|
|
2836
|
+
blockedOperand(file) ||
|
|
2837
|
+
blockedDirectory(posix.join(posix.dirname(file), "__pycache__")),
|
|
2838
|
+
);
|
|
2839
|
+
}
|
|
2467
2840
|
if (programIndex !== undefined) {
|
|
2468
2841
|
const parts = programParts(
|
|
2469
2842
|
inspected[programIndex] ?? "",
|
|
@@ -2597,6 +2970,8 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
|
|
|
2597
2970
|
return paths.some(blockedOperand);
|
|
2598
2971
|
});
|
|
2599
2972
|
}
|
|
2973
|
+
// Unsupported shell syntax retains conservative option evidence.
|
|
2974
|
+
if (/(?:^|[\s;&|])--env-file(?:=|\s)[^;&|]*\.env\b/i.test(command)) return true;
|
|
2600
2975
|
const operands = parseStaticShellCommands(command, true);
|
|
2601
2976
|
if (
|
|
2602
2977
|
operands &&
|
|
@@ -3076,16 +3451,18 @@ function hasEnvironmentSourceAccess(
|
|
|
3076
3451
|
);
|
|
3077
3452
|
}
|
|
3078
3453
|
}
|
|
3079
|
-
if (isLiteralTextWrite(command)
|
|
3454
|
+
if (isLiteralTextWrite(command)) return false;
|
|
3080
3455
|
if (!parsed || typeof parsed === "string") return rawTrigger;
|
|
3081
3456
|
const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
|
|
3457
|
+
const promptContext = isStaticHerdrPrompt(command);
|
|
3082
3458
|
if (rawTrigger) {
|
|
3083
3459
|
// Original invocation-wide evidence survives split writers and inline quote projection.
|
|
3084
3460
|
return !commands.every(
|
|
3085
3461
|
(argv) =>
|
|
3086
3462
|
argv[0]?.split("/").at(-1) === "cat" ||
|
|
3087
3463
|
isPassiveTextCommand(argv) ||
|
|
3088
|
-
isLiteralInlineWriter(argv)
|
|
3464
|
+
isLiteralInlineWriter(argv) ||
|
|
3465
|
+
(promptContext && isLiteralHerdrPrompt(argv)),
|
|
3089
3466
|
);
|
|
3090
3467
|
}
|
|
3091
3468
|
return commands.some((argv) => {
|
|
@@ -3104,6 +3481,32 @@ function hasEnvironmentSourceAccess(
|
|
|
3104
3481
|
});
|
|
3105
3482
|
}
|
|
3106
3483
|
|
|
3484
|
+
/** Explicit lookup/loader names cannot establish interpreter startup context. Literal text is data. */
|
|
3485
|
+
function hasInterpreterStartupMutation(command: string): boolean {
|
|
3486
|
+
const parsed = parseStaticShellCommands(command) ?? parseStaticShellCommands(command, true);
|
|
3487
|
+
if (!parsed || typeof parsed === "string") return false;
|
|
3488
|
+
const commands = parsed.pipelines.flat();
|
|
3489
|
+
if (
|
|
3490
|
+
!commands.some((words) => {
|
|
3491
|
+
const name = unwrapStaticCommand(words)[0];
|
|
3492
|
+
return inlineRuntimeKind(name) !== undefined || isShellWord(name ?? "");
|
|
3493
|
+
})
|
|
3494
|
+
)
|
|
3495
|
+
return false;
|
|
3496
|
+
const startupName =
|
|
3497
|
+
/^(?:PATH|HOME|XDG_CONFIG_HOME|BUN_OPTIONS|BUN_PRELOAD|NODE_OPTIONS|NODE_PATH|PYTHON[A-Z_]*|BASH_ENV|ENV|ZDOTDIR|FPATH|SHELLOPTS|BASHOPTS|LD_[A-Z_]+|DYLD_[A-Z_]+)$/;
|
|
3498
|
+
return commands.some((words) => {
|
|
3499
|
+
const argv = unwrapStaticCommand(words);
|
|
3500
|
+
const prefix = words.slice(0, words.length - argv.length);
|
|
3501
|
+
if (prefix.some((word) => startupName.test(word.split("=", 1)[0] ?? ""))) return true;
|
|
3502
|
+
const mutator = argv[0] === "builtin" ? argv.slice(1) : argv;
|
|
3503
|
+
return (
|
|
3504
|
+
["export", "declare", "typeset", "local", "readonly"].includes(mutator[0] ?? "") &&
|
|
3505
|
+
mutator.slice(1).some((word) => startupName.test(word.split("=", 1)[0] ?? ""))
|
|
3506
|
+
);
|
|
3507
|
+
});
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3107
3510
|
/** Extract file path from hook arguments. */
|
|
3108
3511
|
export function extractFilePath(args: Record<string, unknown>): string {
|
|
3109
3512
|
return (args.filePath as string) || (args.file_path as string) || (args.path as string) || "";
|
|
@@ -3213,9 +3616,29 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
3213
3616
|
|
|
3214
3617
|
function getDangerousBashReason(command: string, shellDepth = 0): string | null {
|
|
3215
3618
|
const originalCommand = command;
|
|
3619
|
+
if (hasUnboundedHeredoc(command)) return "cannot prove a bounded complete heredoc invocation";
|
|
3620
|
+
let unterminatedQuote = false;
|
|
3621
|
+
parseStaticShellCommands(command, false, () => {
|
|
3622
|
+
unterminatedQuote = true;
|
|
3623
|
+
});
|
|
3624
|
+
if (unterminatedQuote)
|
|
3625
|
+
return "cannot prove shell syntax: an unterminated quoted argument was found";
|
|
3216
3626
|
const allowPackageInventory = hasProvedPackageInventoryInvocation(originalCommand);
|
|
3627
|
+
const promptWords = isStaticHerdrPrompt(command)
|
|
3628
|
+
? parseStaticShellCommands(command)
|
|
3629
|
+
: undefined;
|
|
3630
|
+
const sourceText =
|
|
3631
|
+
promptWords && typeof promptWords !== "string"
|
|
3632
|
+
? promptWords.pipelines
|
|
3633
|
+
.flat()
|
|
3634
|
+
.map((words) => {
|
|
3635
|
+
const argv = unwrapStaticCommand(words);
|
|
3636
|
+
return (isLiteralHerdrPrompt(argv) ? argv.slice(0, 4) : words).join(" ");
|
|
3637
|
+
})
|
|
3638
|
+
.join("; ")
|
|
3639
|
+
: command;
|
|
3217
3640
|
const rawEnvironmentSource =
|
|
3218
|
-
mentionsEnvironmentSource(
|
|
3641
|
+
mentionsEnvironmentSource(sourceText) && mentionsInlineRuntime(sourceText);
|
|
3219
3642
|
if (
|
|
3220
3643
|
hasApprovedPythonEnvironmentRead(originalCommand, allowedEnvironmentVariables) &&
|
|
3221
3644
|
hasEnvironmentMutationPrefix(normalizeProgramHeredocs(originalCommand))
|
|
@@ -3277,11 +3700,50 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
3277
3700
|
if (reason || getBlockedCliTool(nested, true, shellDepth))
|
|
3278
3701
|
return reason ?? "invokes a blocked CLI from a script";
|
|
3279
3702
|
}
|
|
3703
|
+
if (hasUnsupportedGitDiffArguments(command))
|
|
3704
|
+
return "cannot prove supported Git diff option and file argument roles";
|
|
3280
3705
|
if (
|
|
3281
3706
|
hasSensitiveFileRead(command, isPathBlocked) ||
|
|
3282
3707
|
hasSensitivePathRedirect(command, isPathBlocked)
|
|
3283
3708
|
)
|
|
3284
3709
|
return "blocked by the file-access policy; a sensitive path matched or this command form could not be verified";
|
|
3710
|
+
if (hasInterpreterStartupMutation(command))
|
|
3711
|
+
return "cannot prove interpreter startup after an explicit lookup or loader mutation";
|
|
3712
|
+
const staticCommands = parseStaticShellCommands(command);
|
|
3713
|
+
const diagnosticCommands = staticCommands ?? parseStaticShellCommands(command, true);
|
|
3714
|
+
const staticArgv =
|
|
3715
|
+
diagnosticCommands && typeof diagnosticCommands !== "string"
|
|
3716
|
+
? diagnosticCommands.pipelines.flat().map(unwrapStaticCommand)
|
|
3717
|
+
: [];
|
|
3718
|
+
const hasJqInvocation = staticArgv.some(
|
|
3719
|
+
(argv) =>
|
|
3720
|
+
unwrapStaticCommand(argv[0] === "builtin" ? argv.slice(1) : argv)[0]
|
|
3721
|
+
?.split("/")
|
|
3722
|
+
.at(-1) === "jq",
|
|
3723
|
+
);
|
|
3724
|
+
if (staticArgv.some(isPythonCompileModule) && !hasProvedPythonCompileInvocation(command))
|
|
3725
|
+
return "cannot prove isolated Python py_compile startup and literal source/cache paths";
|
|
3726
|
+
if (staticArgv.some(isPythonCompileModule) && config?.additionalBlockedPaths?.length)
|
|
3727
|
+
return "cannot prove implicit py_compile cache writes against configured path rules";
|
|
3728
|
+
if (
|
|
3729
|
+
staticArgv.some(
|
|
3730
|
+
(argv) =>
|
|
3731
|
+
argv[0]?.split("/").at(-1) === "jq" &&
|
|
3732
|
+
(!staticCommands ||
|
|
3733
|
+
staticCommands === "brace-expansion" ||
|
|
3734
|
+
staticCommands.pathnameExpansion ||
|
|
3735
|
+
!isPassiveJqCommand(argv)),
|
|
3736
|
+
)
|
|
3737
|
+
)
|
|
3738
|
+
return "cannot prove a supported passive jq filter and literal options";
|
|
3739
|
+
if (
|
|
3740
|
+
staticCommands &&
|
|
3741
|
+
typeof staticCommands !== "string" &&
|
|
3742
|
+
hasJqInvocation &&
|
|
3743
|
+
(!hasProvedNavigationSequence(command) ||
|
|
3744
|
+
staticCommands.pipelines.flat().some((words) => !hasProvedJqContext(words)))
|
|
3745
|
+
)
|
|
3746
|
+
return "cannot prove a supported passive jq startup prefix";
|
|
3285
3747
|
let environmentCommand = command;
|
|
3286
3748
|
if (
|
|
3287
3749
|
!isLiteralTextWrite(command) &&
|
|
@@ -3294,6 +3756,8 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
3294
3756
|
},
|
|
3295
3757
|
)
|
|
3296
3758
|
) {
|
|
3759
|
+
if (getLeadingLiteralAssignments(command).size || /\b[A-Za-z_]\w*=(?:\$\(|~)/.test(command))
|
|
3760
|
+
return "cannot prove a local binding expansion in a supported data operand";
|
|
3297
3761
|
return "expands an unapproved or executable environment variable value";
|
|
3298
3762
|
}
|
|
3299
3763
|
if (
|
|
@@ -3315,6 +3779,8 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
3315
3779
|
}),
|
|
3316
3780
|
)
|
|
3317
3781
|
) {
|
|
3782
|
+
if (staticArgv.some(isUnsupportedInlineRuntime))
|
|
3783
|
+
return "cannot prove supported inline runtime or module startup; external script contents were not inspected";
|
|
3318
3784
|
return "blocked by the environment/execution policy; permitted access or non-execution could not be established for this command form";
|
|
3319
3785
|
}
|
|
3320
3786
|
if (
|
|
@@ -3439,6 +3905,20 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
3439
3905
|
}
|
|
3440
3906
|
|
|
3441
3907
|
function dangerousCommandRecovery(command: string, reason: string): string {
|
|
3908
|
+
if (reason.includes("Git diff"))
|
|
3909
|
+
return (
|
|
3910
|
+
`Use supported literal Git diff options with complete argument values and nonsensitive file operands. ` +
|
|
3911
|
+
`Output/order-file arguments and revision-shaped positional operands receive file policy too. ` +
|
|
3912
|
+
`Unknown, ambiguous, missing or executable option roles remain unproved; use a simple git diff HEAD -- path/to/source form. ` +
|
|
3913
|
+
`The guard does not inspect repository state, native configuration or source contents.`
|
|
3914
|
+
);
|
|
3915
|
+
if (reason.includes("shell syntax"))
|
|
3916
|
+
return (
|
|
3917
|
+
`Check shell quote balance and encode the complete literal argument safely. ` +
|
|
3918
|
+
`An apostrophe inside single quotes needs shell encoding such as 'don'"'"'t'. ` +
|
|
3919
|
+
`Use a documented file-input option only if that command supports one; no such option is inferred here. ` +
|
|
3920
|
+
`Path, environment and execution policies still apply to the corrected command.`
|
|
3921
|
+
);
|
|
3442
3922
|
if (reason.startsWith("invokes a blocked CLI")) {
|
|
3443
3923
|
const blockedTool = getBlockedCliTool(command);
|
|
3444
3924
|
if (blockedTool) return blockedCliRecovery(blockedTool);
|
|
@@ -3447,6 +3927,42 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
3447
3927
|
`The guard sees the command, not external script contents. Removing shell flags does not certify a script. ` +
|
|
3448
3928
|
`No generic approved external-script executor is provided by this guard. Review or implement a repository-owned bounded operation ` +
|
|
3449
3929
|
`with secret injection and filtered output; discover existing operations in the project README and tool help.`;
|
|
3930
|
+
if (reason.includes("py_compile") && config?.additionalBlockedPaths?.length)
|
|
3931
|
+
return (
|
|
3932
|
+
`Configured path rules cannot be proved for py_compile's runtime-specific cache and temporary filenames. ` +
|
|
3933
|
+
`Use a cache-free syntax parse with literal nonsensitive paths: ` +
|
|
3934
|
+
`python3 -I -S -B -c 'import ast; ast.parse(open("path/to/source.py", "rb").read(), filename="path/to/source.py")'. ` +
|
|
3935
|
+
`Source paths and all other command policies still apply; target source is parsed without execution. ` +
|
|
3936
|
+
`The guard has not inspected source contents or resolved symlinks.`
|
|
3937
|
+
);
|
|
3938
|
+
if (reason.includes("py_compile"))
|
|
3939
|
+
return (
|
|
3940
|
+
`Use python3 -I -S -m py_compile path/to/source.py with literal nonsensitive .py paths and passive consumers. ` +
|
|
3941
|
+
`Both -I and -S must precede -m; unknown flags, other modules and stdin file lists are unsupported. ` +
|
|
3942
|
+
`This compiles syntax without executing target source, but writes __pycache__ even with -B and may quote source in errors. ` +
|
|
3943
|
+
`The guard has not inspected source contents or resolved symlinks.`
|
|
3944
|
+
);
|
|
3945
|
+
if (reason.includes("jq"))
|
|
3946
|
+
return (
|
|
3947
|
+
`Use a literal inline jq filter within the supported passive grammar and known formatting/input flags. ` +
|
|
3948
|
+
`Field traversal, // fallback, nested objects/arrays, map/select and length are supported. ` +
|
|
3949
|
+
`Environment reads, imports, program files, unknown calls and incomplete syntax cannot establish this proof. ` +
|
|
3950
|
+
`Use literal nonsensitive input paths and passive output consumers; field omission does not certify input contents. ` +
|
|
3951
|
+
`Native jq normally loads a home .jq file; trusted host configuration is assumed, not inspected. ` +
|
|
3952
|
+
`The exact child-process prefix env HOME=/dev/null jq avoids that user startup lookup; -L does not.`
|
|
3953
|
+
);
|
|
3954
|
+
if (reason.includes("local binding"))
|
|
3955
|
+
return (
|
|
3956
|
+
`Use separate commands with literal nonsensitive file paths, or a supported bounded literal file binding/loop. ` +
|
|
3957
|
+
`Command substitution and a binding used as an executable or script operand cannot establish this proof. ` +
|
|
3958
|
+
`The guard has not inspected external script contents; this refusal does not establish ambient environment access.`
|
|
3959
|
+
);
|
|
3960
|
+
if (reason.includes("runtime or module"))
|
|
3961
|
+
return (
|
|
3962
|
+
`Use a supported literal inline program with known runtime flags, or a repository-owned bounded operation. ` +
|
|
3963
|
+
`Unknown startup options and executable module forms remain unsupported. ` +
|
|
3964
|
+
`The guard has not inspected external script contents.`
|
|
3965
|
+
);
|
|
3450
3966
|
if (reason.startsWith("cannot prove") && reason.includes("shell")) {
|
|
3451
3967
|
return (
|
|
3452
3968
|
`Interactive and login shells load startup files, which can execute code or load credentials. ` +
|
|
@@ -3519,7 +4035,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
|
|
|
3519
4035
|
const dangerousReason = getDangerousBashReason(command);
|
|
3520
4036
|
if (dangerousReason) {
|
|
3521
4037
|
throw new Error(
|
|
3522
|
-
`\u{1F6AB} Command blocked: ${dangerousReason}
|
|
4038
|
+
`\u{1F6AB} Command blocked: ${dangerousReason}.\n\n` +
|
|
3523
4039
|
`Command: ${command}\n\n` +
|
|
3524
4040
|
`${dangerousCommandRecovery(command, dangerousReason)}\n\n` +
|
|
3525
4041
|
`Tool inventory and usage: https://github.com/blogic-cz/agent-tools#tools`,
|