@blogic-cz/agent-tools 1.6.0 → 1.6.2

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,10 +650,12 @@ 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
 
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
+
657
659
  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.
658
660
 
659
661
  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.
@@ -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;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"}
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.2",
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;
@@ -497,9 +504,8 @@ function isLiteralLoopCommand(words: string[], marker: string, value: string): b
497
504
  }
498
505
  if (name === "herdr")
499
506
  return (
500
- argv.length === 4 &&
501
- argv[1] === "agent" &&
502
- argv[2] === "get" &&
507
+ ((argv.length === 4 && argv[1] === "agent" && argv[2] === "get") ||
508
+ isLiteralHerdrPaneRead(argv)) &&
503
509
  /^[A-Za-z0-9_.:-]+$/.test(argv[3] ?? "") &&
504
510
  uses.every((index) => index === 3)
505
511
  );
@@ -513,7 +519,36 @@ function isLiteralLoopCommand(words: string[], marker: string, value: string): b
513
519
  uses.every((index) => index === 8)
514
520
  );
515
521
  return (
516
- ["grep", "head", "tail"].includes(name ?? "") && uses.length === 0 && isPassiveTextCommand(argv)
522
+ uses.length === 0 &&
523
+ ((["grep", "head", "tail"].includes(name ?? "") && isPassiveTextCommand(argv)) ||
524
+ isLiteralStdinJsonDisplay(argv))
525
+ );
526
+ }
527
+
528
+ function isLiteralHerdrPaneRead(argv: string[]): boolean {
529
+ return (
530
+ argv.length === 8 &&
531
+ argv.slice(0, 3).join(" ") === "herdr pane read" &&
532
+ /^[A-Za-z0-9_.:-]+$/.test(argv[3] ?? "") &&
533
+ argv[4] === "--source" &&
534
+ argv[5] === "recent-unwrapped" &&
535
+ argv[6] === "--lines" &&
536
+ /^[1-9]\d*$/.test(argv[7] ?? "")
537
+ );
538
+ }
539
+
540
+ /** A complete stdin-only JSON projection, with no suffix, imports or executable input. */
541
+ function isLiteralStdinJsonDisplay(argv: string[]): boolean {
542
+ if (argv.length !== 3 || !["python", "python3"].includes(argv[0] ?? "") || argv[1] !== "-c")
543
+ return false;
544
+ const program = argv[2] ?? "";
545
+ if (program.length > 65_536) return false;
546
+ const parts = programParts(program, true);
547
+ if (!parts || parts.literals.join("\0") !== "result\0text\0\0result") return false;
548
+ // ponytail: one JSON projection with a single-letter local; extend only with another complete grammar.
549
+ const code = parts.code.replace(/[ \t]+/g, " ").trim();
550
+ return /^import json\s*,\s*sys\s*(?:;|\r?\n)\s*([a-z])\s*=\s*json\s*\.\s*load\s*\(\s*sys\s*\.\s*stdin\s*\)\s*(?:;|\r?\n)\s*print\s*\(\s*\1\s*\[\s*STRING\s*\]\s*\.\s*get\s*\(\s*STRING\s*,\s*STRING\s*\)\s+if isinstance\s*\(\s*\1\s*\.\s*get\s*\(\s*STRING\s*\)\s*,\s*dict\s*\)\s+else \1\s*\)\s*;?$/.test(
551
+ code,
517
552
  );
518
553
  }
519
554
 
@@ -524,7 +559,7 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
524
559
  if (new TextEncoder().encode(command).byteLength > 65_536) return undefined;
525
560
  const marker = "GUARDLITERALBINDING";
526
561
  if (command.includes(marker)) return undefined;
527
- let script = command.trim();
562
+ let script = command.replace(/^[ \t\n]+|[ \t\n]+$/g, "");
528
563
  const wrapped = parseStaticShellCommands(script);
529
564
  if (
530
565
  wrapped &&
@@ -534,7 +569,10 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
534
569
  wrapped.redirects.length === 0
535
570
  ) {
536
571
  const argv = unwrapProofCommand(wrapped.pipelines[0]?.[0] ?? [], marker);
537
- if (argv?.[0] === "sh" && argv[1] === "-c" && argv.length === 3) script = argv[2] ?? "";
572
+ if (argv?.[0] === "sh" && argv[1] === "-c" && argv.length === 3) {
573
+ if (!literalBindingText(script, "", "")) return undefined;
574
+ script = argv[2] ?? "";
575
+ }
538
576
  }
539
577
  const loop =
540
578
  /^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(
@@ -557,7 +595,8 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
557
595
  const values = rawValues.map((item) => item.replace(/^['"]|['"]$/g, ""));
558
596
  if (!values.length || values.length > 16) return undefined;
559
597
  const marked = literalBindingText(loop[3] ?? "", name, marker);
560
- if (!marked || /&&|\|\||[<>]|&/.test(marked.syntax.replace(/\b2>&1\b/g, ""))) return undefined;
598
+ if (!marked || /&&|\|\||[<>]|&/.test(marked.syntax.replace(/\b2>&1(?=[ \t\n;|]|$)/g, "")))
599
+ return undefined;
561
600
  const parsed = parseStaticShellCommands(marked.text);
562
601
  if (
563
602
  !parsed ||
@@ -587,64 +626,135 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
587
626
  paths: [],
588
627
  };
589
628
  }
629
+ // Single ASCII-letter names avoid shell startup/special variables such as PATH and IFS.
590
630
  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,
631
+ /^(?:cd[ \t]+(~?\/?[A-Za-z0-9_./-]+)[ \t]*(;|\n|&&)[ \t]*)?([A-Za-z])=([A-Za-z0-9_./][A-Za-z0-9_./-]*)[ \t]*(;|\n|&&)[ \t]*/.exec(
632
+ script,
593
633
  );
594
- if (assignment && isLiteralBinder(assignment[2] ?? "")) {
634
+ if (assignment) {
595
635
  const rest = literalBindingText(
596
- command.slice(assignment[0].length),
597
- assignment[2] ?? "",
636
+ script.slice(assignment[0].length),
637
+ assignment[3] ?? "",
598
638
  marker,
599
639
  );
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;
640
+ if (!rest || !rest.text.includes(marker)) return undefined;
641
+ // No branches, background jobs or pipeline-local state. FD duplication stays inert.
642
+ const syntax = rest.syntax.replace(/[<>]&[0-9-]+/g, "");
643
+ if (/\|\||(^|[^&])&(?!&)|[^\S \t\n]/.test(syntax)) return undefined;
644
+ if (assignment[2] === "&&") {
645
+ if (assignment[5] !== "&&") return undefined;
646
+ const boundary = rest.syntax.search(/[;\r\n]/);
647
+ if (boundary >= 0 && rest.text.slice(boundary).includes(marker)) return undefined;
648
+ }
649
+ const parsed = parseStaticShellCommands(rest.text);
614
650
  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))
651
+ !parsed ||
652
+ typeof parsed === "string" ||
653
+ parsed.redirects.some(({ target }) => target.includes(marker)) ||
654
+ !parsed.pipelines.flat().length ||
655
+ parsed.pipelines.flat().length > 32
619
656
  )
620
657
  return undefined;
621
- const value = assignment[3] ?? "";
658
+ const value = assignment[4] ?? "";
659
+ const paths: string[] = [];
660
+ for (const words of parsed.pipelines.flat()) {
661
+ const marked = unwrapProofCommand(words, marker);
662
+ if (!marked) return undefined;
663
+ const argv = marked.map((word) => word.replaceAll(marker, () => value));
664
+ const name = argv[0]?.split("/").at(-1) ?? "";
665
+ const uses = marked.flatMap((word, index) => (word.includes(marker) ? [index] : []));
666
+ const checksum =
667
+ ["shasum", "sha1sum", "sha256sum", "sha512sum", "md5sum", "cksum"].includes(name) &&
668
+ !hasExecutionOption(argv.slice(1), ["check"], "c");
669
+ if (!uses.length) {
670
+ if (
671
+ !isSafeLocalAssignmentCommand(argv) &&
672
+ inlineProgramIndex(argv) === undefined &&
673
+ !(argv[0] === "git" && argv[1] === "commit") &&
674
+ !checksum
675
+ )
676
+ return undefined;
677
+ continue;
678
+ }
679
+ if (
680
+ uses.some(
681
+ (index) => index === 0 || !/^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(argv[index] ?? ""),
682
+ )
683
+ )
684
+ return undefined;
685
+ const gitAdd =
686
+ argv.slice(0, 3).join(" ") === "git add --" && uses.every((index) => index >= 3);
687
+ const reader =
688
+ ["cat", "ls", "head", "tail", "wc", "uniq", "cut", "sort", "grep", "rg"].includes(name) &&
689
+ isSafeLocalAssignmentCommand(argv);
690
+ if (!gitAdd && !reader && !checksum) return undefined;
691
+ paths.push(...uses.map((index) => argv[index] ?? ""));
692
+ }
693
+ const prefix = assignment[1] ? `cd ${assignment[1]}${assignment[2]} ` : "";
622
694
  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
- };
695
+ new TextEncoder().encode(prefix + rest.text).byteLength +
696
+ (rest.text.split(marker).length - 1) * (value.length - marker.length);
697
+ if (paths.length > 32 || size > 65_536) return undefined;
698
+ return { command: prefix + rest.text.replaceAll(marker, () => value), paths };
633
699
  }
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, "", "");
700
+ // Expand only complete, unquoted path words. Keep all other source text intact.
701
+ const paths: string[] = [];
702
+ let expanded = "";
703
+ let marked = "";
704
+ let start = 0;
705
+ let quote: "'" | '"' | undefined;
706
+ let braces = false;
707
+ const finishWord = (end: number): boolean => {
708
+ const word = command.slice(start, end);
709
+ if (!braces) {
710
+ expanded += word;
711
+ marked += word;
712
+ return true;
713
+ }
714
+ // ponytail: one comma-list per path, no nesting; extend only with a bounded shell parser.
715
+ const match =
716
+ /^([A-Za-z0-9_./-]*\/[A-Za-z0-9_./-]*)\{([A-Za-z0-9_./-]+(?:,[A-Za-z0-9_./-]+)+)\}([A-Za-z0-9_./-]*)$/.exec(
717
+ word,
718
+ );
719
+ if (!match) return false;
720
+ const members = (match[2] ?? "").split(",");
721
+ if (members.length > 16 || paths.length + members.length > 32) return false;
722
+ const values = members.map((member) => (match[1] ?? "") + member + (match[3] ?? ""));
723
+ const text = values.join(" ");
724
+ if (expanded.length + text.length + command.length - end > 65_536) return false;
725
+ paths.push(...values);
726
+ expanded += text;
727
+ marked += marker;
728
+ return true;
729
+ };
730
+ for (let i = 0; i <= command.length; i++) {
731
+ const char = command[i];
732
+ if (quote === "'") {
733
+ if (char === quote) quote = undefined;
734
+ } else if (char === "\\") {
735
+ if (command[i + 1] === undefined || /[\r\n]/.test(command[i + 1] ?? "")) return undefined;
736
+ i++;
737
+ } else if (quote === '"') {
738
+ if (char === quote) quote = undefined;
739
+ } else if (char === "'" || char === '"') {
740
+ quote = char;
741
+ } else if (char === "{" || char === "}") {
742
+ braces = true;
743
+ } else if (char === undefined || /[ \t\n]/.test(char)) {
744
+ if (!finishWord(i)) return undefined;
745
+ expanded += char ?? "";
746
+ marked += char ?? "";
747
+ start = i + 1;
748
+ braces = false;
749
+ } else if (/[\s;&|<>#]/.test(char)) {
750
+ // Other JS whitespace is a literal shell word character, not a token boundary.
751
+ return undefined;
752
+ }
753
+ }
754
+ if (quote || !paths.length || new TextEncoder().encode(expanded).byteLength > 65_536)
755
+ return undefined;
644
756
  const parsed = parseStaticShellCommands(marked);
645
757
  if (
646
- !lexical ||
647
- /[;&|<>\r\n]/.test(lexical.syntax) ||
648
758
  !parsed ||
649
759
  typeof parsed === "string" ||
650
760
  parsed.redirects.length ||
@@ -653,24 +763,19 @@ function materializeLiteralShell(command: string): LiteralShellProof | undefined
653
763
  )
654
764
  return undefined;
655
765
  const argv = unwrapProofCommand(parsed.pipelines[0]?.[0] ?? [], marker);
766
+ const expandedCommands = parseStaticShellCommands(expanded);
767
+ const expandedArgv =
768
+ expandedCommands && typeof expandedCommands !== "string"
769
+ ? unwrapProofCommand(expandedCommands.pipelines[0]?.[0] ?? [], marker)
770
+ : undefined;
771
+ // Classify actual options too: a marked path can hide an executor such as git grep -O/path.
656
772
  if (
657
773
  !argv ||
658
- argv.length !== 4 ||
659
- argv[0] !== "rg" ||
660
- argv[1] !== "-n" ||
661
- argv[2]?.startsWith("-") ||
662
- argv[3] !== marker
774
+ !expandedArgv ||
775
+ (!isPassiveTextCommand(expandedArgv) && expandedArgv[0]?.split("/").at(-1) !== "cat")
663
776
  )
664
777
  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 };
778
+ return { command: expanded, paths };
674
779
  }
675
780
 
676
781
  /** Complete package metadata loops: no prelude, loader shadowing, mutation or arbitrary suffix. */
@@ -1521,24 +1626,43 @@ function hasStaticEnvironmentRead(
1521
1626
  }
1522
1627
 
1523
1628
  function isStaticHerdrPrompt(command: string): boolean {
1629
+ // Preserve shell word boundaries and executable identity before proving composition.
1630
+ if (!literalBindingText(command, "", "")) return false;
1524
1631
  const parsed = parseStaticShellCommands(command);
1525
1632
  if (!parsed || typeof parsed === "string") return false;
1526
- const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
1633
+ const commands = parsed.pipelines.flat().map((words) => unwrapProofCommand(words, "\0"));
1527
1634
  const prompts = commands.filter(
1528
- (argv) => argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt",
1635
+ (argv) => argv?.[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt",
1529
1636
  );
1530
1637
  return (
1531
1638
  prompts.length > 0 &&
1532
- commands.every((argv) =>
1533
- argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt"
1534
- ? argv.length >= 5
1535
- : isPassiveTextCommand(argv),
1639
+ parsed.pipelines.every((pipeline) =>
1640
+ pipeline.every((words, index) => {
1641
+ const argv = unwrapProofCommand(words, "\0");
1642
+ if (!argv) return false;
1643
+ if (argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt")
1644
+ return index === 0 && argv.length >= 5;
1645
+ // cat is a reader at the start of an independent pipeline, never a consumer.
1646
+ if (argv[0] === "cat")
1647
+ return (
1648
+ index === 0 &&
1649
+ argv.length > 1 &&
1650
+ argv.slice(1).every((arg) => arg === "--" || !arg.startsWith("-"))
1651
+ );
1652
+ return isPassiveTextCommand(argv);
1653
+ }),
1536
1654
  )
1537
1655
  );
1538
1656
  }
1539
1657
 
1540
1658
  function isHerdrPrompt(command: string): boolean {
1541
- return /^herdr\s+agent\s+prompt\b/.test(command.trim());
1659
+ if (/^herdr\s+agent\s+prompt\b/.test(command.trim())) return true;
1660
+ const parsed = parseStaticShellCommands(command, true);
1661
+ if (!parsed || typeof parsed === "string") return false;
1662
+ return parsed.pipelines.flat().some((words) => {
1663
+ const argv = unwrapStaticCommand(words);
1664
+ return argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt";
1665
+ });
1542
1666
  }
1543
1667
 
1544
1668
  function hasEnvironmentRead(
@@ -1710,7 +1834,7 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
1710
1834
  if (/\bcat\b[^;&|]*<<-?\s*\S+[\s\S]*\b(?:secret|credential)\b/i.test(command)) return true;
1711
1835
  const parsed = parseStaticShellCommands(command);
1712
1836
  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;
1837
+ /\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
1838
  if (parsed && parsed !== "brace-expansion") {
1715
1839
  return parsed.pipelines.flat().some((words) => {
1716
1840
  const argv = unwrapStaticCommand(words);