@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.
@@ -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
- /kubeconfig/i,
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
- | { pipelines: string[][][]; redirects: { target: string; dynamic: boolean; pipeline: number }[] }
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) return undefined;
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.length === 9 &&
509
- argv.slice(0, 6).join(" ") === "bun run gh-tool pr review-triage --repo" &&
510
- /^[A-Za-z0-9_.-]+$/.test(argv[6] ?? "") &&
511
- argv[7] === "--pr" &&
512
- /^\d+$/.test(argv[8] ?? "") &&
513
- uses.every((index) => index === 8)
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
- ["shasum", "sha1sum", "sha256sum", "sha512sum", "md5sum", "cksum"].includes(name) &&
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
- if (!gitAdd && !reader && !checksum) return undefined;
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
- // Bun retains eval/loader controls around its tool operand; only a later -c is tool data.
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
- isJqObjectConstruction(argv)
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 isJqObjectConstruction(argv: string[]): boolean {
1510
- if (argv[0]?.split("/").at(-1) !== "jq") return false;
1511
- let index = 1;
1512
- // Options with values, program files, and unknown options cannot prove an inline filter.
1513
- while (/^-[rRcMsScn]+$/.test(argv[index] ?? "")) index++;
1514
- if (argv[index] === "--") index++;
1515
- const filter = argv[index] ?? "";
1516
- if (argv.slice(index + 1).some((arg) => arg.startsWith("-"))) return false;
1517
- // ponytail: one object-map level and one literal contains selector; no jq evaluator.
1518
- const field = String.raw`\.[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*`;
1519
- const key = String.raw`[A-Za-z_][A-Za-z0-9_]*`;
1520
- const flatEntry = String.raw`${key}(?:\s*:\s*${field})?`;
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 isJqObjectConstruction(argv);
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
- (argv) => argv?.[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt",
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[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt")
2157
- return index === 0 && argv.length >= 5;
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 false;
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
- (isJqObjectConstruction(argv) &&
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) || isStaticHerdrPrompt(command)) return false;
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(command) && mentionsInlineRuntime(command);
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}; this command might expose secrets.\n\n` +
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`,