@blogic-cz/agent-tools 1.5.4 → 1.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -634,8 +634,9 @@ Claude Code uses shell command hooks. The package ships a ready-made wrapper scr
634
634
 
635
635
  That's it. The hook reads tool input from stdin, runs the guard, and exits with code 2 (blocked + reason on stderr) or 0 (allowed).
636
636
 
637
- The guard permits static `printenv` reads only for exact names in `credentialGuard.allowedEnvironmentVariables`. The default list is empty. Multiple names are allowed only when each name is approved.
638
- It recognizes `rtk` and `rtk proxy` prefixes and checks each command in a pipeline or chain.
637
+ The guard permits static `printenv` reads and simple `echo`/`printf` expansions only for exact names in `credentialGuard.allowedEnvironmentVariables`. The default list is empty. Multiple names are allowed only when each name is approved. Leading standalone literal assignments such as `F=README.md; cat "$F"` work in supported static commands. Conditional assignments, loop variables, mutation such as `printf -v`, and unknown commands cannot establish that proof. Use a literal path or a leading semicolon-terminated assignment for these cases. Approved environment values may be displayed, but redirecting their pipeline or passing them to an executor stays blocked.
638
+ 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"`.
639
+ It recognizes `rtk`, `rtk proxy`, `command`, and supported `env` prefixes and checks each command in a pipeline or chain. Quoted filenames, comments, and redirects retain the sensitive-path checks. Jq object selectors and inline awk programs qualify only when their actual program and options establish passive behavior.
639
640
  Literal environment-command text in `rg`, `grep`, `git grep`, `echo`, and `printf` is allowed. In pipelines, every other command must be a known passive text command or an approved static environment read.
640
641
  Each pipeline is checked separately from statements joined by `;`, `&&`, or `||`.
641
642
  Execution options, other environment reads, and environment reads in dynamic or unsupported shell syntax stay blocked. Custom dangerous-command patterns still apply to the complete input.
@@ -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;AAM5E,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;AAgsBF,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,CAuNrF;AAQD,kEAAkE;AAClE,eAAO,MAAM,uBAAuB,UAj8BD,SAAS,UAAU,UAAU,KAAK,IAi8BM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YAn8BC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAm8BjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aAr8BE,MAAM,KAAK,OAq8BgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aAv8BE,MAAM,KAAK,OAu8BgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YAz8BC,MAAM,KAAK,OAy8B0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YA38BC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IA28Bf,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YA78BC,MAAM,KAAK,OA68BsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YA/8BC,MAAM,KAAK,MAAM,GAAG,IA+8Ba,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;AAM5E,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;AA6nCF,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,CAoPrF;AAQD,kEAAkE;AAClE,eAAO,MAAM,uBAAuB,UA35CD,SAAS,UAAU,UAAU,KAAK,IA25CM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YA75CC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IA65CjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aA/5CE,MAAM,KAAK,OA+5CgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aAj6CE,MAAM,KAAK,OAi6CgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YAn6CC,MAAM,KAAK,OAm6C0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YAr6CC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAq6Cf,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YAv6CC,MAAM,KAAK,OAu6CsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YAz6CC,MAAM,KAAK,MAAM,GAAG,IAy6Ca,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure platform, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -206,7 +206,7 @@ const DEFAULT_POLLING_DETECTION_RULES: PollingDetectionRule[] = [
206
206
  /**
207
207
  * Read-only gh subcommands safe on external repos with -R flag.
208
208
  */
209
- const GH_ALLOWED_READONLY_SUBCOMMANDS = [
209
+ const GH_ALLOWED_READONLY_SUBCOMMANDS = new Set([
210
210
  "issue list",
211
211
  "issue view",
212
212
  "issue search",
@@ -220,7 +220,103 @@ const GH_ALLOWED_READONLY_SUBCOMMANDS = [
220
220
  "search issues",
221
221
  "search prs",
222
222
  "search repos",
223
- ];
223
+ ]);
224
+
225
+ function isAllowedGhArgv(argv: string[]): boolean {
226
+ const subcommand = argv.slice(1, 3).join(" ");
227
+ return (
228
+ argv.some((arg, index) => (arg === "-R" || arg === "--repo") && Boolean(argv[index + 1])) &&
229
+ GH_ALLOWED_READONLY_SUBCOMMANDS.has(subcommand)
230
+ );
231
+ }
232
+
233
+ function getLeadingLiteralAssignments(
234
+ command: string,
235
+ ): Map<string, { value: string; end: number }> {
236
+ const assignments = new Map<string, { value: string; end: number }>();
237
+ let offset = 0;
238
+ while (offset === 0 || command[offset - 1] === ";" || command[offset - 1] === "\n") {
239
+ while (/\s/.test(command[offset] ?? "")) offset++;
240
+ const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_./-]+)/.exec(command.slice(offset));
241
+ if (!assignment) break;
242
+ const end = offset + assignment[0].length;
243
+ const next = command.slice(end).match(/^\s*/)?.[0].length ?? 0;
244
+ const separator = command[end + next];
245
+ if (separator !== ";" && separator !== "\n") break;
246
+ assignments.set(assignment[1] ?? "", { value: assignment[2] ?? "", end });
247
+ offset = end + next + 1;
248
+ }
249
+ return assignments;
250
+ }
251
+
252
+ function hasSafeLocalAssignmentCommands(
253
+ command: string,
254
+ assignments: Map<string, { value: string; end: number }>,
255
+ ): boolean {
256
+ if (!assignments.size) return true;
257
+ const parsed = parseStaticShellCommands(command);
258
+ if (!parsed || typeof parsed === "string") return false;
259
+ return parsed.pipelines.flat().every((words) => {
260
+ const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_./-]+)$/.exec(words[0] ?? "");
261
+ if (assignment) {
262
+ return words.length === 1 && assignments.get(assignment[1] ?? "")?.value === assignment[2];
263
+ }
264
+ const argv = unwrapStaticCommand(words);
265
+ const name = argv[0]?.split("/").at(-1) ?? "";
266
+ if (name === "cd") return isNavigationOperand(argv, "LOCALVALUE");
267
+ if (name === "git") {
268
+ let index = 1;
269
+ while (argv[index] === "-C" && argv[index + 1]) index += 2;
270
+ return (
271
+ ["branch", "diff", "log", "rev-parse", "rev-list", "show", "status"].includes(
272
+ argv[index] ?? "",
273
+ ) && !hasExecutionOption(argv.slice(index + 1), ["exec", "ext-diff", "textconv"])
274
+ );
275
+ }
276
+ if (name === "find") {
277
+ return !argv.slice(1).some((arg) => /^-(?:exec|ok|delete|fprint|fprintf)/.test(arg));
278
+ }
279
+ if (name === "bun") return argv[1] === "run" && ["db-tool", "gh-tool"].includes(argv[2] ?? "");
280
+ return (
281
+ isPassiveTextCommand(argv) ||
282
+ ["ls", "cat", "tail", "wc", "uniq", "cut", "mv"].includes(name) ||
283
+ (name === "sort" && !hasExecutionOption(argv.slice(1), ["compress-program"])) ||
284
+ (name === "sed" &&
285
+ argv
286
+ .slice(1)
287
+ .every(
288
+ (arg) =>
289
+ /^-[En]+$/.test(arg) ||
290
+ /^s([/|]).*\1[^/|]*\1[gp]*$/.test(arg) ||
291
+ /^[A-Za-z0-9_./-]+$/.test(arg),
292
+ ) &&
293
+ !argv.includes("-f"))
294
+ );
295
+ });
296
+ }
297
+
298
+ function hasSensitivePathRedirect(
299
+ command: string,
300
+ isPathBlocked: (path: string) => boolean,
301
+ ): boolean {
302
+ let heredoc = boundedHeredoc(command);
303
+ while (heredoc) {
304
+ if (!heredoc.closed) return true;
305
+ command = [heredoc.header, heredoc.following].join("\n");
306
+ heredoc = boundedHeredoc(command);
307
+ }
308
+ const parsed = parseStaticShellCommands(command, true);
309
+ if (!parsed || typeof parsed === "string") return false;
310
+ const assignments = getLeadingLiteralAssignments(command);
311
+ return parsed.redirects.some(({ target, dynamic }) => {
312
+ if (!dynamic) return isPathBlocked(target);
313
+ const materialized = target.replace(
314
+ /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
315
+ (match, braced: string, plain: string) => assignments.get(braced ?? plain)?.value ?? match,
316
+ );
317
+ return /[$`]/.test(materialized) || isPathBlocked(materialized);
318
+ });
319
+ }
224
320
 
225
321
  // ============================================================================
226
322
  // HELPERS
@@ -230,11 +326,21 @@ function escapeRegex(s: string): string {
230
326
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
231
327
  }
232
328
 
233
- /** Static shell words only. Never expand variables or execute a command. */
329
+ /** Static shell words only. Never expand variables or execute a command.
330
+ * literalOperandsOnly retains recognizable paths/commands on unsupported expansions;
331
+ * callers may use it to deny, never to establish a safe exception.
332
+ */
234
333
  function parseStaticShellCommands(
235
334
  command: string,
236
- ): { pipelines: string[][][] } | "brace-expansion" | undefined {
335
+ literalOperandsOnly = false,
336
+ ):
337
+ | { pipelines: string[][][]; redirects: { target: string; dynamic: boolean; pipeline: number }[] }
338
+ | "brace-expansion"
339
+ | undefined {
237
340
  const pipelines: string[][][] = [];
341
+ const redirects: { target: string; dynamic: boolean; pipeline: number }[] = [];
342
+ let redirect = false;
343
+ let dynamic = false;
238
344
  let commands: string[][] = [];
239
345
  let argv: string[] = [];
240
346
  let word = "";
@@ -242,9 +348,14 @@ function parseStaticShellCommands(
242
348
  let quote: "'" | '"' | undefined;
243
349
 
244
350
  const finishWord = () => {
245
- if (started) argv.push(word);
351
+ if (started) {
352
+ if (redirect) redirects.push({ target: word, dynamic, pipeline: pipelines.length });
353
+ else argv.push(word);
354
+ redirect = false;
355
+ }
246
356
  word = "";
247
357
  started = false;
358
+ dynamic = false;
248
359
  };
249
360
  const finishCommand = () => {
250
361
  finishWord();
@@ -269,17 +380,46 @@ function parseStaticShellCommands(
269
380
  word += next;
270
381
  started = true;
271
382
  } else if (char === "$" || char === "`") {
272
- return undefined;
383
+ if (!literalOperandsOnly) return undefined;
384
+ dynamic = true;
385
+ word += char;
386
+ started = true;
273
387
  } else if (quote === '"') {
274
388
  if (char === quote) quote = undefined;
275
389
  else word += char;
276
390
  } else if (char === "'" || char === '"') {
277
391
  quote = char;
278
392
  started = true;
279
- } else if (char === "{" && /^\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(command.slice(i))) {
393
+ } else if (
394
+ !literalOperandsOnly &&
395
+ char === "{" &&
396
+ /^\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(command.slice(i))
397
+ ) {
280
398
  return "brace-expansion";
281
- } else if (/[<>()]/.test(char) || (char === "#" && !started)) {
282
- return undefined;
399
+ } else if (char === ">" || char === "<") {
400
+ if (char === "<" && command[i + 1] === "<") {
401
+ if (literalOperandsOnly) break;
402
+ return undefined;
403
+ }
404
+ if (/^\d+$/.test(word)) {
405
+ word = "";
406
+ started = false;
407
+ } else {
408
+ finishWord();
409
+ }
410
+ if (redirect) return undefined;
411
+ if (command[i + 1] === char) i++;
412
+ if (command[i + 1] === "&" && /[0-9-]/.test(command[i + 2] ?? "")) {
413
+ i += 2;
414
+ while (/\d/.test(command[i + 1] ?? "")) i++;
415
+ } else {
416
+ redirect = true;
417
+ }
418
+ } else if (char === "#" && !started) {
419
+ while (i + 1 < command.length && command[i + 1] !== "\n") i++;
420
+ } else if (/[()]/.test(char)) {
421
+ if (!literalOperandsOnly) return undefined;
422
+ finishWord();
283
423
  } else if (char === "|" && command[i - 1] !== "|" && command[i + 1] !== "|") {
284
424
  finishCommand();
285
425
  if (command[i + 1] === "&") i++;
@@ -293,9 +433,10 @@ function parseStaticShellCommands(
293
433
  }
294
434
  }
295
435
 
296
- if (quote) return undefined;
436
+ if (quote && !literalOperandsOnly) return undefined;
297
437
  finishPipeline();
298
- return { pipelines };
438
+ if (redirect) return undefined;
439
+ return { pipelines, redirects };
299
440
  }
300
441
 
301
442
  function mentionsEnvironmentRead(text: string): boolean {
@@ -383,20 +524,59 @@ function isLiteralTextProducer(argv: string[]): boolean {
383
524
 
384
525
  function isAwkExecution(argv: string[]): boolean {
385
526
  if (argv[0]?.split("/").at(-1) !== "awk") return false;
386
- const program = argv.slice(1).join(" ");
387
- return /\b(?:system|getline)\s*\(|\||\b(?:print|printf)\b[^\n]*>/.test(program);
527
+ // Only a literal inline program establishes passive behavior. File/unknown options do not.
528
+ const program = argv[1];
529
+ return (
530
+ !program ||
531
+ program.startsWith("-") ||
532
+ /\b(?:system|getline|ENVIRON)\b|\||\b(?:print|printf)\b[^\n]*>|@(?:include|load)/.test(
533
+ program,
534
+ ) ||
535
+ argv.slice(2).some((arg) => arg.startsWith("-"))
536
+ );
388
537
  }
389
538
 
390
539
  function isJqObjectConstruction(argv: string[]): boolean {
540
+ if (argv[0]?.split("/").at(-1) !== "jq") return false;
541
+ let index = 1;
542
+ // Options with values, program files, and unknown options cannot prove an inline filter.
543
+ while (/^-[rRcMsScn]+$/.test(argv[index] ?? "")) index++;
544
+ if (argv[index] === "--") index++;
545
+ const filter = argv[index] ?? "";
546
+ if (argv.slice(index + 1).some((arg) => arg.startsWith("-"))) return false;
391
547
  return (
392
- argv[0]?.split("/").at(-1) === "jq" &&
393
- argv
394
- .slice(1)
395
- .some((arg) => /^\{[A-Za-z_][A-Za-z0-9_-]*(?:,\s*[A-Za-z_][A-Za-z0-9_-]*)*\}$/.test(arg)) &&
396
- !argv.slice(1).some((arg) => /\benv\b|\binput(?:s)?\b/.test(arg))
548
+ /^\{\s*[A-Za-z_][A-Za-z0-9_-]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_-]*)*\s*\}$/.test(filter) ||
549
+ /^\{\s*[A-Za-z_][A-Za-z0-9_-]*\s*:\s*\.[A-Za-z_][A-Za-z0-9_.-]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_-]*\s*:\s*\.[A-Za-z_][A-Za-z0-9_.-]*)*\s*\}$/.test(
550
+ filter,
551
+ )
397
552
  );
398
553
  }
399
554
 
555
+ function jqFileArguments(argv: string[]): string[] {
556
+ const files: string[] = [];
557
+ let index = 1;
558
+ for (; index < argv.length; index++) {
559
+ const arg = argv[index] ?? "";
560
+ if (arg === "--") {
561
+ index++;
562
+ break;
563
+ }
564
+ if (/^-[rRcMsScn]+$/.test(arg)) continue;
565
+ if (arg === "--arg" || arg === "--argjson") {
566
+ index += 2;
567
+ continue;
568
+ }
569
+ if (arg === "--rawfile" || arg === "--slurpfile") {
570
+ files.push(argv[index + 2] ?? "");
571
+ index += 2;
572
+ continue;
573
+ }
574
+ if (arg.startsWith("-")) return argv.slice(1);
575
+ break;
576
+ }
577
+ return [...files, ...argv.slice(index + 1)];
578
+ }
579
+
400
580
  /** Index in `args` where the command wrapped by `env` starts, or null when `env` only lists. */
401
581
  function environmentCommandStart(args: string[]): number | null {
402
582
  for (let i = 0; i < args.length; i++) {
@@ -408,7 +588,8 @@ function environmentCommandStart(args: string[]): number | null {
408
588
  continue;
409
589
  }
410
590
  if (arg === "-i" || arg === "--ignore-environment" || arg.startsWith("--chdir=")) continue;
411
- if (arg.startsWith("-")) continue;
591
+ if (arg.startsWith("--unset=")) continue;
592
+ if (arg.startsWith("-")) return null;
412
593
  return i;
413
594
  }
414
595
  return null;
@@ -444,13 +625,25 @@ function isSafeLiteralConsumer(argv: string[]): boolean {
444
625
  }
445
626
 
446
627
  function unwrapStaticCommand(words: string[]): string[] {
447
- const argv = [...words];
448
- const executable = () => argv[0]?.split("/").at(-1);
449
- while (executable() === "rtk" || executable() === "command") {
450
- const wrapper = executable();
451
- argv.shift();
452
- if (wrapper === "rtk" && argv[0] === "proxy") argv.shift();
453
- if (wrapper === "command" && argv[0] === "--") argv.shift();
628
+ let argv = [...words];
629
+ while (argv.length) {
630
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0] ?? "")) {
631
+ argv.shift();
632
+ continue;
633
+ }
634
+ const executable = argv[0]?.split("/").at(-1);
635
+ if (executable === "env") {
636
+ const wrapped = unwrapEnvironmentCommand(argv);
637
+ if (!wrapped) break;
638
+ argv = wrapped;
639
+ } else if (executable === "rtk" || executable === "command") {
640
+ argv.shift();
641
+ if (executable === "rtk") {
642
+ if (argv[0] === "proxy") argv.shift();
643
+ else if (argv[0] === "read") argv[0] = "cat";
644
+ }
645
+ if (executable === "command" && argv[0] === "--") argv.shift();
646
+ } else break;
454
647
  }
455
648
  return argv;
456
649
  }
@@ -473,25 +666,62 @@ function hasExecutionOption(args: string[], longOptions: string[], shortOption?:
473
666
  });
474
667
  }
475
668
 
669
+ function passivePrintfFormatIndex(argv: string[]): number | undefined {
670
+ const index = argv[1] === "--" ? 2 : 1;
671
+ const format = argv[index];
672
+ if (format === undefined || (index === 1 && format.startsWith("-"))) return undefined;
673
+ // Ignore escaped percent signs; %n writes a shell variable named by a data argument.
674
+ return /%[-+ #0]*(?:\d+|\*)?(?:\.(?:\d+|\*))?[hljztL]*n/.test(format.replaceAll("%%", ""))
675
+ ? undefined
676
+ : index;
677
+ }
678
+
679
+ /** Unknown values are permitted only in operands that navigate without displaying the value. */
680
+ function isNavigationOperand(argv: string[], marker: string): boolean {
681
+ const name = argv[0]?.split("/").at(-1);
682
+ if (name === "cd") {
683
+ const index = argv[1] === "--" ? 2 : 1;
684
+ return argv.length === index + 1 && !argv[0]?.includes(marker);
685
+ }
686
+ if (name !== "git") return false;
687
+ let index = 1;
688
+ while (argv[index] === "-C" && argv[index + 1]) index += 2;
689
+ return (
690
+ ["status", "rev-parse", "rev-list"].includes(argv[index] ?? "") &&
691
+ !argv.slice(index).some((arg) => arg.includes(marker)) &&
692
+ !argv[0]?.includes(marker)
693
+ );
694
+ }
695
+
476
696
  function isPassiveTextCommand(argv: string[]): boolean {
477
697
  const name = argv[0]?.split("/").at(-1) ?? "";
478
698
  const args = argv.slice(1);
479
699
  // These commands consume literal text. Execution options are not exceptions.
480
- if (["echo", "printf", "grep", "head"].includes(name)) {
700
+ if (name === "printf") return passivePrintfFormatIndex(argv) !== undefined;
701
+ if (["echo", "grep", "head"].includes(name)) {
481
702
  return true;
482
703
  }
483
704
  if (name === "rg") return !hasExecutionOption(args, ["pre", "hostname-bin"]);
484
705
  if (name === "awk") return !isAwkExecution(argv);
485
706
  if (name === "jq") return isJqObjectConstruction(argv);
486
707
  return (
487
- name === "git" &&
488
- args[0] === "grep" &&
489
- !hasExecutionOption(args, ["open-files-in-pager", "textconv"], "O")
708
+ (name === "git" && args[0] === "status") ||
709
+ (name === "git" &&
710
+ args[0] === "grep" &&
711
+ !hasExecutionOption(args, ["open-files-in-pager", "textconv"], "O"))
490
712
  );
491
713
  }
492
714
 
493
715
  function hasStaticEnvironmentRead(argv: string[], allowedNames: Set<string>): boolean {
494
716
  const name = argv[0]?.split("/").at(-1);
717
+ if (
718
+ ["sh", "bash", "zsh"].includes(name ?? "") &&
719
+ hasExecutionOption(argv.slice(1), ["command"], "c") &&
720
+ argv.slice(1).some((arg) => /\$\{?[A-Za-z_]/.test(arg))
721
+ ) {
722
+ return true;
723
+ }
724
+ if (name === "awk" && argv.slice(1).some((arg) => /\bENVIRON\b/.test(arg))) return true;
495
725
  if (name === "printenv") return !isAllowedEnvironmentRead(argv, allowedNames);
496
726
  if (name === "env") {
497
727
  const wrapped = unwrapEnvironmentCommand(argv);
@@ -505,21 +735,20 @@ function hasStaticEnvironmentRead(argv: string[], allowedNames: Set<string>): bo
505
735
  }
506
736
 
507
737
  function isStaticHerdrPrompt(command: string): boolean {
508
- if (/^herdr\s+agent\s+prompt\s+\S+\s+(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*')$/.test(command.trim())) {
509
- if (!/[$`]/.test(command)) return true;
510
- return !hasSensitiveFileRead(command) && !mentionsEnvironmentRead(command);
511
- }
512
738
  const parsed = parseStaticShellCommands(command);
513
- if (
514
- !parsed ||
515
- typeof parsed === "string" ||
516
- parsed.pipelines.length !== 1 ||
517
- parsed.pipelines[0]?.length !== 1
518
- ) {
519
- return false;
520
- }
521
- const argv = unwrapStaticCommand(parsed.pipelines[0]?.[0] ?? []);
522
- return argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt";
739
+ if (!parsed || typeof parsed === "string") return false;
740
+ const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
741
+ const prompts = commands.filter(
742
+ (argv) => argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt",
743
+ );
744
+ return (
745
+ prompts.length > 0 &&
746
+ commands.every((argv) =>
747
+ argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt"
748
+ ? argv.length >= 5
749
+ : isPassiveTextCommand(argv),
750
+ )
751
+ );
523
752
  }
524
753
 
525
754
  function isHerdrPrompt(command: string): boolean {
@@ -547,6 +776,23 @@ function hasEnvironmentRead(command: string, allowedNames: Set<string>): boolean
547
776
  }
548
777
  const pipelines = parsed.pipelines.map((commands) => commands.map(unwrapStaticCommand));
549
778
  const unwrapped = pipelines.flat();
779
+ if (
780
+ command.includes(">") &&
781
+ unwrapped.some((argv) => isAllowedEnvironmentRead(argv, allowedNames))
782
+ ) {
783
+ return true;
784
+ }
785
+ if (
786
+ command.includes(">") &&
787
+ pipelines.some((pipeline) =>
788
+ pipeline.some(
789
+ (argv) => isLiteralTextProducer(argv) && mentionsEnvironmentRead(argv.join(" ")),
790
+ ),
791
+ ) &&
792
+ unwrapped.some((argv) => ["sh", "bash", "zsh", "xargs", "eval"].includes(argv[0] ?? ""))
793
+ ) {
794
+ return true;
795
+ }
550
796
  if (unwrapped.some((argv) => hasStaticEnvironmentRead(argv, allowedNames))) {
551
797
  return true;
552
798
  }
@@ -568,22 +814,62 @@ function hasEnvironmentRead(command: string, allowedNames: Set<string>): boolean
568
814
  return result;
569
815
  }
570
816
 
817
+ /** Find an actual heredoc operator outside shell quotes/comments, then parse one bounded body. */
818
+ function boundedHeredoc(command: string) {
819
+ let quote: "'" | '"' | undefined;
820
+ let wordStarted = false;
821
+ for (let i = 0; i < command.length; i++) {
822
+ const char = command[i];
823
+ if (quote === "'") {
824
+ if (char === quote) quote = undefined;
825
+ } else if (char === "\\") {
826
+ i++;
827
+ wordStarted = true;
828
+ } else if (quote === '"') {
829
+ if (char === quote) quote = undefined;
830
+ } else if (char === "'" || char === '"') {
831
+ quote = char;
832
+ wordStarted = true;
833
+ } else if (char === "#" && !wordStarted) {
834
+ while (i + 1 < command.length && command[i + 1] !== "\n") i++;
835
+ } else if (command.startsWith("<<", i)) {
836
+ const match =
837
+ /^<<(-?)[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2[ \t]*(?:#[^\r\n]*)?(?:\r?\n|$)/.exec(
838
+ command.slice(i),
839
+ );
840
+ const lines = match
841
+ ? command
842
+ .slice(i + match[0].length)
843
+ .trimEnd()
844
+ .split(/\r?\n/)
845
+ : [];
846
+ const end = lines.findIndex(
847
+ (line) => (match?.[1] ? line.replace(/^\t+/, "") : line) === match?.[3],
848
+ );
849
+ return {
850
+ header: command.slice(0, i),
851
+ quoted: Boolean(match?.[2]),
852
+ closed: Boolean(match) && end >= 0,
853
+ body: lines.slice(0, end >= 0 ? end : undefined).join("\n"),
854
+ following: end >= 0 ? lines.slice(end + 1).join("\n") : "",
855
+ };
856
+ } else {
857
+ wordStarted = !/[\s;&|()<>]/.test(char ?? "");
858
+ }
859
+ }
860
+ return undefined;
861
+ }
862
+
571
863
  /** A single literal writer cannot execute its body or feed another command. */
572
864
  function isLiteralTextWrite(command: string): boolean {
573
- const lines = command.trimEnd().split(/\r?\n/);
574
- const heredoc = /^(.*?)<<(-?)[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\3[ \t]*$/.exec(lines[0] ?? "");
865
+ const heredoc = boundedHeredoc(command);
575
866
  let header = command;
576
867
  let writers = ["echo", "printf"];
577
868
  if (heredoc) {
578
- const [, prefix, stripTabs, quote, delimiter] = heredoc;
579
- const end = lines.findIndex(
580
- (line, index) => index > 0 && (stripTabs ? line.replace(/^\t+/, "") : line) === delimiter,
581
- );
582
- // Only the first delimiter closes the body. No following commands are exceptions.
583
- if (end < 0 || end !== lines.length - 1) return false;
584
- const body = lines.slice(1, end).join("\n");
585
- if (!quote && /[$`\\]/.test(body)) return false;
586
- header = prefix ?? "";
869
+ // Only a complete body with no following commands is a literal-write exception.
870
+ if (!heredoc.closed || heredoc.following) return false;
871
+ if (!heredoc.quoted && /[$`\\]/.test(heredoc.body)) return false;
872
+ header = heredoc.header;
587
873
  writers = ["cat", "tee"];
588
874
  } else if (!command.includes(">")) {
589
875
  return false;
@@ -591,14 +877,7 @@ function isLiteralTextWrite(command: string): boolean {
591
877
 
592
878
  // Output redirection does not execute text. Other unsupported syntax still fails parsing.
593
879
  const parsed = parseStaticShellCommands(header.replace(/2>&1/g, " ").replace(/>>?/g, " "));
594
- if (!parsed || parsed === "brace-expansion") {
595
- const balancedQuotes =
596
- (command.match(/'/g)?.length ?? 0) % 2 === 0 && (command.match(/"/g)?.length ?? 0) % 2 === 0;
597
- return (
598
- balancedQuotes &&
599
- /^(?:cd\s+\S+\s+&&\s+)?(?:echo|printf)\b[\s\S]*>>?\s+\S+\s*(?:&&\s+tail\b.*)?$/i.test(command)
600
- );
601
- }
880
+ if (!parsed || parsed === "brace-expansion") return false;
602
881
  const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
603
882
  const writerIndexes = commands
604
883
  .map((argv, index) => (writers.includes(argv[0]?.split("/").at(-1) ?? "") ? index : -1))
@@ -613,18 +892,21 @@ function isLiteralTextWrite(command: string): boolean {
613
892
  );
614
893
  }
615
894
 
616
- function hasSensitiveFileRead(command: string): boolean {
895
+ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) => boolean): boolean {
617
896
  const sensitivePath =
618
897
  /(?:^|[/\s'"])(?:[.]env(?![.](?:example|template|sample)\b)|\S*[.](?:pem|key)\b|\S*[.](?:ssh|aws)[/]|(?:\S*secret(?:-|[/\s.]|$))|(?:(?:secrets?|credentials?)(?:[/\s.]|$)|[A-Za-z0-9_.-]*(?:secrets?|credentials?)(?:[/\s.]|$)|[A-Za-z0-9_.-]*-(?:secrets?|credentials?)(?:[/\s.-]|$)))/i;
619
898
  const sensitiveCamelCase =
620
899
  /(?:^|[/\s'"])[A-Za-z0-9_.-]*(?:(?:secret|credential)[A-Z]|(?:Secret|Credential)[A-Z])[^/\s'"]*/;
621
900
  const sensitiveName = /secret|credential/i;
622
901
  const isSensitivePath = (text: string) =>
623
- sensitivePath.test(text) || sensitiveCamelCase.test(text) || sensitiveName.test(text);
902
+ sensitivePath.test(text) ||
903
+ sensitiveCamelCase.test(text) ||
904
+ (sensitiveName.test(text) && !/(?:^|\/)credential-guard(?:-[^/]+)?\.(?:ts|md)$/.test(text));
624
905
  if (/(?:^|[\s;&|])--env-file(?:=|\s)[^;&|]*\.env\b/i.test(command)) return true;
625
906
  if (/\bcat\b[^;&|]*<<-?\s*\S+[\s\S]*\b(?:secret|credential)\b/i.test(command)) return true;
626
907
  const parsed = parseStaticShellCommands(command);
627
- const readers = /\b(?:cat|cp|grep|rg|sed|awk|head|tail|less|more|source|ls)\b/i;
908
+ const readers =
909
+ /\b(?:cat|cp|grep|rg|sed|awk|jq|head|tail|less|more|source|ls|sort|uniq|cut|wc|mv|find)\b/i;
628
910
  if (parsed && parsed !== "brace-expansion") {
629
911
  return parsed.pipelines.flat().some((words) => {
630
912
  const argv = unwrapStaticCommand(words);
@@ -633,6 +915,17 @@ function hasSensitiveFileRead(command: string): boolean {
633
915
  if (inspectedArgv === null) return false;
634
916
  const inspected = unwrapStaticCommand(inspectedArgv);
635
917
  const name = inspected[0]?.split("/").at(-1) ?? "";
918
+ if (name === "bun" && inspected[1] === "run" && inspected[2] === "gh-tool") {
919
+ return inspected.some((arg, index) => {
920
+ const path =
921
+ arg === "--body-file"
922
+ ? inspected[index + 1]
923
+ : arg.startsWith("--body-file=")
924
+ ? arg.slice("--body-file=".length)
925
+ : undefined;
926
+ return path !== undefined && (isPathBlocked(path) || isSensitivePath(path));
927
+ });
928
+ }
636
929
  if (!readers.test(name)) return false;
637
930
  if (name === "rg" || name === "grep") {
638
931
  const args = inspected.slice(1);
@@ -722,11 +1015,23 @@ function hasSensitiveFileRead(command: string): boolean {
722
1015
  }
723
1016
  paths.push(arg);
724
1017
  }
725
- return paths.some(isSensitivePath);
1018
+ return paths.some((path) => isPathBlocked(path) || isSensitivePath(path));
726
1019
  }
727
- return inspected.slice(1).some(isSensitivePath);
1020
+ const paths = name === "jq" ? jqFileArguments(inspected) : inspected.slice(1);
1021
+ return paths.some((path) => isPathBlocked(path) || isSensitivePath(path));
728
1022
  });
729
1023
  }
1024
+ const operands = parseStaticShellCommands(command, true);
1025
+ if (
1026
+ operands &&
1027
+ typeof operands !== "string" &&
1028
+ operands.pipelines.flat().some((words) => {
1029
+ const argv = unwrapStaticCommand(words);
1030
+ return readers.test(argv[0] ?? "") && argv.slice(1).some(isPathBlocked);
1031
+ })
1032
+ )
1033
+ return true;
1034
+ if (readers.test(command) && command.split(/[\s'"`()<>;&|]+/).some(isPathBlocked)) return true;
730
1035
  const unquoted = command.replace(/'(?:\\.|[^'])*'/g, " ").replace(/"(?:\\.|[^"$`])*"/g, " ");
731
1036
  if (/\b(?:bun|node|python3?|ruby)\b/i.test(command) && isSensitivePath(command)) {
732
1037
  return true;
@@ -738,9 +1043,149 @@ function hasSensitiveFileRead(command: string): boolean {
738
1043
  );
739
1044
  }
740
1045
 
741
- function hasEnvironmentVariableExpansionRead(command: string): boolean {
742
- if (!/\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/.test(command)) return false;
743
- return /\b(?:echo|printf)\b[^;&|]*\$|\b(?:sh|bash|zsh)\b[^;&|]*\s-c\b/i.test(command);
1046
+ function hasEnvironmentVariableExpansionRead(
1047
+ command: string,
1048
+ allowedNames: Set<string>,
1049
+ isPathBlocked: (path: string) => boolean,
1050
+ ): boolean {
1051
+ let heredoc = boundedHeredoc(command);
1052
+ while (heredoc) {
1053
+ // In an unquoted heredoc, # and quote characters are data, not shell syntax.
1054
+ if (!heredoc.closed || (!heredoc.quoted && /[$`\\]/.test(heredoc.body))) return true;
1055
+ command = [heredoc.header, heredoc.following].join("\n");
1056
+ heredoc = boundedHeredoc(command);
1057
+ }
1058
+ if (!command.includes("$")) return false;
1059
+ let invalidExpansion = false;
1060
+ const assignments = getLeadingLiteralAssignments(command);
1061
+ const localValues = new Map<string, string>();
1062
+ let localIndex = 0;
1063
+ let expansions = 0;
1064
+ let normalized = "";
1065
+ let quote: "'" | '"' | undefined;
1066
+ let wordStarted = false;
1067
+ for (let i = 0; i < command.length; i++) {
1068
+ const char = command[i] ?? "";
1069
+ if (!quote && char === "#" && !wordStarted) {
1070
+ while (i + 1 < command.length && command[i + 1] !== "\n") i++;
1071
+ continue;
1072
+ }
1073
+ if (!quote && /[\s;&|()<>]/.test(char)) wordStarted = false;
1074
+ else wordStarted = true;
1075
+ if (char === "\\" && quote !== "'") {
1076
+ normalized += char + (command[++i] ?? "");
1077
+ continue;
1078
+ }
1079
+ if (quote === "'") {
1080
+ normalized += char;
1081
+ if (char === "'") quote = undefined;
1082
+ continue;
1083
+ }
1084
+ if (char === "'" && quote !== '"') {
1085
+ normalized += char;
1086
+ quote = "'";
1087
+ continue;
1088
+ }
1089
+ if (char === '"') {
1090
+ normalized += char;
1091
+ quote = quote === '"' ? undefined : '"';
1092
+ continue;
1093
+ }
1094
+ if (char !== "$") {
1095
+ normalized += char;
1096
+ continue;
1097
+ }
1098
+ const variable = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}|^\$([A-Za-z_][A-Za-z0-9_]*)/.exec(
1099
+ command.slice(i),
1100
+ );
1101
+ if (!variable) {
1102
+ if (command[i + 1] === "{" || /[$][0-9@*#?!]/.test(command.slice(i, i + 2))) {
1103
+ invalidExpansion = true;
1104
+ }
1105
+ normalized += char;
1106
+ continue;
1107
+ }
1108
+ expansions++;
1109
+ const name = variable[1] ?? variable[2] ?? "";
1110
+ const assignment = assignments.get(name);
1111
+ if (assignment && assignment.end >= i) invalidExpansion = true;
1112
+ if (assignment && assignment.end < i) {
1113
+ const marker = `LOCALVALUE${localIndex++}`;
1114
+ localValues.set(marker, assignment.value);
1115
+ normalized += marker;
1116
+ } else if (allowedNames.has(name)) {
1117
+ normalized += quote ? "ENVVALUE" : "ENVVALUEUNQUOTED";
1118
+ } else {
1119
+ normalized += quote ? "UNKNOWNVALUE" : "UNKNOWNVALUEUNQUOTED";
1120
+ }
1121
+ i += variable[0].length - 1;
1122
+ }
1123
+ if (!expansions) return invalidExpansion;
1124
+ if (invalidExpansion) return true;
1125
+
1126
+ const localMaterialized = [...localValues].reduce(
1127
+ (text, [marker, value]) => text.replaceAll(marker, value),
1128
+ normalized,
1129
+ );
1130
+ // Options must be checked after local literals become actual argv, never as placeholders.
1131
+ if (!hasSafeLocalAssignmentCommands(localMaterialized, assignments)) return true;
1132
+ const parsed = parseStaticShellCommands(localMaterialized);
1133
+ if (!parsed || typeof parsed === "string") return true;
1134
+ for (const words of parsed.pipelines.flat()) {
1135
+ if (!words.some((arg) => arg.includes("UNKNOWNVALUE"))) continue;
1136
+ if (words.some((arg) => arg.includes("UNKNOWNVALUEUNQUOTED"))) return true;
1137
+ const argv = unwrapStaticCommand(words);
1138
+ if (
1139
+ words.slice(0, words.length - argv.length).some((arg) => arg.includes("UNKNOWNVALUE")) ||
1140
+ !isNavigationOperand(argv, "UNKNOWNVALUE")
1141
+ )
1142
+ return true;
1143
+ }
1144
+ if (!localMaterialized.includes("ENVVALUE")) {
1145
+ return (
1146
+ hasSensitiveFileRead(localMaterialized, isPathBlocked) ||
1147
+ hasSensitivePathRedirect(localMaterialized, isPathBlocked) ||
1148
+ hasEnvironmentRead(localMaterialized, allowedNames)
1149
+ );
1150
+ }
1151
+
1152
+ for (const [index, pipeline] of parsed.pipelines.entries()) {
1153
+ const hasEnvironmentValue = pipeline.some((argv) =>
1154
+ argv.some((arg) => arg.includes("ENVVALUE")),
1155
+ );
1156
+ if (!hasEnvironmentValue) continue;
1157
+ if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
1158
+ if (
1159
+ pipeline.some((argv) => {
1160
+ const unwrapped = unwrapStaticCommand(argv);
1161
+ const name = unwrapped[0]?.split("/").at(-1) ?? "";
1162
+ if (argv.some((arg) => arg.includes("ENVVALUE"))) {
1163
+ if (argv.slice(0, argv.length - unwrapped.length).some((arg) => arg.includes("ENVVALUE")))
1164
+ return true;
1165
+ if (isNavigationOperand(unwrapped, "ENVVALUE")) {
1166
+ return unwrapped.some((arg) => arg.includes("ENVVALUEUNQUOTED"));
1167
+ }
1168
+ const formatIndex = name === "printf" ? passivePrintfFormatIndex(unwrapped) : undefined;
1169
+ return (
1170
+ !["echo", "printf"].includes(name) ||
1171
+ !isPassiveTextCommand(unwrapped) ||
1172
+ unwrapped[0]?.includes("ENVVALUE") === true ||
1173
+ (name === "printf" &&
1174
+ (formatIndex === undefined ||
1175
+ unwrapped.slice(0, formatIndex + 1).some((arg) => arg.includes("ENVVALUE"))))
1176
+ );
1177
+ }
1178
+ return !isPassiveTextCommand(unwrapped);
1179
+ })
1180
+ ) {
1181
+ return true;
1182
+ }
1183
+ }
1184
+ const materialized = [...localValues].reduce(
1185
+ (text, [marker, value]) => text.replaceAll(marker, value),
1186
+ normalized.replaceAll("ENVVALUE", "SAFEENVVALUE"),
1187
+ );
1188
+ return hasSensitiveFileRead(materialized, isPathBlocked);
744
1189
  }
745
1190
 
746
1191
  function hasEnvironmentSourceAccess(command: string): boolean {
@@ -849,37 +1294,44 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
849
1294
  }
850
1295
 
851
1296
  function isDangerousBashCommand(command: string): boolean {
852
- return (
853
- dangerousBashPatterns.some((pattern) => pattern.test(command)) ||
854
- hasSensitiveFileRead(command) ||
855
- hasEnvironmentVariableExpansionRead(command) ||
856
- hasEnvironmentSourceAccess(command) ||
857
- hasEnvironmentRead(command, allowedEnvironmentVariables)
858
- );
1297
+ return getDangerousBashReason(command) !== null;
859
1298
  }
860
1299
 
861
- function isGhCommandAllowed(command: string): boolean {
862
- if (!/ -R\s+\S+/.test(command) && !/ --repo\s+\S+/.test(command)) {
863
- return false;
1300
+ function getDangerousBashReason(command: string): string | null {
1301
+ if (
1302
+ hasSensitiveFileRead(command, isPathBlocked) ||
1303
+ hasSensitivePathRedirect(command, isPathBlocked)
1304
+ ) {
1305
+ return "accesses a sensitive file path";
864
1306
  }
865
-
866
- const ghMatch = command.match(/(?:^|[;&|]\s*)gh\s+(\S+(?:\s+\S+)?)/);
867
- if (!ghMatch) {
868
- return false;
1307
+ if (
1308
+ !isLiteralTextWrite(command) &&
1309
+ hasEnvironmentVariableExpansionRead(command, allowedEnvironmentVariables, isPathBlocked)
1310
+ ) {
1311
+ return "expands an unapproved or executable environment variable value";
869
1312
  }
1313
+ if (hasEnvironmentSourceAccess(command)) return "reads process environment from a script";
1314
+ if (hasEnvironmentRead(command, allowedEnvironmentVariables)) {
1315
+ return "reads environment variables or passes them to an executor";
1316
+ }
1317
+ if (dangerousBashPatterns.some((pattern) => pattern.test(command))) {
1318
+ return "matches a configured dangerous-command pattern";
1319
+ }
1320
+ return null;
1321
+ }
870
1322
 
871
- const subcommand = ghMatch[1];
872
-
873
- return GH_ALLOWED_READONLY_SUBCOMMANDS.some(
874
- (allowed) => subcommand === allowed || subcommand.startsWith(`${allowed} `),
875
- );
1323
+ function isGhCommandAllowed(command: string): boolean {
1324
+ const parsed = parseStaticShellCommands(command);
1325
+ if (!parsed || typeof parsed === "string") return false;
1326
+ const ghCommands = parsed.pipelines
1327
+ .flat()
1328
+ .map(unwrapStaticCommand)
1329
+ .filter((argv) => argv[0]?.split("/").at(-1) === "gh");
1330
+ return ghCommands.length > 0 && ghCommands.every(isAllowedGhArgv);
876
1331
  }
877
1332
 
878
1333
  function allGhCommandsAllowed(command: string): boolean {
879
- const segments = command.split(/[;&|\n]+/);
880
- const ghSegments = segments.filter((s) => /\bgh\s/.test(s));
881
- if (ghSegments.length === 0) return false;
882
- return ghSegments.every((segment) => isGhCommandAllowed(segment.trim()));
1334
+ return isGhCommandAllowed(command);
883
1335
  }
884
1336
 
885
1337
  function detectSleepPolling(command: string): string | null {
@@ -894,6 +1346,27 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
894
1346
  }
895
1347
 
896
1348
  function getBlockedCliTool(command: string): { name: string; wrapper: string } | null {
1349
+ const staticCommands = parseStaticShellCommands(command);
1350
+ const parsed = staticCommands ?? parseStaticShellCommands(command, true);
1351
+ if (parsed && typeof parsed !== "string") {
1352
+ const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
1353
+ for (const argv of commands) {
1354
+ const executable = argv[0]?.split("/").at(-1) ?? "";
1355
+ const match = blockedCliTools.find(({ name }) =>
1356
+ name === "curl (Azure DevOps)"
1357
+ ? executable === "curl" && argv.slice(1).join(" ").includes("dev.azure.com")
1358
+ : name === "az" || name === "az (Azure DevOps)"
1359
+ ? executable === "az" &&
1360
+ (name !== "az (Azure DevOps)" ||
1361
+ /^(?:devops|pipelines|repos|boards|artifacts)$/.test(argv[1] ?? ""))
1362
+ : executable === name,
1363
+ );
1364
+ if (!match) continue;
1365
+ if (match.name === "gh" && allGhCommandsAllowed(command)) continue;
1366
+ return { name: match.name, wrapper: match.wrapper };
1367
+ }
1368
+ if (staticCommands) return null;
1369
+ }
897
1370
  for (const { pattern, name, wrapper } of blockedCliTools) {
898
1371
  if (pattern.test(command)) {
899
1372
  if (name === "gh" && allGhCommandsAllowed(command)) {
@@ -946,9 +1419,10 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
946
1419
  if (tool === "bash") {
947
1420
  const command = extractCommand(args);
948
1421
 
949
- if (isDangerousBashCommand(command)) {
1422
+ const dangerousReason = getDangerousBashReason(command);
1423
+ if (dangerousReason) {
950
1424
  throw new Error(
951
- `\u{1F6AB} Command blocked: This command might expose secrets.\n\n` +
1425
+ `\u{1F6AB} Command blocked: ${dangerousReason}; this command might expose secrets.\n\n` +
952
1426
  `Command: ${command}\n\n` +
953
1427
  `If you need environment info, ask the user directly.\n\n` +
954
1428
  `Think this is wrong? Review the project repository, adjust the patterns, and submit a PR.`,