@blogic-cz/agent-tools 1.6.2 → 1.6.4

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
@@ -656,6 +656,8 @@ Ordinary navigation operands such as `cd "$HOME"` and `git -C "$WORKTREE_ROOT" s
656
656
 
657
657
  Literal `herdr agent prompt` commands can be composed with independent `cat` readers and passive text pipelines. Prompt text stays data; command substitutions, environment mutation, unknown consumers, and executable pipelines cannot establish the proof. All reader operands and redirects still receive the ordinary sensitive-path checks. Literal loops also support `herdr pane read ID --source recent-unwrapped --lines N`, with the binder restricted to `ID`, and `2>&1` file-descriptor duplication. A bounded Python stdin processor supports only `import json,sys; d=json.load(sys.stdin); print(d["result"].get("text","") if isinstance(d.get("result"),dict) else d)`, including insignificant spacing and single lowercase-letter local renaming. Extra imports, statements, calls, runtime options, arguments, and other Python programs cannot establish this loop proof. The existing loop size and command limits apply.
658
658
 
659
+ Prompt composition also supports one `cd [--] PATH` with a literal relative or absolute directory at the start of the final `&&` chain. All subsequent inspection must remain in that chain, so a failed `cd` cannot reach its readers. Directory names may contain quoted spaces. Multiple `cd` commands, later `;` or newline boundaries, tilde or variable expansion, other `cd` options, background jobs, `||`, and navigation in pipelines remain unproved. Supported Git inspection can use literal `-C PATH` prefixes with `grep`, `status`, `rev-parse`, or `rev-list`; execution and startup options remain unsupported. Navigation composition refuses relative file redirects, including redirects on `cd` and Git commands, because those use the shell directory before the command runs. Absolute redirects still receive ordinary path checks. Named directories receive sensitive-path checks with directory-prefix semantics. Only a proved `cd` chain qualifies relative reader operands with that directory; Git `-C` qualifies that command's file operands without changing shell state. Git grep uses the shared grep pattern-versus-file checks. This checks textual operands and does not resolve aliases, symlinks, ambient directories, or command lookup.
660
+
659
661
  The guard recognizes `rtk`, `rtk proxy`, `command`, and supported `env` prefixes and checks commands in pipelines and chains. Quoted filenames, comments, and redirects retain sensitive-path checks. Jq object selectors and inline awk programs qualify only when their actual program and options establish passive behavior. Literal environment-command text in `rg`, `grep`, `git grep`, `echo`, and `printf` is allowed only with supported command roles and passive pipeline consumers. Environment-source evidence and startup identity checks also apply to the complete original invocation. Custom dangerous-command patterns apply to both the original input and proved expansions.
660
662
 
661
663
  Paths are checked against sensitive filename patterns and configured path rules. Supported proofs apply those checks to their expanded operands. Ordinary parent-directory and absolute paths may be allowed; the guard does not resolve filesystem paths or symlinks, or enforce repository confinement.
@@ -666,6 +668,10 @@ Single literal `echo`/`printf` writes with output redirection and single `cat`/`
666
668
 
667
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.
668
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
+
669
675
  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.
670
676
 
671
677
  ### 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;AA6tEF,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,UAnkFD,SAAS,UAAU,UAAU,KAAK,IAmkFM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YArkFC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAqkFjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aAvkFE,MAAM,KAAK,OAukFgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aAzkFE,MAAM,KAAK,OAykFgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YA3kFC,MAAM,KAAK,OA2kF0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YA7kFC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IA6kFf,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YA/kFC,MAAM,KAAK,OA+kFsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YAjlFC,MAAM,KAAK,MAAM,GAAG,IAilFa,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;AAwhFF,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,UA93FD,SAAS,UAAU,UAAU,KAAK,IA83FM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YAh4FC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAg4FjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aAl4FE,MAAM,KAAK,OAk4FgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aAp4FE,MAAM,KAAK,OAo4FgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YAt4FC,MAAM,KAAK,OAs4F0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YAx4FC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAw4Ff,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YA14FC,MAAM,KAAK,OA04FsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YA54FC,MAAM,KAAK,MAAM,GAAG,IA44Fa,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "1.6.2",
3
+ "version": "1.6.4",
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,14 @@ 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
+
540
533
  /** A complete stdin-only JSON projection, with no suffix, imports or executable input. */
541
534
  function isLiteralStdinJsonDisplay(argv: string[]): boolean {
542
535
  if (argv.length !== 3 || !["python", "python3"].includes(argv[0] ?? "") || argv[1] !== "-c")
@@ -852,15 +845,54 @@ function inlineProgramIndex(argv: string[]): number | undefined {
852
845
  }
853
846
 
854
847
  function isUnsupportedInlineRuntime(argv: string[]): boolean {
855
- if (!inlineRuntimeKind(argv[0])) return false;
848
+ const runtime = inlineRuntimeKind(argv[0]);
849
+ if (!runtime) return false;
856
850
  if (inlineProgramIndex(argv) !== undefined) return false;
857
- return argv
858
- .slice(1)
859
- .some((arg) =>
851
+ let index = 1;
852
+ for (; index < argv.length; index++) {
853
+ const arg = argv[index] ?? "";
854
+ if (arg === "--" || !arg.startsWith("-")) break;
855
+ if (
860
856
  /^(?:-[A-Za-z]*[cep]|-[rmp]|--(?:eval|print|require|import|loader|preload|experimental-loader))(?:$|=|[^-])/.test(
861
857
  arg,
862
- ),
863
- );
858
+ )
859
+ )
860
+ return true;
861
+ if (
862
+ (runtime === "python" && arg === "--check-hash-based-pycs") ||
863
+ (runtime === "node" &&
864
+ ["--conditions", "-C", "--icu-data-dir", "--dns-result-order"].includes(arg))
865
+ ) {
866
+ if (argv[index + 1] === undefined) return true;
867
+ index++;
868
+ continue;
869
+ }
870
+ // Unknown startup-option arity cannot establish where the script/tool begins.
871
+ if (
872
+ !(
873
+ (runtime === "python" && /^-[uBISsEOqvV]+$/.test(arg)) ||
874
+ (runtime === "node" &&
875
+ /^(?:--(?:trace-warnings|no-warnings|version|help)|--(?:conditions|icu-data-dir|dns-result-order)=.+)$/.test(
876
+ arg,
877
+ )) ||
878
+ (runtime === "bun" && ["--no-env-file", "--version", "--help"].includes(arg)) ||
879
+ ["--version", "--help", "-h"].includes(arg)
880
+ )
881
+ )
882
+ return true;
883
+ }
884
+ if (runtime === "bun" && ["exec", "repl"].includes(argv[index] ?? "")) return true;
885
+ // Bun retains eval/loader controls around its tool operand; only a later -c is tool data.
886
+ return (
887
+ runtime === "bun" &&
888
+ argv
889
+ .slice(index)
890
+ .some((arg) =>
891
+ /^(?:-[A-Za-z]*[ep]|-[rmp]|--(?:eval|print|require|import|loader|preload|experimental-loader))(?:$|=|[^-])/.test(
892
+ arg,
893
+ ),
894
+ )
895
+ );
864
896
  }
865
897
 
866
898
  /** Move a complete, literal Python stdin program into the existing inline argv boundary. */
@@ -1045,7 +1077,64 @@ function staticSubprocessCommand(program: string): string | undefined {
1045
1077
  (match) => match[1] ?? match[2] ?? "",
1046
1078
  );
1047
1079
  if (!values.length) return undefined;
1048
- return values.map((value) => "'" + value.replaceAll("'", "'\"'\"'") + "'").join(" ");
1080
+ const quote = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'";
1081
+ const command = values.map(quote).join(" ");
1082
+ const directories = [...call[0].matchAll(/\bcwd\s*=\s*(?:"([^"\\]*)"|'([^'\\]*)')/g)];
1083
+ if (!directories.length) return command;
1084
+ const cwd = directories[0]?.[1] ?? directories[0]?.[2] ?? "";
1085
+ // Never replay a cwd-bearing call as if it ran in the original shell directory.
1086
+ if (directories.length !== 1 || !isStaticNavigationCommand(["cd", "--", cwd])) return undefined;
1087
+ const argv = unwrapCwdSubprocessCommand(values);
1088
+ if (!argv || !isProvedCwdSubprocessRole(argv)) return undefined;
1089
+ // Keep wrapper assignments/options in policy replay after proving the nested command role.
1090
+ const replay = `cd -- ${quote(cwd)} && ${command}`;
1091
+ return hasProvedNavigationSequence(replay) ? replay : undefined;
1092
+ }
1093
+
1094
+ function unwrapCwdSubprocessCommand(words: string[]): string[] | undefined {
1095
+ let argv = words;
1096
+ // Each successful wrapper removes words; the original word count bounds recursion.
1097
+ for (let depth = 0; depth < words.length; depth++) {
1098
+ const unwrapped = unwrapProofCommand(argv, "\0");
1099
+ if (!unwrapped) return undefined;
1100
+ argv = unwrapped;
1101
+ if (argv[0]?.split("/").at(-1) !== "env") return argv;
1102
+ const nested = unwrapEnvironmentCommand(argv);
1103
+ if (!nested) return undefined;
1104
+ // Inspect only env's prefix, not options belonging to its child command.
1105
+ // env directory, split-string and unknown options do not preserve the proved context.
1106
+ if (
1107
+ argv
1108
+ .slice(1, argv.length - nested.length)
1109
+ .some(
1110
+ (arg) =>
1111
+ arg.startsWith("-") && arg !== "--" && arg !== "-i" && arg !== "--ignore-environment",
1112
+ )
1113
+ )
1114
+ return undefined;
1115
+ argv = nested;
1116
+ }
1117
+ return undefined;
1118
+ }
1119
+
1120
+ function isProvedCwdSubprocessRole(argv: string[]): boolean {
1121
+ const name = argv[0]?.split("/").at(-1) ?? "";
1122
+ if (inlineProgramIndex(argv) !== undefined || isUnsupportedInlineRuntime(argv)) return false;
1123
+ if (isPassiveTextCommand(argv)) return true;
1124
+ if (name === "find") return isSafeLocalAssignmentCommand(argv);
1125
+ if (["ls", "cat", "uniq", "cut"].includes(name)) return true;
1126
+ if (name === "wc") return !hasExecutionOption(argv.slice(1), ["files0-from"]);
1127
+ if (name === "sort")
1128
+ return !hasExecutionOption(argv.slice(1), ["compress-program", "files0-from"]);
1129
+ if (name === "git") {
1130
+ let index = 1;
1131
+ while (argv[index] === "-C" && argv[index + 1]) index += 2;
1132
+ return ["ls-files", "ls-tree", "status", "rev-parse", "rev-list"].includes(argv[index] ?? "");
1133
+ }
1134
+ // A literal Bun script keeps the existing supported test-runner route.
1135
+ if (name === "bun") return /^[A-Za-z0-9_./-]+\.(?:[cm]?[jt]s)$/.test(argv[1] ?? "");
1136
+ // Retain extraction so the nested CLI policy returns its ordinary descriptor.
1137
+ return DEFAULT_BLOCKED_CLI_TOOLS.some((tool) => tool.name === name);
1049
1138
  }
1050
1139
 
1051
1140
  function inlineSubprocessCommands(command: string): string[] {
@@ -1207,6 +1296,9 @@ function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
1207
1296
  argv[4] === "query"
1208
1297
  )
1209
1298
  return false;
1299
+ const sql = literalSqlArgumentIndex(argv);
1300
+ if (sql !== undefined)
1301
+ return argv.some((arg, index) => index !== sql && hasArgumentBraceExpansion(arg));
1210
1302
  return (argv[0]?.split("/").at(-1) === "awk" && !isAwkExecution(argv)) ||
1211
1303
  inlineProgramIndex(argv) !== undefined ||
1212
1304
  isJqObjectConstruction(argv)
@@ -1214,6 +1306,32 @@ function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
1214
1306
  : hasArgumentBraceExpansion(argv.join(" "));
1215
1307
  }
1216
1308
 
1309
+ function literalSqlArgumentIndex(argv: string[]): number | undefined {
1310
+ if (
1311
+ argv[0]?.split("/").at(-1) !== "bun" ||
1312
+ argv[1] !== "run" ||
1313
+ argv[2] !== "db-tool" ||
1314
+ !["sql", "query"].includes(argv[3] ?? "")
1315
+ )
1316
+ return undefined;
1317
+ let sql: number | undefined;
1318
+ const seen = new Set<string>();
1319
+ for (let index = 4; index < argv.length; index++) {
1320
+ const [option = "", ...attached] = (argv[index] ?? "").split("=");
1321
+ if (
1322
+ !["--sql", "--env", "--profile", "--limit", "--format"].includes(option) ||
1323
+ seen.has(option)
1324
+ )
1325
+ return undefined;
1326
+ seen.add(option);
1327
+ const value = attached.length ? attached.join("=") : argv[++index];
1328
+ if (!value || value.startsWith("--")) return undefined;
1329
+ if (option === "--sql") sql = index;
1330
+ else if (!/^[A-Za-z0-9_./-]+$/.test(value)) return undefined;
1331
+ }
1332
+ return sql;
1333
+ }
1334
+
1217
1335
  /** echo and printf print their arguments; grep -o, rg and git grep print matched pattern text. */
1218
1336
  function isLiteralTextProducer(argv: string[]): boolean {
1219
1337
  return isPassiveTextCommand(argv) && argv[0]?.split("/").at(-1) !== "head";
@@ -1548,6 +1666,57 @@ function isNavigationOperand(argv: string[], marker: string): boolean {
1548
1666
  );
1549
1667
  }
1550
1668
 
1669
+ /** Literal directory operands only; navigation is never a pipeline consumer. */
1670
+ function isStaticNavigationCommand(argv: string[]): boolean {
1671
+ const path = (value: string | undefined) => /^[A-Za-z0-9_./][A-Za-z0-9_./ -]*$/.test(value ?? "");
1672
+ if (argv[0] === "cd")
1673
+ return isNavigationOperand(argv, "\0") && path(argv[argv[1] === "--" ? 2 : 1]);
1674
+ if (argv[0] !== "git") return false;
1675
+ let index = 1;
1676
+ while (argv[index] === "-C") {
1677
+ if (!path(argv[index + 1])) return false;
1678
+ index += 2;
1679
+ }
1680
+ return (
1681
+ isNavigationOperand(argv, "\0") ||
1682
+ (index > 1 && isPassiveTextCommand(["git", ...argv.slice(index)]))
1683
+ );
1684
+ }
1685
+
1686
+ /** Prove a single cd whose uses are gated on success, without simulating shell state. */
1687
+ function hasProvedNavigationSequence(command: string): boolean {
1688
+ const literal = literalBindingText(command, "", "");
1689
+ const parsed = parseStaticShellCommands(command);
1690
+ if (!literal || !parsed || typeof parsed === "string") return false;
1691
+ const commands = parsed.pipelines.flat().map((words) => unwrapProofCommand(words, "\0"));
1692
+ const changesDirectory = commands.filter((argv) => argv?.[0] === "cd");
1693
+ if (
1694
+ !commands.some((argv) => argv && (argv[0] === "cd" || (argv[0] === "git" && argv[1] === "-C")))
1695
+ )
1696
+ return true;
1697
+ if (
1698
+ /\|\||(^|[^&])&(?!&)/.test(literal.syntax.replace(/[<>]&[0-9-]+/g, "")) ||
1699
+ parsed.redirects.some(({ target }) => !target.startsWith("/")) ||
1700
+ changesDirectory.length > 1
1701
+ )
1702
+ return false;
1703
+ if (!changesDirectory.length) return true;
1704
+ // Only the final AND list can use this directory. A failing cd never reaches its readers.
1705
+ // Trim real trailing whitespace, not the spaces masking final quoted argv words.
1706
+ const syntax = literal.syntax.slice(0, command.replace(/[ \t\n]+$/g, "").length);
1707
+ const boundary = Math.max(syntax.lastIndexOf(";"), syntax.lastIndexOf("\n"));
1708
+ const tail = parseStaticShellCommands(command.slice(boundary + 1, syntax.length));
1709
+ if (!tail || typeof tail === "string" || tail.pipelines.length < 2) return false;
1710
+ const first = tail.pipelines[0];
1711
+ const argv = unwrapProofCommand(first?.[0] ?? [], "\0");
1712
+ return (
1713
+ first?.length === 1 &&
1714
+ argv?.[0] === "cd" &&
1715
+ isStaticNavigationCommand(argv) &&
1716
+ syntax.slice(boundary + 1).match(/&&|[;&|\n]/)?.[0] === "&&"
1717
+ );
1718
+ }
1719
+
1551
1720
  function isPassiveTextCommand(argv: string[]): boolean {
1552
1721
  const name = argv[0]?.split("/").at(-1) ?? "";
1553
1722
  const args = argv.slice(1);
@@ -1558,6 +1727,7 @@ function isPassiveTextCommand(argv: string[]): boolean {
1558
1727
  }
1559
1728
  if (name === "rg") return !hasExecutionOption(args, ["pre", "hostname-bin"]);
1560
1729
  if (name === "awk") return !isAwkExecution(argv);
1730
+ if (name === "sed") return isReadonlySedPrint(argv);
1561
1731
  if (name === "jq") return isJqObjectConstruction(argv);
1562
1732
  return (
1563
1733
  (name === "git" && args[0] === "status") ||
@@ -1567,6 +1737,19 @@ function isPassiveTextCommand(argv: string[]): boolean {
1567
1737
  );
1568
1738
  }
1569
1739
 
1740
+ /** One print program with optional numeric or escaped-regex addresses; no callbacks. */
1741
+ function isReadonlySedPrint(argv: string[]): boolean {
1742
+ if (argv[0]?.split("/").at(-1) !== "sed") return false;
1743
+ let index = 1;
1744
+ while (/^-[nE]+$/.test(argv[index] ?? "")) index++;
1745
+ if (argv[index] === "--") index++;
1746
+ const address = String.raw`(?:[0-9]+|\$|/(?:\\[^\r\n]|[^/\\\r\n])*/)`;
1747
+ return (
1748
+ new RegExp(`^(?:${address}(?:,${address})?)?p$`).test(argv[index] ?? "") &&
1749
+ argv.slice(index + 1).every((arg) => !arg.startsWith("-"))
1750
+ );
1751
+ }
1752
+
1570
1753
  function hasStaticEnvironmentRead(
1571
1754
  argv: string[],
1572
1755
  allowedNames: Set<string>,
@@ -1627,13 +1810,15 @@ function hasStaticEnvironmentRead(
1627
1810
 
1628
1811
  function isStaticHerdrPrompt(command: string): boolean {
1629
1812
  // Preserve shell word boundaries and executable identity before proving composition.
1630
- if (!literalBindingText(command, "", "")) return false;
1813
+ const literal = literalBindingText(command, "", "");
1814
+ if (!literal) return false;
1631
1815
  const parsed = parseStaticShellCommands(command);
1632
1816
  if (!parsed || typeof parsed === "string") return false;
1633
1817
  const commands = parsed.pipelines.flat().map((words) => unwrapProofCommand(words, "\0"));
1634
1818
  const prompts = commands.filter(
1635
1819
  (argv) => argv?.[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt",
1636
1820
  );
1821
+ if (!hasProvedNavigationSequence(command)) return false;
1637
1822
  return (
1638
1823
  prompts.length > 0 &&
1639
1824
  parsed.pipelines.every((pipeline) =>
@@ -1649,7 +1834,11 @@ function isStaticHerdrPrompt(command: string): boolean {
1649
1834
  argv.length > 1 &&
1650
1835
  argv.slice(1).every((arg) => arg === "--" || !arg.startsWith("-"))
1651
1836
  );
1652
- return isPassiveTextCommand(argv);
1837
+ if (isPassiveTextCommand(argv)) return true;
1838
+ if (isLiteralHerdrRead(argv)) return index === 0;
1839
+ if (isStaticNavigationCommand(argv))
1840
+ return index === 0 && (argv[0] !== "cd" || pipeline.length === 1);
1841
+ return false;
1653
1842
  }),
1654
1843
  )
1655
1844
  );
@@ -1677,6 +1866,26 @@ function hasEnvironmentRead(
1677
1866
  const parsed = parseStaticShellCommands(command);
1678
1867
  if (parsed === "brace-expansion") return true;
1679
1868
  if (!parsed) {
1869
+ const operands = parseStaticShellCommands(command, true);
1870
+ if (
1871
+ operands &&
1872
+ typeof operands !== "string" &&
1873
+ operands.pipelines.flat().some((words) => {
1874
+ const argv = unwrapStaticCommand(words);
1875
+ return (
1876
+ argv[0]?.split("/").at(-1) === "bun" &&
1877
+ argv[1] === "run" &&
1878
+ argv[2] === "db-tool" &&
1879
+ ["sql", "query"].includes(argv[3] ?? "") &&
1880
+ argv.some(
1881
+ (arg, index) =>
1882
+ (arg === "--sql" && /\$\(|`/.test(argv[index + 1] ?? "")) ||
1883
+ (arg.startsWith("--sql=") && /\$\(|`/.test(arg)),
1884
+ )
1885
+ );
1886
+ })
1887
+ )
1888
+ return true;
1680
1889
  const unquoted = command.replace(/'(?:\\.|[^'])*'/g, " ").replace(/"(?:\\.|[^"$`])*"/g, " ");
1681
1890
  const hasExecutor =
1682
1891
  /\|\s*(?:sh|bash|zsh|xargs)|\b(?:sh|bash|zsh)\b|\$\(|`|\b(?:eval|source)\b|\bfind\b.*\b-exec\b/i.test(
@@ -1690,6 +1899,16 @@ function hasEnvironmentRead(
1690
1899
  }
1691
1900
  const pipelines = parsed.pipelines.map((commands) => commands.map(unwrapStaticCommand));
1692
1901
  const unwrapped = pipelines.flat();
1902
+ if (
1903
+ pipelines.some((pipeline) =>
1904
+ pipeline.some(
1905
+ (argv, index) =>
1906
+ literalSqlArgumentIndex(argv) !== undefined &&
1907
+ pipeline.slice(index + 1).some((consumer) => !isPassiveTextCommand(consumer)),
1908
+ ),
1909
+ )
1910
+ )
1911
+ return true;
1693
1912
  if (
1694
1913
  parsed.redirects.length > 0 &&
1695
1914
  unwrapped.some((argv) => isAllowedEnvironmentRead(argv, allowedNames))
@@ -1836,13 +2055,41 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
1836
2055
  const readers =
1837
2056
  /\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;
1838
2057
  if (parsed && parsed !== "brace-expansion") {
2058
+ let directory = "";
2059
+ const trackDirectory = hasProvedNavigationSequence(command);
1839
2060
  return parsed.pipelines.flat().some((words) => {
1840
2061
  const argv = unwrapStaticCommand(words);
1841
2062
  const inspectedArgv =
1842
2063
  argv[0]?.split("/").at(-1) === "env" ? unwrapEnvironmentCommand(argv) : argv;
1843
2064
  if (inspectedArgv === null) return false;
1844
- const inspected = unwrapStaticCommand(inspectedArgv);
1845
- const name = inspected[0]?.split("/").at(-1) ?? "";
2065
+ let inspected = unwrapStaticCommand(inspectedArgv);
2066
+ let name = inspected[0]?.split("/").at(-1) ?? "";
2067
+ let commandDirectory = directory;
2068
+ const inDirectory = (path: string) =>
2069
+ path.startsWith("/") ? posix.normalize(path) : posix.join(commandDirectory, path);
2070
+ const blockedOperand = (path: string) =>
2071
+ [path, inDirectory(path)].some((value) => isPathBlocked(value) || isSensitivePath(value));
2072
+ const blockedDirectory = (path: string) => blockedOperand(path) || blockedOperand(path + "/");
2073
+ if (name === "cd") {
2074
+ const path = inspected[inspected[1] === "--" ? 2 : 1];
2075
+ if (path === undefined) return false;
2076
+ if (blockedDirectory(path)) return true;
2077
+ if (trackDirectory && isStaticNavigationCommand(inspected)) directory = inDirectory(path);
2078
+ return false;
2079
+ }
2080
+ if (name === "git") {
2081
+ let index = 1;
2082
+ while (inspected[index] === "-C" && inspected[index + 1]) {
2083
+ if (blockedDirectory(inspected[index + 1] ?? "")) return true;
2084
+ commandDirectory = inDirectory(inspected[index + 1] ?? "");
2085
+ index += 2;
2086
+ }
2087
+ // Git grep has the same pattern-versus-file roles as grep below.
2088
+ if (inspected[index] === "grep") {
2089
+ inspected = ["grep", ...inspected.slice(index + 1)];
2090
+ name = "grep";
2091
+ }
2092
+ }
1846
2093
  if (name === "bun" && inspected[1] === "run" && inspected[2] === "gh-tool") {
1847
2094
  return inspected.some((arg, index) => {
1848
2095
  const path =
@@ -1982,10 +2229,10 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
1982
2229
  }
1983
2230
  paths.push(arg);
1984
2231
  }
1985
- return paths.some((path) => isPathBlocked(path) || isSensitivePath(path));
2232
+ return paths.some(blockedOperand);
1986
2233
  }
1987
2234
  const paths = name === "jq" ? jqFileArguments(inspected) : inspected.slice(1);
1988
- return paths.some((path) => isPathBlocked(path) || isSensitivePath(path));
2235
+ return paths.some(blockedOperand);
1989
2236
  });
1990
2237
  }
1991
2238
  const operands = parseStaticShellCommands(command, true);
@@ -2063,7 +2310,13 @@ function hasEnvironmentVariableExpansionRead(
2063
2310
  normalized += char;
2064
2311
  continue;
2065
2312
  }
2066
- const variable = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}|^\$([A-Za-z_][A-Za-z0-9_]*)/.exec(
2313
+ if (command[i + 1] === "?") {
2314
+ expansions++;
2315
+ normalized += "NUMERICSTATUSVALUE";
2316
+ i++;
2317
+ continue;
2318
+ }
2319
+ const variable = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(:-)?\}|^\$([A-Za-z_][A-Za-z0-9_]*)/.exec(
2067
2320
  command.slice(i),
2068
2321
  );
2069
2322
  if (!variable) {
@@ -2074,7 +2327,13 @@ function hasEnvironmentVariableExpansionRead(
2074
2327
  continue;
2075
2328
  }
2076
2329
  expansions++;
2077
- const name = variable[1] ?? variable[2] ?? "";
2330
+ const name = variable[1] ?? variable[3] ?? "";
2331
+ if (variable[2]) {
2332
+ if (quote !== '"' || !allowedNames.has(name)) invalidExpansion = true;
2333
+ normalized += "ENVTESTVALUE";
2334
+ i += variable[0].length - 1;
2335
+ continue;
2336
+ }
2078
2337
  const assignment = assignments.get(name);
2079
2338
  if (assignment && assignment.end >= i) invalidExpansion = true;
2080
2339
  if (assignment && assignment.end < i) {
@@ -2099,6 +2358,39 @@ function hasEnvironmentVariableExpansionRead(
2099
2358
  if (!hasSafeLocalAssignmentCommands(localMaterialized, assignments)) return true;
2100
2359
  const parsed = parseStaticShellCommands(localMaterialized);
2101
2360
  if (!parsed || typeof parsed === "string") return true;
2361
+ for (const [index, pipeline] of parsed.pipelines.entries()) {
2362
+ if (!pipeline.some((words) => words.some((arg) => arg.includes("NUMERICSTATUSVALUE"))))
2363
+ continue;
2364
+ if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
2365
+ if (
2366
+ pipeline.some((words) => {
2367
+ const argv = unwrapStaticCommand(words);
2368
+ if (!words.some((arg) => arg.includes("NUMERICSTATUSVALUE")))
2369
+ return !isPassiveTextCommand(argv);
2370
+ const name = argv[0]?.split("/").at(-1);
2371
+ const format = name === "printf" ? passivePrintfFormatIndex(argv) : undefined;
2372
+ return (
2373
+ !["echo", "printf"].includes(name ?? "") ||
2374
+ !isPassiveTextCommand(argv) ||
2375
+ words
2376
+ .slice(0, words.length - argv.length)
2377
+ .some((arg) => arg.includes("NUMERICSTATUSVALUE")) ||
2378
+ argv[0]?.includes("NUMERICSTATUSVALUE") === true ||
2379
+ (name === "printf" &&
2380
+ (format === undefined ||
2381
+ argv.slice(0, format + 1).some((arg) => arg.includes("NUMERICSTATUSVALUE"))))
2382
+ );
2383
+ })
2384
+ )
2385
+ return true;
2386
+ }
2387
+ for (const words of parsed.pipelines.flat()) {
2388
+ if (
2389
+ words.some((arg) => arg.includes("ENVTESTVALUE")) &&
2390
+ !isApprovedEnvironmentPredicate(unwrapStaticCommand(words))
2391
+ )
2392
+ return true;
2393
+ }
2102
2394
  for (const words of parsed.pipelines.flat()) {
2103
2395
  if (!words.some((arg) => arg.includes("UNKNOWNVALUE"))) continue;
2104
2396
  if (words.some((arg) => arg.includes("UNKNOWNVALUEUNQUOTED"))) return true;
@@ -2109,7 +2401,7 @@ function hasEnvironmentVariableExpansionRead(
2109
2401
  )
2110
2402
  return true;
2111
2403
  }
2112
- if (!localMaterialized.includes("ENVVALUE")) {
2404
+ if (!localMaterialized.includes("ENVVALUE") && !localMaterialized.includes("ENVTESTVALUE")) {
2113
2405
  return (
2114
2406
  hasSensitiveFileRead(localMaterialized, isPathBlocked) ||
2115
2407
  hasSensitivePathRedirect(localMaterialized, isPathBlocked) ||
@@ -2119,7 +2411,7 @@ function hasEnvironmentVariableExpansionRead(
2119
2411
 
2120
2412
  for (const [index, pipeline] of parsed.pipelines.entries()) {
2121
2413
  const hasEnvironmentValue = pipeline.some((argv) =>
2122
- argv.some((arg) => arg.includes("ENVVALUE")),
2414
+ argv.some((arg) => arg.includes("ENVVALUE") || arg.includes("ENVTESTVALUE")),
2123
2415
  );
2124
2416
  if (!hasEnvironmentValue) continue;
2125
2417
  if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
@@ -2127,9 +2419,14 @@ function hasEnvironmentVariableExpansionRead(
2127
2419
  pipeline.some((argv) => {
2128
2420
  const unwrapped = unwrapStaticCommand(argv);
2129
2421
  const name = unwrapped[0]?.split("/").at(-1) ?? "";
2130
- if (argv.some((arg) => arg.includes("ENVVALUE"))) {
2131
- if (argv.slice(0, argv.length - unwrapped.length).some((arg) => arg.includes("ENVVALUE")))
2422
+ if (argv.some((arg) => arg.includes("ENVVALUE") || arg.includes("ENVTESTVALUE"))) {
2423
+ if (
2424
+ argv
2425
+ .slice(0, argv.length - unwrapped.length)
2426
+ .some((arg) => arg.includes("ENVVALUE") || arg.includes("ENVTESTVALUE"))
2427
+ )
2132
2428
  return true;
2429
+ if (isApprovedEnvironmentPredicate(unwrapped)) return false;
2133
2430
  if (isNavigationOperand(unwrapped, "ENVVALUE")) {
2134
2431
  return unwrapped.some((arg) => arg.includes("ENVVALUEUNQUOTED"));
2135
2432
  }
@@ -2156,6 +2453,24 @@ function hasEnvironmentVariableExpansionRead(
2156
2453
  return hasSensitiveFileRead(materialized, isPathBlocked);
2157
2454
  }
2158
2455
 
2456
+ /** Quoted approved metadata can be compared or checked for blankness, never evaluated. */
2457
+ function isApprovedEnvironmentPredicate(argv: string[]): boolean {
2458
+ let args = argv.slice(1);
2459
+ if (argv[0] === "[") {
2460
+ if (args.at(-1) !== "]") return false;
2461
+ args = args.slice(0, -1);
2462
+ } else if (argv[0] !== "test") return false;
2463
+ const value = (arg: string | undefined) => arg === "ENVVALUE" || arg === "ENVTESTVALUE";
2464
+ const literal = (arg: string | undefined) =>
2465
+ /^[A-Za-z0-9_.:-]*$/.test(arg ?? "") && arg !== undefined;
2466
+ return (
2467
+ (args.length === 2 && ["-n", "-z"].includes(args[0] ?? "") && value(args[1])) ||
2468
+ (args.length === 3 &&
2469
+ args[1] === "=" &&
2470
+ ((value(args[0]) && literal(args[2])) || (literal(args[0]) && value(args[2]))))
2471
+ );
2472
+ }
2473
+
2159
2474
  function mentionsEnvironmentSource(command: string): boolean {
2160
2475
  return /\b(?:os\s*\.\s*(?:environ|getenv)|process\s*\.\s*env|Environment\s*\.\s*GetEnvironmentVariable)\b/.test(
2161
2476
  command,