@blogic-cz/agent-tools 1.6.3 → 1.6.5

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 CHANGED
@@ -668,6 +668,12 @@ Single literal `echo`/`printf` writes with output redirection and single `cat`/`
668
668
 
669
669
  Python JSON parsing, dictionary iteration, and printing are not themselves evidence of credential access. They follow the same access and execution rules as other supported inline programs. This intentionally permits ordinary JSON output that older versions refused. Unknown secrets in ordinary files can therefore reach arbitrary shell output; wrapper content checks do not protect that output. A refusal caused by unsupported syntax means the guard could not establish its policy conditions, not that it observed a secret being read.
670
670
 
671
+ The bounded Python subprocess proof supports one immutable literal argv binding followed by one `subprocess.run` call. A literal `cwd` is replayed as a quoted, success-gated directory change before the nested argv, so relative reader operands and named directories receive the ordinary path policies. Supported directories are literal relative or absolute paths, including quoted spaces. Empty, dynamic, Unicode, escaped, tilde and other unsupported directory forms refuse rather than dropping the context. Cwd proof recursively unwraps only supported command, RTK proxy and environment wrappers. Environment directory/split-string options and unproved wrapper forms refuse. Only supported passive text, reader, metadata, literal Bun script and blocked CLI roles receive nested replay; shell executors, inline interpreter programs, unproved sed programs, find execution actions and unknown command roles refuse. Nested blocked CLI, environment, sensitive-path and custom-command policies remain active. Outer shell redirects retain their original shell directory. Arbitrary multi-call Python and `check_output` programs remain unsupported; direct Git metadata commands provide index/tree inspection.
672
+
673
+ Supported runtime script/tool operands form an argument boundary: a later tool `-c` configuration flag is data. Genuine eval and loader flags, Bun exec/repl and unproved startup-option arity still refuse. Literal Herdr prompts can compose with the existing literal agent-get and pane-read forms and passive consumers. One literal `--sql` operand on the supported `bun run db-tool sql/query` shape can contain SQL braces; substitutions, duplicate SQL operands and unknown wrapper options receive no exception. SQL output pipelines require closed passive consumers, so shell, xargs and unknown executors refuse. Numeric `$?` can appear only in passive echo/printf data operands, with passive pipeline consumers. Explicitly approved environment names can appear in quoted equality or blankness tests, including the quoted empty default `${NAME:-}`. This does not add default-approved names or permit shell evaluators. One sed print program with numeric or escaped-regex addresses/ranges and known `-n`/`-E` flags is passive; execution/read/write callbacks, extra programs and script files receive no print proof. Local file bindings use that same closed print proof and never a generic sed-word fallback. Ordinary path and configured command policies still apply to all these forms.
674
+
675
+ Literal shell bodies receive recursive shared policy replay under exact `sh -c`/`bash -c` and `zsh -f -c` invocations, using only these bare executable names or their `/bin/` and `/usr/bin/` paths and known RTK/command wrappers. Both protected paths and blocked CLIs remain enforced inside the body, along with environment and custom-command policies. A conventional shell-family name or numeric version in an unproved executing operand/control position refuses; proved literal text, reader and file-metadata operands remain data. Only the listed sh/bash/zsh identities receive body replay; rbash/rksh/ksh93 and versioned names refuse, and arbitrary renamed executables are outside this identity proof. Replay permits only existing closed body roles/materializations, at most eight shell levels and 64 KiB per invocation. Unproved options, environment prefixes, active exec/nohup/sudo wrappers, positional/script/stdin forms, executable binders, control flow and outer directory changes around a wrapper refuse. A body-internal proved cd affects body readers; outer redirects retain the outer shell directory. This is a textual body proof, not verification of native binaries or all host startup code. In particular, zsh -f still sources the global zshenv file. For unsupported executable inventories, use separate literal metadata requests and literal candidate invocations; no program name or --version flag receives a special exception.
676
+
671
677
  A separate bounded Node proof supports literal `package.json` inventory loops that print `bin` metadata or use the supported scripts filter. The complete invocation must preserve loader identity through known passive commands, closed literal writers, or proved inventories. Startup/environment mutations, unknown commands, loader changes, and other dynamic loading remain unproved and are refused.
672
678
 
673
679
  ### Setup for OpenCode
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/credential-guard/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAmB,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAK5E,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAM/D,8CAA8C;AAC9C,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,+CAA+C;AAC/C,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B,CAAC;AAQF,+CAA+C;AAC/C,MAAM,MAAM,eAAe,GAAG;IAC5B,uBAAuB,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;IACxE,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC3E,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,sBAAsB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IACrD,iBAAiB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACjF,kBAAkB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD,kBAAkB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;CACxD,CAAC;AAgzEF,6CAA6C;AAC7C,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAErE;AAED,2CAA2C;AAC3C,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAEpE;AAED,2CAA2C;AAC3C,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAEpE;AAMD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,eAAe,CA4TrF;AAQD,kEAAkE;AAClE,eAAO,MAAM,uBAAuB,UAtpFD,SAAS,UAAU,UAAU,KAAK,IAspFM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YAxpFC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAwpFjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aA1pFE,MAAM,KAAK,OA0pFgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aA5pFE,MAAM,KAAK,OA4pFgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YA9pFC,MAAM,KAAK,OA8pF0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YAhqFC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAgqFf,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YAlqFC,MAAM,KAAK,OAkqFsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YApqFC,MAAM,KAAK,MAAM,GAAG,IAoqFa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/credential-guard/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAmB,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAK5E,OAAO,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAChE,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAM/D,8CAA8C;AAC9C,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,+CAA+C;AAC/C,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B,CAAC;AAQF,+CAA+C;AAC/C,MAAM,MAAM,eAAe,GAAG;IAC5B,uBAAuB,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;IACxE,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC3E,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,sBAAsB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IACrD,iBAAiB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACjF,kBAAkB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IACjD,kBAAkB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;CACxD,CAAC;AA2oFF,6CAA6C;AAC7C,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAErE;AAED,2CAA2C;AAC3C,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAEpE;AAED,2CAA2C;AAC3C,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAEpE;AAMD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,eAAe,CA4VrF;AAQD,kEAAkE;AAClE,eAAO,MAAM,uBAAuB,UAjhGD,SAAS,UAAU,UAAU,KAAK,IAihGM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YAnhGC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAmhGjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aArhGE,MAAM,KAAK,OAqhGgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aAvhGE,MAAM,KAAK,OAuhGgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YAzhGC,MAAM,KAAK,OAyhG0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YA3hGC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IA2hGf,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YA7hGC,MAAM,KAAK,OA6hGsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YA/hGC,MAAM,KAAK,MAAM,GAAG,IA+hGa,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "1.6.3",
3
+ "version": "1.6.5",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure platform, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -262,21 +262,12 @@ function isSafeLocalAssignmentCommand(words: string[]): boolean {
262
262
  return !argv.slice(1).some((arg) => /^-(?:exec|ok|delete|fprint|fprintf)/.test(arg));
263
263
  }
264
264
  if (name === "bun") return argv[1] === "run" && ["db-tool", "gh-tool"].includes(argv[2] ?? "");
265
+ if (name === "sed") return isReadonlySedPrint(argv);
265
266
  return (
266
267
  isPassiveTextCommand(argv) ||
267
268
  ["ls", "cat", "tail", "wc", "uniq", "cut", "mv"].includes(name) ||
268
269
  (name === "sort" &&
269
- !hasExecutionOption(argv.slice(1), ["compress-program", "files0-from", "output"], "o")) ||
270
- (name === "sed" &&
271
- argv
272
- .slice(1)
273
- .every(
274
- (arg) =>
275
- /^-[En]+$/.test(arg) ||
276
- /^s([/|]).*\1[^/|]*\1[gp]*$/.test(arg) ||
277
- /^[A-Za-z0-9_./-]+$/.test(arg),
278
- ) &&
279
- !argv.includes("-f"))
270
+ !hasExecutionOption(argv.slice(1), ["compress-program", "files0-from", "output"], "o"))
280
271
  );
281
272
  }
282
273
 
@@ -502,13 +493,7 @@ function isLiteralLoopCommand(words: string[], marker: string, value: string): b
502
493
  const format = passivePrintfFormatIndex(argv);
503
494
  return format !== undefined && uses.every((index) => index > format);
504
495
  }
505
- if (name === "herdr")
506
- return (
507
- ((argv.length === 4 && argv[1] === "agent" && argv[2] === "get") ||
508
- isLiteralHerdrPaneRead(argv)) &&
509
- /^[A-Za-z0-9_.:-]+$/.test(argv[3] ?? "") &&
510
- uses.every((index) => index === 3)
511
- );
496
+ if (name === "herdr") return isLiteralHerdrRead(argv) && uses.every((index) => index === 3);
512
497
  if (name === "bun")
513
498
  return (
514
499
  argv.length === 9 &&
@@ -537,6 +522,122 @@ function isLiteralHerdrPaneRead(argv: string[]): boolean {
537
522
  );
538
523
  }
539
524
 
525
+ function isLiteralHerdrRead(argv: string[]): boolean {
526
+ return (
527
+ ((argv.length === 4 && argv.slice(0, 3).join(" ") === "herdr agent get") ||
528
+ isLiteralHerdrPaneRead(argv)) &&
529
+ /^[A-Za-z0-9_.:-]+$/.test(argv[3] ?? "")
530
+ );
531
+ }
532
+
533
+ /** Conventional platform shell names and numeric versions; not arbitrary renamed executables. */
534
+ function isShellWord(word: string): boolean {
535
+ return /^(?:sh|bash|rbash|zsh|dash|ash|ksh|rksh|csh|tcsh|fish)(?:[.-]?[0-9]+(?:[.][0-9]+)*)?$/.test(
536
+ word.split("/").at(-1) ?? "",
537
+ );
538
+ }
539
+
540
+ /** Shell-named operands are data only under an existing non-executing argument role. */
541
+ function hasProvedShellDataOperands(argv: string[]): boolean {
542
+ const name = argv[0]?.split("/").at(-1) ?? "";
543
+ if (isPassiveTextCommand(argv) || isLiteralHerdrRead(argv) || isStaticNavigationCommand(argv))
544
+ return true;
545
+ if (["cat", "ls", "uniq", "cut"].includes(name)) return true;
546
+ if (["find", "wc", "sort"].includes(name)) return isProvedCwdSubprocessRole(argv);
547
+ return name === "test" && argv.length === 3 && argv[1] === "-e" && !/[$`]/.test(argv[2] ?? "");
548
+ }
549
+
550
+ const SHELL_IDENTITIES = new Set([
551
+ "sh",
552
+ "bash",
553
+ "zsh",
554
+ "/bin/sh",
555
+ "/usr/bin/sh",
556
+ "/bin/bash",
557
+ "/usr/bin/bash",
558
+ "/bin/zsh",
559
+ "/usr/bin/zsh",
560
+ ]);
561
+
562
+ /** Literal argv only. Undefined means a shell was found without a complete invocation proof. */
563
+ function literalShellBodies(command: string): string[] | undefined {
564
+ const parsed = parseStaticShellCommands(command);
565
+ const operands = parsed ?? parseStaticShellCommands(command, true);
566
+ if (!operands || typeof operands === "string") return [];
567
+ const commands = operands.pipelines.flat();
568
+ if (
569
+ commands.some((words) =>
570
+ ["exec", "nohup", "sudo"].includes(unwrapStaticCommand(words)[0]?.split("/").at(-1) ?? ""),
571
+ )
572
+ )
573
+ return undefined;
574
+ for (const words of commands) {
575
+ const argv = unwrapStaticCommand(words);
576
+ if (
577
+ !isShellWord(argv[0] ?? "") &&
578
+ argv.some(
579
+ (word, index) =>
580
+ index !== inlineProgramIndex(argv) &&
581
+ (isShellWord(word) ||
582
+ (/^--?[A-Za-z0-9-]+=/.test(word) && isShellWord(word.slice(word.indexOf("=") + 1)))),
583
+ ) &&
584
+ !hasProvedShellDataOperands(argv) &&
585
+ !isStaticHerdrPrompt(command)
586
+ )
587
+ return undefined;
588
+ }
589
+ const shells = commands.filter((words) => isShellWord(unwrapStaticCommand(words)[0] ?? ""));
590
+ if (!shells.length) return [];
591
+ if (
592
+ !parsed ||
593
+ typeof parsed === "string" ||
594
+ !literalBindingText(command, "", "") ||
595
+ new TextEncoder().encode(command).byteLength > 65_536 ||
596
+ commands.some((words) => unwrapStaticCommand(words)[0]?.split("/").at(-1) === "cd")
597
+ )
598
+ return undefined;
599
+ const bodies: string[] = [];
600
+ for (const words of shells) {
601
+ const argv = unwrapProofCommand(words, "\0");
602
+ if (!argv || !SHELL_IDENTITIES.has(argv[0] ?? "")) return undefined;
603
+ const name = argv[0]?.split("/").at(-1);
604
+ if (argv.length === 2 && ["--version", "--help"].includes(argv[1] ?? "")) continue;
605
+ const index = name === "zsh" && argv[1] === "-f" ? 2 : 1;
606
+ if ((name === "zsh" && index !== 2) || argv[index] !== "-c" || argv.length !== index + 2)
607
+ return undefined;
608
+ bodies.push(argv[index + 1] ?? "");
609
+ }
610
+ return bodies;
611
+ }
612
+
613
+ function isProvedLiteralShellBody(body: string): boolean {
614
+ if (!body.trim()) return true;
615
+ const materialized = materializeLiteralShell(body)?.command ?? body;
616
+ const parsed = parseStaticShellCommands(materialized);
617
+ if (!parsed || typeof parsed === "string" || !hasProvedNavigationSequence(materialized))
618
+ return false;
619
+ if (isStaticHerdrPrompt(materialized)) return true;
620
+ return parsed.pipelines.flat().every((words) => {
621
+ const argv = unwrapProofCommand(words, "\0");
622
+ if (!argv) return false;
623
+ if (argv[0]?.split("/").at(-1) === "env") return false;
624
+ const name = unwrapStaticCommand(argv)[0]?.split("/").at(-1) ?? "";
625
+ if (isShellWord(name))
626
+ return (
627
+ literalShellBodies(
628
+ words.map((word) => "'" + word.replaceAll("'", "'\"'\"'") + "'").join(" "),
629
+ ) !== undefined
630
+ );
631
+ return (
632
+ isProvedCwdSubprocessRole(unwrapStaticCommand(argv)) ||
633
+ (name === "bun" && isSafeLocalAssignmentCommand(argv) && !isUnsupportedInlineRuntime(argv)) ||
634
+ isStaticNavigationCommand(argv) ||
635
+ isLiteralHerdrRead(argv) ||
636
+ (argv.length === 1 && ["true", "false", "pwd"].includes(name))
637
+ );
638
+ });
639
+ }
640
+
540
641
  /** A complete stdin-only JSON projection, with no suffix, imports or executable input. */
541
642
  function isLiteralStdinJsonDisplay(argv: string[]): boolean {
542
643
  if (argv.length !== 3 || !["python", "python3"].includes(argv[0] ?? "") || argv[1] !== "-c")
@@ -852,15 +953,54 @@ function inlineProgramIndex(argv: string[]): number | undefined {
852
953
  }
853
954
 
854
955
  function isUnsupportedInlineRuntime(argv: string[]): boolean {
855
- if (!inlineRuntimeKind(argv[0])) return false;
956
+ const runtime = inlineRuntimeKind(argv[0]);
957
+ if (!runtime) return false;
856
958
  if (inlineProgramIndex(argv) !== undefined) return false;
857
- return argv
858
- .slice(1)
859
- .some((arg) =>
959
+ let index = 1;
960
+ for (; index < argv.length; index++) {
961
+ const arg = argv[index] ?? "";
962
+ if (arg === "--" || !arg.startsWith("-")) break;
963
+ if (
860
964
  /^(?:-[A-Za-z]*[cep]|-[rmp]|--(?:eval|print|require|import|loader|preload|experimental-loader))(?:$|=|[^-])/.test(
861
965
  arg,
862
- ),
863
- );
966
+ )
967
+ )
968
+ return true;
969
+ if (
970
+ (runtime === "python" && arg === "--check-hash-based-pycs") ||
971
+ (runtime === "node" &&
972
+ ["--conditions", "-C", "--icu-data-dir", "--dns-result-order"].includes(arg))
973
+ ) {
974
+ if (argv[index + 1] === undefined) return true;
975
+ index++;
976
+ continue;
977
+ }
978
+ // Unknown startup-option arity cannot establish where the script/tool begins.
979
+ if (
980
+ !(
981
+ (runtime === "python" && /^-[uBISsEOqvV]+$/.test(arg)) ||
982
+ (runtime === "node" &&
983
+ /^(?:--(?:trace-warnings|no-warnings|version|help)|--(?:conditions|icu-data-dir|dns-result-order)=.+)$/.test(
984
+ arg,
985
+ )) ||
986
+ (runtime === "bun" && ["--no-env-file", "--version", "--help"].includes(arg)) ||
987
+ ["--version", "--help", "-h"].includes(arg)
988
+ )
989
+ )
990
+ return true;
991
+ }
992
+ if (runtime === "bun" && ["exec", "repl"].includes(argv[index] ?? "")) return true;
993
+ // Bun retains eval/loader controls around its tool operand; only a later -c is tool data.
994
+ return (
995
+ runtime === "bun" &&
996
+ argv
997
+ .slice(index)
998
+ .some((arg) =>
999
+ /^(?:-[A-Za-z]*[ep]|-[rmp]|--(?:eval|print|require|import|loader|preload|experimental-loader))(?:$|=|[^-])/.test(
1000
+ arg,
1001
+ ),
1002
+ )
1003
+ );
864
1004
  }
865
1005
 
866
1006
  /** Move a complete, literal Python stdin program into the existing inline argv boundary. */
@@ -1045,7 +1185,64 @@ function staticSubprocessCommand(program: string): string | undefined {
1045
1185
  (match) => match[1] ?? match[2] ?? "",
1046
1186
  );
1047
1187
  if (!values.length) return undefined;
1048
- return values.map((value) => "'" + value.replaceAll("'", "'\"'\"'") + "'").join(" ");
1188
+ const quote = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'";
1189
+ const command = values.map(quote).join(" ");
1190
+ const directories = [...call[0].matchAll(/\bcwd\s*=\s*(?:"([^"\\]*)"|'([^'\\]*)')/g)];
1191
+ if (!directories.length) return command;
1192
+ const cwd = directories[0]?.[1] ?? directories[0]?.[2] ?? "";
1193
+ // Never replay a cwd-bearing call as if it ran in the original shell directory.
1194
+ if (directories.length !== 1 || !isStaticNavigationCommand(["cd", "--", cwd])) return undefined;
1195
+ const argv = unwrapCwdSubprocessCommand(values);
1196
+ if (!argv || !isProvedCwdSubprocessRole(argv)) return undefined;
1197
+ // Keep wrapper assignments/options in policy replay after proving the nested command role.
1198
+ const replay = `cd -- ${quote(cwd)} && ${command}`;
1199
+ return hasProvedNavigationSequence(replay) ? replay : undefined;
1200
+ }
1201
+
1202
+ function unwrapCwdSubprocessCommand(words: string[]): string[] | undefined {
1203
+ let argv = words;
1204
+ // Each successful wrapper removes words; the original word count bounds recursion.
1205
+ for (let depth = 0; depth < words.length; depth++) {
1206
+ const unwrapped = unwrapProofCommand(argv, "\0");
1207
+ if (!unwrapped) return undefined;
1208
+ argv = unwrapped;
1209
+ if (argv[0]?.split("/").at(-1) !== "env") return argv;
1210
+ const nested = unwrapEnvironmentCommand(argv);
1211
+ if (!nested) return undefined;
1212
+ // Inspect only env's prefix, not options belonging to its child command.
1213
+ // env directory, split-string and unknown options do not preserve the proved context.
1214
+ if (
1215
+ argv
1216
+ .slice(1, argv.length - nested.length)
1217
+ .some(
1218
+ (arg) =>
1219
+ arg.startsWith("-") && arg !== "--" && arg !== "-i" && arg !== "--ignore-environment",
1220
+ )
1221
+ )
1222
+ return undefined;
1223
+ argv = nested;
1224
+ }
1225
+ return undefined;
1226
+ }
1227
+
1228
+ function isProvedCwdSubprocessRole(argv: string[]): boolean {
1229
+ const name = argv[0]?.split("/").at(-1) ?? "";
1230
+ if (inlineProgramIndex(argv) !== undefined || isUnsupportedInlineRuntime(argv)) return false;
1231
+ if (isPassiveTextCommand(argv)) return true;
1232
+ if (name === "find") return isSafeLocalAssignmentCommand(argv);
1233
+ if (["ls", "cat", "uniq", "cut"].includes(name)) return true;
1234
+ if (name === "wc") return !hasExecutionOption(argv.slice(1), ["files0-from"]);
1235
+ if (name === "sort")
1236
+ return !hasExecutionOption(argv.slice(1), ["compress-program", "files0-from"]);
1237
+ if (name === "git") {
1238
+ let index = 1;
1239
+ while (argv[index] === "-C" && argv[index + 1]) index += 2;
1240
+ return ["ls-files", "ls-tree", "status", "rev-parse", "rev-list"].includes(argv[index] ?? "");
1241
+ }
1242
+ // A literal Bun script keeps the existing supported test-runner route.
1243
+ if (name === "bun") return /^[A-Za-z0-9_./-]+\.(?:[cm]?[jt]s)$/.test(argv[1] ?? "");
1244
+ // Retain extraction so the nested CLI policy returns its ordinary descriptor.
1245
+ return DEFAULT_BLOCKED_CLI_TOOLS.some((tool) => tool.name === name);
1049
1246
  }
1050
1247
 
1051
1248
  function inlineSubprocessCommands(command: string): string[] {
@@ -1207,6 +1404,9 @@ function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
1207
1404
  argv[4] === "query"
1208
1405
  )
1209
1406
  return false;
1407
+ const sql = literalSqlArgumentIndex(argv);
1408
+ if (sql !== undefined)
1409
+ return argv.some((arg, index) => index !== sql && hasArgumentBraceExpansion(arg));
1210
1410
  return (argv[0]?.split("/").at(-1) === "awk" && !isAwkExecution(argv)) ||
1211
1411
  inlineProgramIndex(argv) !== undefined ||
1212
1412
  isJqObjectConstruction(argv)
@@ -1214,6 +1414,32 @@ function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
1214
1414
  : hasArgumentBraceExpansion(argv.join(" "));
1215
1415
  }
1216
1416
 
1417
+ function literalSqlArgumentIndex(argv: string[]): number | undefined {
1418
+ if (
1419
+ argv[0]?.split("/").at(-1) !== "bun" ||
1420
+ argv[1] !== "run" ||
1421
+ argv[2] !== "db-tool" ||
1422
+ !["sql", "query"].includes(argv[3] ?? "")
1423
+ )
1424
+ return undefined;
1425
+ let sql: number | undefined;
1426
+ const seen = new Set<string>();
1427
+ for (let index = 4; index < argv.length; index++) {
1428
+ const [option = "", ...attached] = (argv[index] ?? "").split("=");
1429
+ if (
1430
+ !["--sql", "--env", "--profile", "--limit", "--format"].includes(option) ||
1431
+ seen.has(option)
1432
+ )
1433
+ return undefined;
1434
+ seen.add(option);
1435
+ const value = attached.length ? attached.join("=") : argv[++index];
1436
+ if (!value || value.startsWith("--")) return undefined;
1437
+ if (option === "--sql") sql = index;
1438
+ else if (!/^[A-Za-z0-9_./-]+$/.test(value)) return undefined;
1439
+ }
1440
+ return sql;
1441
+ }
1442
+
1217
1443
  /** echo and printf print their arguments; grep -o, rg and git grep print matched pattern text. */
1218
1444
  function isLiteralTextProducer(argv: string[]): boolean {
1219
1445
  return isPassiveTextCommand(argv) && argv[0]?.split("/").at(-1) !== "head";
@@ -1584,7 +1810,8 @@ function hasProvedNavigationSequence(command: string): boolean {
1584
1810
  return false;
1585
1811
  if (!changesDirectory.length) return true;
1586
1812
  // Only the final AND list can use this directory. A failing cd never reaches its readers.
1587
- const syntax = literal.syntax.replace(/[ \t\n]+$/g, "");
1813
+ // Trim real trailing whitespace, not the spaces masking final quoted argv words.
1814
+ const syntax = literal.syntax.slice(0, command.replace(/[ \t\n]+$/g, "").length);
1588
1815
  const boundary = Math.max(syntax.lastIndexOf(";"), syntax.lastIndexOf("\n"));
1589
1816
  const tail = parseStaticShellCommands(command.slice(boundary + 1, syntax.length));
1590
1817
  if (!tail || typeof tail === "string" || tail.pipelines.length < 2) return false;
@@ -1608,6 +1835,7 @@ function isPassiveTextCommand(argv: string[]): boolean {
1608
1835
  }
1609
1836
  if (name === "rg") return !hasExecutionOption(args, ["pre", "hostname-bin"]);
1610
1837
  if (name === "awk") return !isAwkExecution(argv);
1838
+ if (name === "sed") return isReadonlySedPrint(argv);
1611
1839
  if (name === "jq") return isJqObjectConstruction(argv);
1612
1840
  return (
1613
1841
  (name === "git" && args[0] === "status") ||
@@ -1617,6 +1845,19 @@ function isPassiveTextCommand(argv: string[]): boolean {
1617
1845
  );
1618
1846
  }
1619
1847
 
1848
+ /** One print program with optional numeric or escaped-regex addresses; no callbacks. */
1849
+ function isReadonlySedPrint(argv: string[]): boolean {
1850
+ if (argv[0]?.split("/").at(-1) !== "sed") return false;
1851
+ let index = 1;
1852
+ while (/^-[nE]+$/.test(argv[index] ?? "")) index++;
1853
+ if (argv[index] === "--") index++;
1854
+ const address = String.raw`(?:[0-9]+|\$|/(?:\\[^\r\n]|[^/\\\r\n])*/)`;
1855
+ return (
1856
+ new RegExp(`^(?:${address}(?:,${address})?)?p$`).test(argv[index] ?? "") &&
1857
+ argv.slice(index + 1).every((arg) => !arg.startsWith("-"))
1858
+ );
1859
+ }
1860
+
1620
1861
  function hasStaticEnvironmentRead(
1621
1862
  argv: string[],
1622
1863
  allowedNames: Set<string>,
@@ -1702,6 +1943,7 @@ function isStaticHerdrPrompt(command: string): boolean {
1702
1943
  argv.slice(1).every((arg) => arg === "--" || !arg.startsWith("-"))
1703
1944
  );
1704
1945
  if (isPassiveTextCommand(argv)) return true;
1946
+ if (isLiteralHerdrRead(argv)) return index === 0;
1705
1947
  if (isStaticNavigationCommand(argv))
1706
1948
  return index === 0 && (argv[0] !== "cd" || pipeline.length === 1);
1707
1949
  return false;
@@ -1724,6 +1966,7 @@ function hasEnvironmentRead(
1724
1966
  command: string,
1725
1967
  allowedNames: Set<string>,
1726
1968
  allowInventory = hasProvedPackageInventoryInvocation(command),
1969
+ replayedShellBodies: string[] = [],
1727
1970
  ): boolean {
1728
1971
  if (isStaticHerdrPrompt(command)) return false;
1729
1972
  if (isHerdrPrompt(command)) return true;
@@ -1732,6 +1975,26 @@ function hasEnvironmentRead(
1732
1975
  const parsed = parseStaticShellCommands(command);
1733
1976
  if (parsed === "brace-expansion") return true;
1734
1977
  if (!parsed) {
1978
+ const operands = parseStaticShellCommands(command, true);
1979
+ if (
1980
+ operands &&
1981
+ typeof operands !== "string" &&
1982
+ operands.pipelines.flat().some((words) => {
1983
+ const argv = unwrapStaticCommand(words);
1984
+ return (
1985
+ argv[0]?.split("/").at(-1) === "bun" &&
1986
+ argv[1] === "run" &&
1987
+ argv[2] === "db-tool" &&
1988
+ ["sql", "query"].includes(argv[3] ?? "") &&
1989
+ argv.some(
1990
+ (arg, index) =>
1991
+ (arg === "--sql" && /\$\(|`/.test(argv[index + 1] ?? "")) ||
1992
+ (arg.startsWith("--sql=") && /\$\(|`/.test(arg)),
1993
+ )
1994
+ );
1995
+ })
1996
+ )
1997
+ return true;
1735
1998
  const unquoted = command.replace(/'(?:\\.|[^'])*'/g, " ").replace(/"(?:\\.|[^"$`])*"/g, " ");
1736
1999
  const hasExecutor =
1737
2000
  /\|\s*(?:sh|bash|zsh|xargs)|\b(?:sh|bash|zsh)\b|\$\(|`|\b(?:eval|source)\b|\bfind\b.*\b-exec\b/i.test(
@@ -1745,6 +2008,16 @@ function hasEnvironmentRead(
1745
2008
  }
1746
2009
  const pipelines = parsed.pipelines.map((commands) => commands.map(unwrapStaticCommand));
1747
2010
  const unwrapped = pipelines.flat();
2011
+ if (
2012
+ pipelines.some((pipeline) =>
2013
+ pipeline.some(
2014
+ (argv, index) =>
2015
+ literalSqlArgumentIndex(argv) !== undefined &&
2016
+ pipeline.slice(index + 1).some((consumer) => !isPassiveTextCommand(consumer)),
2017
+ ),
2018
+ )
2019
+ )
2020
+ return true;
1748
2021
  if (
1749
2022
  parsed.redirects.length > 0 &&
1750
2023
  unwrapped.some((argv) => isAllowedEnvironmentRead(argv, allowedNames))
@@ -1762,7 +2035,13 @@ function hasEnvironmentRead(
1762
2035
  ) {
1763
2036
  return true;
1764
2037
  }
1765
- if (unwrapped.some((argv) => hasStaticEnvironmentRead(argv, allowedNames, allowInventory))) {
2038
+ if (
2039
+ unwrapped.some(
2040
+ (argv) =>
2041
+ !(SHELL_IDENTITIES.has(argv[0] ?? "") && replayedShellBodies.includes(argv.at(-1) ?? "")) &&
2042
+ hasStaticEnvironmentRead(argv, allowedNames, allowInventory),
2043
+ )
2044
+ ) {
1766
2045
  return true;
1767
2046
  }
1768
2047
 
@@ -2146,7 +2425,13 @@ function hasEnvironmentVariableExpansionRead(
2146
2425
  normalized += char;
2147
2426
  continue;
2148
2427
  }
2149
- const variable = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}|^\$([A-Za-z_][A-Za-z0-9_]*)/.exec(
2428
+ if (command[i + 1] === "?") {
2429
+ expansions++;
2430
+ normalized += "NUMERICSTATUSVALUE";
2431
+ i++;
2432
+ continue;
2433
+ }
2434
+ const variable = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(:-)?\}|^\$([A-Za-z_][A-Za-z0-9_]*)/.exec(
2150
2435
  command.slice(i),
2151
2436
  );
2152
2437
  if (!variable) {
@@ -2157,7 +2442,13 @@ function hasEnvironmentVariableExpansionRead(
2157
2442
  continue;
2158
2443
  }
2159
2444
  expansions++;
2160
- const name = variable[1] ?? variable[2] ?? "";
2445
+ const name = variable[1] ?? variable[3] ?? "";
2446
+ if (variable[2]) {
2447
+ if (quote !== '"' || !allowedNames.has(name)) invalidExpansion = true;
2448
+ normalized += "ENVTESTVALUE";
2449
+ i += variable[0].length - 1;
2450
+ continue;
2451
+ }
2161
2452
  const assignment = assignments.get(name);
2162
2453
  if (assignment && assignment.end >= i) invalidExpansion = true;
2163
2454
  if (assignment && assignment.end < i) {
@@ -2182,6 +2473,39 @@ function hasEnvironmentVariableExpansionRead(
2182
2473
  if (!hasSafeLocalAssignmentCommands(localMaterialized, assignments)) return true;
2183
2474
  const parsed = parseStaticShellCommands(localMaterialized);
2184
2475
  if (!parsed || typeof parsed === "string") return true;
2476
+ for (const [index, pipeline] of parsed.pipelines.entries()) {
2477
+ if (!pipeline.some((words) => words.some((arg) => arg.includes("NUMERICSTATUSVALUE"))))
2478
+ continue;
2479
+ if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
2480
+ if (
2481
+ pipeline.some((words) => {
2482
+ const argv = unwrapStaticCommand(words);
2483
+ if (!words.some((arg) => arg.includes("NUMERICSTATUSVALUE")))
2484
+ return !isPassiveTextCommand(argv);
2485
+ const name = argv[0]?.split("/").at(-1);
2486
+ const format = name === "printf" ? passivePrintfFormatIndex(argv) : undefined;
2487
+ return (
2488
+ !["echo", "printf"].includes(name ?? "") ||
2489
+ !isPassiveTextCommand(argv) ||
2490
+ words
2491
+ .slice(0, words.length - argv.length)
2492
+ .some((arg) => arg.includes("NUMERICSTATUSVALUE")) ||
2493
+ argv[0]?.includes("NUMERICSTATUSVALUE") === true ||
2494
+ (name === "printf" &&
2495
+ (format === undefined ||
2496
+ argv.slice(0, format + 1).some((arg) => arg.includes("NUMERICSTATUSVALUE"))))
2497
+ );
2498
+ })
2499
+ )
2500
+ return true;
2501
+ }
2502
+ for (const words of parsed.pipelines.flat()) {
2503
+ if (
2504
+ words.some((arg) => arg.includes("ENVTESTVALUE")) &&
2505
+ !isApprovedEnvironmentPredicate(unwrapStaticCommand(words))
2506
+ )
2507
+ return true;
2508
+ }
2185
2509
  for (const words of parsed.pipelines.flat()) {
2186
2510
  if (!words.some((arg) => arg.includes("UNKNOWNVALUE"))) continue;
2187
2511
  if (words.some((arg) => arg.includes("UNKNOWNVALUEUNQUOTED"))) return true;
@@ -2192,7 +2516,7 @@ function hasEnvironmentVariableExpansionRead(
2192
2516
  )
2193
2517
  return true;
2194
2518
  }
2195
- if (!localMaterialized.includes("ENVVALUE")) {
2519
+ if (!localMaterialized.includes("ENVVALUE") && !localMaterialized.includes("ENVTESTVALUE")) {
2196
2520
  return (
2197
2521
  hasSensitiveFileRead(localMaterialized, isPathBlocked) ||
2198
2522
  hasSensitivePathRedirect(localMaterialized, isPathBlocked) ||
@@ -2202,7 +2526,7 @@ function hasEnvironmentVariableExpansionRead(
2202
2526
 
2203
2527
  for (const [index, pipeline] of parsed.pipelines.entries()) {
2204
2528
  const hasEnvironmentValue = pipeline.some((argv) =>
2205
- argv.some((arg) => arg.includes("ENVVALUE")),
2529
+ argv.some((arg) => arg.includes("ENVVALUE") || arg.includes("ENVTESTVALUE")),
2206
2530
  );
2207
2531
  if (!hasEnvironmentValue) continue;
2208
2532
  if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
@@ -2210,9 +2534,14 @@ function hasEnvironmentVariableExpansionRead(
2210
2534
  pipeline.some((argv) => {
2211
2535
  const unwrapped = unwrapStaticCommand(argv);
2212
2536
  const name = unwrapped[0]?.split("/").at(-1) ?? "";
2213
- if (argv.some((arg) => arg.includes("ENVVALUE"))) {
2214
- if (argv.slice(0, argv.length - unwrapped.length).some((arg) => arg.includes("ENVVALUE")))
2537
+ if (argv.some((arg) => arg.includes("ENVVALUE") || arg.includes("ENVTESTVALUE"))) {
2538
+ if (
2539
+ argv
2540
+ .slice(0, argv.length - unwrapped.length)
2541
+ .some((arg) => arg.includes("ENVVALUE") || arg.includes("ENVTESTVALUE"))
2542
+ )
2215
2543
  return true;
2544
+ if (isApprovedEnvironmentPredicate(unwrapped)) return false;
2216
2545
  if (isNavigationOperand(unwrapped, "ENVVALUE")) {
2217
2546
  return unwrapped.some((arg) => arg.includes("ENVVALUEUNQUOTED"));
2218
2547
  }
@@ -2239,6 +2568,24 @@ function hasEnvironmentVariableExpansionRead(
2239
2568
  return hasSensitiveFileRead(materialized, isPathBlocked);
2240
2569
  }
2241
2570
 
2571
+ /** Quoted approved metadata can be compared or checked for blankness, never evaluated. */
2572
+ function isApprovedEnvironmentPredicate(argv: string[]): boolean {
2573
+ let args = argv.slice(1);
2574
+ if (argv[0] === "[") {
2575
+ if (args.at(-1) !== "]") return false;
2576
+ args = args.slice(0, -1);
2577
+ } else if (argv[0] !== "test") return false;
2578
+ const value = (arg: string | undefined) => arg === "ENVVALUE" || arg === "ENVTESTVALUE";
2579
+ const literal = (arg: string | undefined) =>
2580
+ /^[A-Za-z0-9_.:-]*$/.test(arg ?? "") && arg !== undefined;
2581
+ return (
2582
+ (args.length === 2 && ["-n", "-z"].includes(args[0] ?? "") && value(args[1])) ||
2583
+ (args.length === 3 &&
2584
+ args[1] === "=" &&
2585
+ ((value(args[0]) && literal(args[2])) || (literal(args[0]) && value(args[2]))))
2586
+ );
2587
+ }
2588
+
2242
2589
  function mentionsEnvironmentSource(command: string): boolean {
2243
2590
  return /\b(?:os\s*\.\s*(?:environ|getenv)|process\s*\.\s*env|Environment\s*\.\s*GetEnvironmentVariable)\b/.test(
2244
2591
  command,
@@ -2511,7 +2858,7 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
2511
2858
  return getDangerousBashReason(command) !== null;
2512
2859
  }
2513
2860
 
2514
- function getDangerousBashReason(command: string): string | null {
2861
+ function getDangerousBashReason(command: string, shellDepth = 0): string | null {
2515
2862
  const originalCommand = command;
2516
2863
  const allowPackageInventory = hasProvedPackageInventoryInvocation(originalCommand);
2517
2864
  const rawEnvironmentSource =
@@ -2525,20 +2872,30 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
2525
2872
  if (rawEnvironmentSource && hasEnvironmentMutationPrefix(originalCommand))
2526
2873
  return "blocked by the environment/execution policy after an unproved environment mutation";
2527
2874
  command = normalizeProgramHeredocs(command);
2875
+ const shellBodies = literalShellBodies(command);
2876
+ if (!shellBodies || (shellBodies.length && shellDepth >= 8))
2877
+ return "cannot prove a bounded literal shell invocation";
2878
+ for (const body of shellBodies) {
2879
+ const reason = getDangerousBashReason(body, shellDepth + 1);
2880
+ if (reason || getBlockedCliTool(body, true, shellDepth + 1))
2881
+ return reason ?? "invokes a blocked CLI from a shell body";
2882
+ if (!isProvedLiteralShellBody(body)) return "cannot prove a closed literal shell body";
2883
+ }
2528
2884
  const proof = materializeLiteralShell(command);
2529
2885
  if (!proof && /^\s*for\b/.test(command)) return "cannot prove a bounded literal shell loop";
2530
2886
  if (proof) {
2531
2887
  if (proof.paths.some((path) => hasSensitiveFileRead("cat " + path, isPathBlocked)))
2532
2888
  return "accesses a sensitive file path";
2533
2889
  command = proof.command;
2534
- if (getBlockedCliTool(command)) return "invokes a blocked CLI from a literal expansion";
2890
+ if (getBlockedCliTool(command, true, shellDepth))
2891
+ return "invokes a blocked CLI from a literal expansion";
2535
2892
  }
2536
2893
  for (const nested of [
2537
2894
  ...inlineSubprocessCommands(command),
2538
2895
  ...inlinePackageCommands(command, allowPackageInventory),
2539
2896
  ]) {
2540
- const reason = getDangerousBashReason(nested);
2541
- if (reason || getBlockedCliTool(nested))
2897
+ const reason = getDangerousBashReason(nested, shellDepth);
2898
+ if (reason || getBlockedCliTool(nested, true, shellDepth))
2542
2899
  return reason ?? "invokes a blocked CLI from a script";
2543
2900
  }
2544
2901
  if (
@@ -2554,7 +2911,21 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
2554
2911
  }
2555
2912
  if (
2556
2913
  hasEnvironmentSourceAccess(command, rawEnvironmentSource, allowedEnvironmentVariables) ||
2557
- hasEnvironmentRead(command, allowedEnvironmentVariables, allowPackageInventory)
2914
+ hasEnvironmentRead(
2915
+ command,
2916
+ allowedEnvironmentVariables,
2917
+ allowPackageInventory,
2918
+ shellBodies.filter((body) => {
2919
+ const parsed = parseStaticShellCommands(body);
2920
+ return (
2921
+ parsed &&
2922
+ typeof parsed !== "string" &&
2923
+ parsed.pipelines
2924
+ .flat()
2925
+ .every((words) => isPassiveTextCommand(unwrapStaticCommand(words)))
2926
+ );
2927
+ }),
2928
+ )
2558
2929
  ) {
2559
2930
  return "blocked by the environment/execution policy; permitted access or non-execution could not be established for this command form";
2560
2931
  }
@@ -2596,16 +2967,24 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
2596
2967
  function getBlockedCliTool(
2597
2968
  command: string,
2598
2969
  expand = true,
2970
+ shellDepth = 0,
2599
2971
  ): { name: string; wrapper: string } | null {
2600
2972
  command = normalizeProgramHeredocs(command);
2973
+ const shellBodies = literalShellBodies(command);
2974
+ if (shellBodies && shellDepth < 8) {
2975
+ for (const body of shellBodies) {
2976
+ const blocked = getBlockedCliTool(body, true, shellDepth + 1);
2977
+ if (blocked) return blocked;
2978
+ }
2979
+ }
2601
2980
  const proof = expand ? materializeLiteralShell(command) : undefined;
2602
2981
  if (proof) {
2603
- const originalBlocked = getBlockedCliTool(command, false);
2982
+ const originalBlocked = getBlockedCliTool(command, false, shellDepth);
2604
2983
  if (originalBlocked) return originalBlocked;
2605
2984
  command = proof.command;
2606
2985
  }
2607
2986
  for (const nested of inlineSubprocessCommands(command)) {
2608
- const blocked = getBlockedCliTool(nested);
2987
+ const blocked = getBlockedCliTool(nested, true, shellDepth);
2609
2988
  if (blocked) return blocked;
2610
2989
  }
2611
2990
  const staticCommands = parseStaticShellCommands(command);