@blogic-cz/agent-tools 1.6.0 → 1.6.1

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
@@ -650,7 +650,7 @@ The guard permits static `printenv` reads and simple `echo`/`printf` expansions
650
650
 
651
651
  Python inline programs and quoted stdin heredocs can also display approved values through `os.getenv("NAME")`, `os.environ.get("NAME", "default")`, or `os.environ["NAME"]`. The complete program must be `import os` followed by `print(...)` statements using these reads and plain string labels or defaults. Dynamic names, aliases, escaped or interpolated strings, additional statements, and whole-environment output remain unsupported. The same redirection and pipeline restrictions apply.
652
652
 
653
- Literal assignments such as `F=README.md; cat "$F"` work in supported static commands. Bounded proofs also support a literal `for` loop over a fixed list, the adjacent `cd PATH && T=LITERAL && git add -- "$T/file"` form, and `rg -n PATTERN src/{a,b}` with a single literal path-brace component. Loop binders are a single lowercase letter or `T`, and their uses must occupy supported data operands. Every body command and expanded path is checked; unsupported control flow, mutations such as `printf -v`, and unknown command roles cannot establish the proof. Other variable assignments and brace expansions retain conservative refusal. Fully quoted literal braces remain usable in static searches.
653
+ Literal assignments such as `F=README.md; cat "$F"` work in supported static commands. Bounded proofs also support a literal `for` loop over a fixed list, a single ASCII-letter path binding such as `cd PATH && A=docs/report.md && shasum -a 256 "$A"` or `F=README.md; cat "$F"`, including the existing `git add -- "$T/file"` form, and literal path braces such as `rg -n -i PATTERN src/{a,b} docs/{guide,api}.md`. Path braces work with supported passive text commands and `cat`, and support one unquoted comma list per path, at most 16 members per list and 32 generated paths per command, within 64 KiB. Nested braces, ranges, empty members, mixed quoting, command positions, and compound commands remain unsupported. Path bindings accept literal values and `;`, newline, or `&&` sequencing; a binding conditional on successful `cd` is proved only for uses in that same `&&` list. Their uses must be supported read-only file operands or explicit `git add --` paths. Rebinding, executable operands, startup-variable names, and uncertain scopes cannot establish this proof. Loop binders are a single lowercase letter or `T`, and their uses must occupy supported data operands. Every body command and expanded path is checked; unsupported control flow, mutations such as `printf -v`, and unknown command roles cannot establish the proof. Other variable assignments and brace expansions retain conservative refusal. Fully quoted literal braces remain usable in static searches.
654
654
 
655
655
  Ordinary navigation operands such as `cd "$HOME"` and `git -C "$WORKTREE_ROOT" status --short` do not need display permission. This does not authorize printing those values, using them as code/options, or reading unknown file paths. In `printf`, approved values belong in data arguments after a fixed nonmutating format, for example `printf -- '%s' "$WORKSPACE_LABEL"`.
656
656
 
@@ -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;AAimEF,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,UAv8ED,SAAS,UAAU,UAAU,KAAK,IAu8EM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YAz8EC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAy8EjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aA38EE,MAAM,KAAK,OA28EgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aA78EE,MAAM,KAAK,OA68EgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YA/8EC,MAAM,KAAK,OA+8E0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YAj9EC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAi9Ef,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YAn9EC,MAAM,KAAK,OAm9EsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YAr9EC,MAAM,KAAK,MAAM,GAAG,IAq9Ea,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;AA6qEF,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,UAnhFD,SAAS,UAAU,UAAU,KAAK,IAmhFM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YArhFC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAqhFjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aAvhFE,MAAM,KAAK,OAuhFgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aAzhFE,MAAM,KAAK,OAyhFgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YA3hFC,MAAM,KAAK,OA2hF0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YA7hFC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IA6hFf,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YA/hFC,MAAM,KAAK,OA+hFsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YAjiFC,MAAM,KAAK,MAAM,GAAG,IAiiFa,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
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",
@@ -190,11 +190,11 @@ function getLeadingLiteralAssignments(
190
190
  const assignments = new Map<string, { value: string; end: number }>();
191
191
  let offset = 0;
192
192
  while (offset === 0 || command[offset - 1] === ";" || command[offset - 1] === "\n") {
193
- while (/\s/.test(command[offset] ?? "")) offset++;
193
+ while (/[ \t\n]/.test(command[offset] ?? "")) offset++;
194
194
  const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_./-]+)/.exec(command.slice(offset));
195
195
  if (!assignment) break;
196
196
  const end = offset + assignment[0].length;
197
- const next = command.slice(end).match(/^\s*/)?.[0].length ?? 0;
197
+ const next = command.slice(end).match(/^[ \t]*/)?.[0].length ?? 0;
198
198
  const separator = command[end + next];
199
199
  if (separator !== ";" && separator !== "\n") break;
200
200
  assignments.set(assignment[1] ?? "", { value: assignment[2] ?? "", end });
@@ -239,39 +239,45 @@ function hasSafeLocalAssignmentCommands(
239
239
  assignments.get(assignment[1] ?? "")?.value === assignment[2]
240
240
  );
241
241
  }
242
- const argv = unwrapStaticCommand(words);
243
- if (isSafeHerdrTabCreate(argv)) return true;
244
- const name = argv[0]?.split("/").at(-1) ?? "";
245
- if (name === "cd") return isNavigationOperand(argv, "LOCALVALUE");
246
- if (name === "git") {
247
- let index = 1;
248
- while (argv[index] === "-C" && argv[index + 1]) index += 2;
249
- return (
250
- ["branch", "diff", "log", "rev-parse", "rev-list", "show", "status"].includes(
251
- argv[index] ?? "",
252
- ) && !hasExecutionOption(argv.slice(index + 1), ["exec", "ext-diff", "textconv"])
253
- );
254
- }
255
- if (name === "find") {
256
- return !argv.slice(1).some((arg) => /^-(?:exec|ok|delete|fprint|fprintf)/.test(arg));
257
- }
258
- if (name === "bun") return argv[1] === "run" && ["db-tool", "gh-tool"].includes(argv[2] ?? "");
242
+ return isSafeLocalAssignmentCommand(words);
243
+ });
244
+ }
245
+
246
+ function isSafeLocalAssignmentCommand(words: string[]): boolean {
247
+ const argv = unwrapStaticCommand(words);
248
+ if (isSafeHerdrTabCreate(argv)) return true;
249
+ const name = argv[0]?.split("/").at(-1) ?? "";
250
+ if (name === "wc" && hasExecutionOption(argv.slice(1), ["files0-from"])) return false;
251
+ if (name === "cd") return isNavigationOperand(argv, "LOCALVALUE");
252
+ if (name === "git") {
253
+ let index = 1;
254
+ while (argv[index] === "-C" && argv[index + 1]) index += 2;
259
255
  return (
260
- isPassiveTextCommand(argv) ||
261
- ["ls", "cat", "tail", "wc", "uniq", "cut", "mv"].includes(name) ||
262
- (name === "sort" && !hasExecutionOption(argv.slice(1), ["compress-program"])) ||
263
- (name === "sed" &&
264
- argv
265
- .slice(1)
266
- .every(
267
- (arg) =>
268
- /^-[En]+$/.test(arg) ||
269
- /^s([/|]).*\1[^/|]*\1[gp]*$/.test(arg) ||
270
- /^[A-Za-z0-9_./-]+$/.test(arg),
271
- ) &&
272
- !argv.includes("-f"))
256
+ ["branch", "diff", "log", "rev-parse", "rev-list", "show", "status"].includes(
257
+ argv[index] ?? "",
258
+ ) && !hasExecutionOption(argv.slice(index + 1), ["exec", "ext-diff", "textconv"])
273
259
  );
274
- });
260
+ }
261
+ if (name === "find") {
262
+ return !argv.slice(1).some((arg) => /^-(?:exec|ok|delete|fprint|fprintf)/.test(arg));
263
+ }
264
+ if (name === "bun") return argv[1] === "run" && ["db-tool", "gh-tool"].includes(argv[2] ?? "");
265
+ return (
266
+ isPassiveTextCommand(argv) ||
267
+ ["ls", "cat", "tail", "wc", "uniq", "cut", "mv"].includes(name) ||
268
+ (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"))
280
+ );
275
281
  }
276
282
 
277
283
  function hasSensitivePathRedirect(
@@ -429,6 +435,7 @@ function literalBindingText(
429
435
  let quote: "'" | '"' | undefined;
430
436
  for (let i = 0; i < command.length; i++) {
431
437
  const char = command[i] ?? "";
438
+ if (!quote && /[^\S \t\n]/.test(char)) return undefined;
432
439
  if (char === "\\" && quote !== "'") {
433
440
  const next = command[++i];
434
441
  if (next === undefined || /[\r\n]/.test(next)) return undefined;
@@ -524,7 +531,7 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
524
531
  if (new TextEncoder().encode(command).byteLength > 65_536) return undefined;
525
532
  const marker = "GUARDLITERALBINDING";
526
533
  if (command.includes(marker)) return undefined;
527
- let script = command.trim();
534
+ let script = command.replace(/^[ \t\n]+|[ \t\n]+$/g, "");
528
535
  const wrapped = parseStaticShellCommands(script);
529
536
  if (
530
537
  wrapped &&
@@ -534,7 +541,10 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
534
541
  wrapped.redirects.length === 0
535
542
  ) {
536
543
  const argv = unwrapProofCommand(wrapped.pipelines[0]?.[0] ?? [], marker);
537
- if (argv?.[0] === "sh" && argv[1] === "-c" && argv.length === 3) script = argv[2] ?? "";
544
+ if (argv?.[0] === "sh" && argv[1] === "-c" && argv.length === 3) {
545
+ if (!literalBindingText(script, "", "")) return undefined;
546
+ script = argv[2] ?? "";
547
+ }
538
548
  }
539
549
  const loop =
540
550
  /^for[ \t]+([A-Za-z_]\w*)[ \t]+in[ \t]+([^;\n]+);[ \t\r\n]*do[ \t\r\n]+([\s\S]*);[ \t\r\n]*done[ \t\r\n]*$/.exec(
@@ -587,64 +597,135 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
587
597
  paths: [],
588
598
  };
589
599
  }
600
+ // Single ASCII-letter names avoid shell startup/special variables such as PATH and IFS.
590
601
  const assignment =
591
- /^cd[ \t]+(~?\/?[A-Za-z0-9_./-]+)[ \t]+&&[ \t]+([A-Za-z_]\w*)=([A-Za-z0-9_./][A-Za-z0-9_./-]*)[ \t]+&&[ \t]+/.exec(
592
- command,
602
+ /^(?:cd[ \t]+(~?\/?[A-Za-z0-9_./-]+)[ \t]*(;|\n|&&)[ \t]*)?([A-Za-z])=([A-Za-z0-9_./][A-Za-z0-9_./-]*)[ \t]*(;|\n|&&)[ \t]*/.exec(
603
+ script,
593
604
  );
594
- if (assignment && isLiteralBinder(assignment[2] ?? "")) {
605
+ if (assignment) {
595
606
  const rest = literalBindingText(
596
- command.slice(assignment[0].length),
597
- assignment[2] ?? "",
607
+ script.slice(assignment[0].length),
608
+ assignment[3] ?? "",
598
609
  marker,
599
610
  );
600
- if (!rest) return undefined;
601
- const end = rest.syntax.search(/[;&|\r\n]/);
602
- const use = rest.text.slice(0, end < 0 ? undefined : end);
603
- const suffix = end < 0 ? "" : rest.text.slice(end);
604
- if (suffix.includes(marker)) return undefined;
605
- const parsed = parseStaticShellCommands(use);
606
- const argv =
607
- parsed &&
608
- typeof parsed !== "string" &&
609
- parsed.pipelines.length === 1 &&
610
- parsed.pipelines[0]?.length === 1 &&
611
- parsed.redirects.length === 0
612
- ? parsed.pipelines[0]?.[0]
613
- : undefined;
611
+ if (!rest || !rest.text.includes(marker)) return undefined;
612
+ // No branches, background jobs or pipeline-local state. FD duplication stays inert.
613
+ const syntax = rest.syntax.replace(/[<>]&[0-9-]+/g, "");
614
+ if (/\|\||(^|[^&])&(?!&)|[^\S \t\n]/.test(syntax)) return undefined;
615
+ if (assignment[2] === "&&") {
616
+ if (assignment[5] !== "&&") return undefined;
617
+ const boundary = rest.syntax.search(/[;\r\n]/);
618
+ if (boundary >= 0 && rest.text.slice(boundary).includes(marker)) return undefined;
619
+ }
620
+ const parsed = parseStaticShellCommands(rest.text);
614
621
  if (
615
- !argv ||
616
- argv.length < 4 ||
617
- argv.slice(0, 3).join(" ") !== "git add --" ||
618
- argv.slice(3).some((word) => !/^[A-Za-z0-9_./-]+$/.test(word))
622
+ !parsed ||
623
+ typeof parsed === "string" ||
624
+ parsed.redirects.some(({ target }) => target.includes(marker)) ||
625
+ !parsed.pipelines.flat().length ||
626
+ parsed.pipelines.flat().length > 32
619
627
  )
620
628
  return undefined;
621
- const value = assignment[3] ?? "";
629
+ const value = assignment[4] ?? "";
630
+ const paths: string[] = [];
631
+ for (const words of parsed.pipelines.flat()) {
632
+ const marked = unwrapProofCommand(words, marker);
633
+ if (!marked) return undefined;
634
+ const argv = marked.map((word) => word.replaceAll(marker, () => value));
635
+ const name = argv[0]?.split("/").at(-1) ?? "";
636
+ const uses = marked.flatMap((word, index) => (word.includes(marker) ? [index] : []));
637
+ const checksum =
638
+ ["shasum", "sha1sum", "sha256sum", "sha512sum", "md5sum", "cksum"].includes(name) &&
639
+ !hasExecutionOption(argv.slice(1), ["check"], "c");
640
+ if (!uses.length) {
641
+ if (
642
+ !isSafeLocalAssignmentCommand(argv) &&
643
+ inlineProgramIndex(argv) === undefined &&
644
+ !(argv[0] === "git" && argv[1] === "commit") &&
645
+ !checksum
646
+ )
647
+ return undefined;
648
+ continue;
649
+ }
650
+ if (
651
+ uses.some(
652
+ (index) => index === 0 || !/^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(argv[index] ?? ""),
653
+ )
654
+ )
655
+ return undefined;
656
+ const gitAdd =
657
+ argv.slice(0, 3).join(" ") === "git add --" && uses.every((index) => index >= 3);
658
+ const reader =
659
+ ["cat", "ls", "head", "tail", "wc", "uniq", "cut", "sort", "grep", "rg"].includes(name) &&
660
+ isSafeLocalAssignmentCommand(argv);
661
+ if (!gitAdd && !reader && !checksum) return undefined;
662
+ paths.push(...uses.map((index) => argv[index] ?? ""));
663
+ }
664
+ const prefix = assignment[1] ? `cd ${assignment[1]}${assignment[2]} ` : "";
622
665
  const size =
623
- new TextEncoder().encode(rest.text).byteLength +
624
- (use.split(marker).length - 1) * (value.length - marker.length) +
625
- (assignment[1]?.length ?? 0) +
626
- 7;
627
- if (size > 65_536) return undefined;
628
- const paths = argv.slice(3).map((word) => word.replaceAll(marker, () => value));
629
- return {
630
- command: `cd ${assignment[1]} && ${use.replaceAll(marker, () => value)}${suffix}`,
631
- paths,
632
- };
666
+ new TextEncoder().encode(prefix + rest.text).byteLength +
667
+ (rest.text.split(marker).length - 1) * (value.length - marker.length);
668
+ if (paths.length > 32 || size > 65_536) return undefined;
669
+ return { command: prefix + rest.text.replaceAll(marker, () => value), paths };
633
670
  }
634
- // Only the observed rg -n PATTERN PATH form; marker position proves a real file operand.
635
- const brace =
636
- /(?:^|\s)([A-Za-z0-9_./-]*\/[A-Za-z0-9_./-]*\{[A-Za-z_]\w*(?:,[A-Za-z_]\w*){1,7}\}[A-Za-z0-9_./-]*)$/.exec(
637
- command,
638
- );
639
- if (!brace) return undefined;
640
- const path = brace[1] ?? "";
641
- const start = command.length - path.length;
642
- const marked = command.slice(0, start) + marker;
643
- const lexical = literalBindingText(marked, "", "");
671
+ // Expand only complete, unquoted path words. Keep all other source text intact.
672
+ const paths: string[] = [];
673
+ let expanded = "";
674
+ let marked = "";
675
+ let start = 0;
676
+ let quote: "'" | '"' | undefined;
677
+ let braces = false;
678
+ const finishWord = (end: number): boolean => {
679
+ const word = command.slice(start, end);
680
+ if (!braces) {
681
+ expanded += word;
682
+ marked += word;
683
+ return true;
684
+ }
685
+ // ponytail: one comma-list per path, no nesting; extend only with a bounded shell parser.
686
+ const match =
687
+ /^([A-Za-z0-9_./-]*\/[A-Za-z0-9_./-]*)\{([A-Za-z0-9_./-]+(?:,[A-Za-z0-9_./-]+)+)\}([A-Za-z0-9_./-]*)$/.exec(
688
+ word,
689
+ );
690
+ if (!match) return false;
691
+ const members = (match[2] ?? "").split(",");
692
+ if (members.length > 16 || paths.length + members.length > 32) return false;
693
+ const values = members.map((member) => (match[1] ?? "") + member + (match[3] ?? ""));
694
+ const text = values.join(" ");
695
+ if (expanded.length + text.length + command.length - end > 65_536) return false;
696
+ paths.push(...values);
697
+ expanded += text;
698
+ marked += marker;
699
+ return true;
700
+ };
701
+ for (let i = 0; i <= command.length; i++) {
702
+ const char = command[i];
703
+ if (quote === "'") {
704
+ if (char === quote) quote = undefined;
705
+ } else if (char === "\\") {
706
+ if (command[i + 1] === undefined || /[\r\n]/.test(command[i + 1] ?? "")) return undefined;
707
+ i++;
708
+ } else if (quote === '"') {
709
+ if (char === quote) quote = undefined;
710
+ } else if (char === "'" || char === '"') {
711
+ quote = char;
712
+ } else if (char === "{" || char === "}") {
713
+ braces = true;
714
+ } else if (char === undefined || /[ \t\n]/.test(char)) {
715
+ if (!finishWord(i)) return undefined;
716
+ expanded += char ?? "";
717
+ marked += char ?? "";
718
+ start = i + 1;
719
+ braces = false;
720
+ } else if (/[\s;&|<>#]/.test(char)) {
721
+ // Other JS whitespace is a literal shell word character, not a token boundary.
722
+ return undefined;
723
+ }
724
+ }
725
+ if (quote || !paths.length || new TextEncoder().encode(expanded).byteLength > 65_536)
726
+ return undefined;
644
727
  const parsed = parseStaticShellCommands(marked);
645
728
  if (
646
- !lexical ||
647
- /[;&|<>\r\n]/.test(lexical.syntax) ||
648
729
  !parsed ||
649
730
  typeof parsed === "string" ||
650
731
  parsed.redirects.length ||
@@ -653,24 +734,19 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
653
734
  )
654
735
  return undefined;
655
736
  const argv = unwrapProofCommand(parsed.pipelines[0]?.[0] ?? [], marker);
737
+ const expandedCommands = parseStaticShellCommands(expanded);
738
+ const expandedArgv =
739
+ expandedCommands && typeof expandedCommands !== "string"
740
+ ? unwrapProofCommand(expandedCommands.pipelines[0]?.[0] ?? [], marker)
741
+ : undefined;
742
+ // Classify actual options too: a marked path can hide an executor such as git grep -O/path.
656
743
  if (
657
744
  !argv ||
658
- argv.length !== 4 ||
659
- argv[0] !== "rg" ||
660
- argv[1] !== "-n" ||
661
- argv[2]?.startsWith("-") ||
662
- argv[3] !== marker
745
+ !expandedArgv ||
746
+ (!isPassiveTextCommand(expandedArgv) && expandedArgv[0]?.split("/").at(-1) !== "cat")
663
747
  )
664
748
  return undefined;
665
- const members = /\{([^{}]+)\}/.exec(path);
666
- if (!members) return undefined;
667
- const values = (members[1] ?? "").split(",");
668
- const size =
669
- new TextEncoder().encode(command.slice(0, start)).byteLength +
670
- values.reduce((total, value) => total + path.length - members[0].length + value.length + 1, -1);
671
- if (size > 65_536) return undefined;
672
- const paths = values.map((member) => path.replace(members[0], () => member));
673
- return { command: command.slice(0, start) + paths.join(" "), paths };
749
+ return { command: expanded, paths };
674
750
  }
675
751
 
676
752
  /** Complete package metadata loops: no prelude, loader shadowing, mutation or arbitrary suffix. */
@@ -1710,7 +1786,7 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
1710
1786
  if (/\bcat\b[^;&|]*<<-?\s*\S+[\s\S]*\b(?:secret|credential)\b/i.test(command)) return true;
1711
1787
  const parsed = parseStaticShellCommands(command);
1712
1788
  const readers =
1713
- /\b(?:cat|cp|grep|rg|sed|awk|jq|head|tail|less|more|source|ls|sort|uniq|cut|wc|mv|find)\b/i;
1789
+ /\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;
1714
1790
  if (parsed && parsed !== "brace-expansion") {
1715
1791
  return parsed.pipelines.flat().some((words) => {
1716
1792
  const argv = unwrapStaticCommand(words);