@blogic-cz/agent-tools 1.5.4 → 1.5.6

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;AAspCF,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,UAp7CD,SAAS,UAAU,UAAU,KAAK,IAo7CM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YAt7CC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAs7CjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aAx7CE,MAAM,KAAK,OAw7CgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aA17CE,MAAM,KAAK,OA07CgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YA57CC,MAAM,KAAK,OA47C0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YA97CC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IA87Cf,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YAh8CC,MAAM,KAAK,OAg8CsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YAl8CC,MAAM,KAAK,MAAM,GAAG,IAk8Ca,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.6",
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,128 @@ 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 isSafeHerdrTabCreate(argv: string[]): boolean {
253
+ if (argv[0] !== "herdr" || argv[1] !== "tab" || argv[2] !== "create") return false;
254
+ const seen = new Set<string>();
255
+ for (let index = 3; index < argv.length; index++) {
256
+ const arg = argv[index] ?? "";
257
+ const [option, ...attached] = arg.split("=");
258
+ if (option === "--focus" || option === "--no-focus") {
259
+ if (attached.length || seen.has("focus")) return false;
260
+ seen.add("focus");
261
+ continue;
262
+ }
263
+ if (!["--workspace", "--cwd", "--label"].includes(option ?? "") || seen.has(option ?? ""))
264
+ return false;
265
+ const value = attached.length ? attached.join("=") : argv[++index];
266
+ if (!value || (!attached.length && value.startsWith("--"))) return false;
267
+ seen.add(option ?? "");
268
+ }
269
+ return true;
270
+ }
271
+
272
+ function hasSafeLocalAssignmentCommands(
273
+ command: string,
274
+ assignments: Map<string, { value: string; end: number }>,
275
+ ): boolean {
276
+ if (!assignments.size) return true;
277
+ const parsed = parseStaticShellCommands(command);
278
+ if (!parsed || typeof parsed === "string") return false;
279
+ return parsed.pipelines.flat().every((words) => {
280
+ const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_./-]+)$/.exec(words[0] ?? "");
281
+ if (assignment) {
282
+ return (
283
+ words.length === 1 &&
284
+ assignment[1] !== "IFS" &&
285
+ assignments.get(assignment[1] ?? "")?.value === assignment[2]
286
+ );
287
+ }
288
+ const argv = unwrapStaticCommand(words);
289
+ if (isSafeHerdrTabCreate(argv)) return true;
290
+ const name = argv[0]?.split("/").at(-1) ?? "";
291
+ if (name === "cd") return isNavigationOperand(argv, "LOCALVALUE");
292
+ if (name === "git") {
293
+ let index = 1;
294
+ while (argv[index] === "-C" && argv[index + 1]) index += 2;
295
+ return (
296
+ ["branch", "diff", "log", "rev-parse", "rev-list", "show", "status"].includes(
297
+ argv[index] ?? "",
298
+ ) && !hasExecutionOption(argv.slice(index + 1), ["exec", "ext-diff", "textconv"])
299
+ );
300
+ }
301
+ if (name === "find") {
302
+ return !argv.slice(1).some((arg) => /^-(?:exec|ok|delete|fprint|fprintf)/.test(arg));
303
+ }
304
+ if (name === "bun") return argv[1] === "run" && ["db-tool", "gh-tool"].includes(argv[2] ?? "");
305
+ return (
306
+ isPassiveTextCommand(argv) ||
307
+ ["ls", "cat", "tail", "wc", "uniq", "cut", "mv"].includes(name) ||
308
+ (name === "sort" && !hasExecutionOption(argv.slice(1), ["compress-program"])) ||
309
+ (name === "sed" &&
310
+ argv
311
+ .slice(1)
312
+ .every(
313
+ (arg) =>
314
+ /^-[En]+$/.test(arg) ||
315
+ /^s([/|]).*\1[^/|]*\1[gp]*$/.test(arg) ||
316
+ /^[A-Za-z0-9_./-]+$/.test(arg),
317
+ ) &&
318
+ !argv.includes("-f"))
319
+ );
320
+ });
321
+ }
322
+
323
+ function hasSensitivePathRedirect(
324
+ command: string,
325
+ isPathBlocked: (path: string) => boolean,
326
+ ): boolean {
327
+ let heredoc = boundedHeredoc(command);
328
+ while (heredoc) {
329
+ if (!heredoc.closed) return true;
330
+ command = [heredoc.header, heredoc.following].join("\n");
331
+ heredoc = boundedHeredoc(command);
332
+ }
333
+ const parsed = parseStaticShellCommands(command, true);
334
+ if (!parsed || typeof parsed === "string") return false;
335
+ const assignments = getLeadingLiteralAssignments(command);
336
+ return parsed.redirects.some(({ target, dynamic }) => {
337
+ if (!dynamic) return isPathBlocked(target);
338
+ const materialized = target.replace(
339
+ /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g,
340
+ (match, braced: string, plain: string) => assignments.get(braced ?? plain)?.value ?? match,
341
+ );
342
+ return /[$`]/.test(materialized) || isPathBlocked(materialized);
343
+ });
344
+ }
224
345
 
225
346
  // ============================================================================
226
347
  // HELPERS
@@ -230,11 +351,21 @@ function escapeRegex(s: string): string {
230
351
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
231
352
  }
232
353
 
233
- /** Static shell words only. Never expand variables or execute a command. */
354
+ /** Static shell words only. Never expand variables or execute a command.
355
+ * literalOperandsOnly retains recognizable paths/commands on unsupported expansions;
356
+ * callers may use it to deny, never to establish a safe exception.
357
+ */
234
358
  function parseStaticShellCommands(
235
359
  command: string,
236
- ): { pipelines: string[][][] } | "brace-expansion" | undefined {
360
+ literalOperandsOnly = false,
361
+ ):
362
+ | { pipelines: string[][][]; redirects: { target: string; dynamic: boolean; pipeline: number }[] }
363
+ | "brace-expansion"
364
+ | undefined {
237
365
  const pipelines: string[][][] = [];
366
+ const redirects: { target: string; dynamic: boolean; pipeline: number }[] = [];
367
+ let redirect = false;
368
+ let dynamic = false;
238
369
  let commands: string[][] = [];
239
370
  let argv: string[] = [];
240
371
  let word = "";
@@ -242,9 +373,14 @@ function parseStaticShellCommands(
242
373
  let quote: "'" | '"' | undefined;
243
374
 
244
375
  const finishWord = () => {
245
- if (started) argv.push(word);
376
+ if (started) {
377
+ if (redirect) redirects.push({ target: word, dynamic, pipeline: pipelines.length });
378
+ else argv.push(word);
379
+ redirect = false;
380
+ }
246
381
  word = "";
247
382
  started = false;
383
+ dynamic = false;
248
384
  };
249
385
  const finishCommand = () => {
250
386
  finishWord();
@@ -269,17 +405,46 @@ function parseStaticShellCommands(
269
405
  word += next;
270
406
  started = true;
271
407
  } else if (char === "$" || char === "`") {
272
- return undefined;
408
+ if (!literalOperandsOnly) return undefined;
409
+ dynamic = true;
410
+ word += char;
411
+ started = true;
273
412
  } else if (quote === '"') {
274
413
  if (char === quote) quote = undefined;
275
414
  else word += char;
276
415
  } else if (char === "'" || char === '"') {
277
416
  quote = char;
278
417
  started = true;
279
- } else if (char === "{" && /^\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(command.slice(i))) {
418
+ } else if (
419
+ !literalOperandsOnly &&
420
+ char === "{" &&
421
+ /^\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(command.slice(i))
422
+ ) {
280
423
  return "brace-expansion";
281
- } else if (/[<>()]/.test(char) || (char === "#" && !started)) {
282
- return undefined;
424
+ } else if (char === ">" || char === "<") {
425
+ if (char === "<" && command[i + 1] === "<") {
426
+ if (literalOperandsOnly) break;
427
+ return undefined;
428
+ }
429
+ if (/^\d+$/.test(word)) {
430
+ word = "";
431
+ started = false;
432
+ } else {
433
+ finishWord();
434
+ }
435
+ if (redirect) return undefined;
436
+ if (command[i + 1] === char) i++;
437
+ if (command[i + 1] === "&" && /[0-9-]/.test(command[i + 2] ?? "")) {
438
+ i += 2;
439
+ while (/\d/.test(command[i + 1] ?? "")) i++;
440
+ } else {
441
+ redirect = true;
442
+ }
443
+ } else if (char === "#" && !started) {
444
+ while (i + 1 < command.length && command[i + 1] !== "\n") i++;
445
+ } else if (/[()]/.test(char)) {
446
+ if (!literalOperandsOnly) return undefined;
447
+ finishWord();
283
448
  } else if (char === "|" && command[i - 1] !== "|" && command[i + 1] !== "|") {
284
449
  finishCommand();
285
450
  if (command[i + 1] === "&") i++;
@@ -293,9 +458,10 @@ function parseStaticShellCommands(
293
458
  }
294
459
  }
295
460
 
296
- if (quote) return undefined;
461
+ if (quote && !literalOperandsOnly) return undefined;
297
462
  finishPipeline();
298
- return { pipelines };
463
+ if (redirect) return undefined;
464
+ return { pipelines, redirects };
299
465
  }
300
466
 
301
467
  function mentionsEnvironmentRead(text: string): boolean {
@@ -383,20 +549,59 @@ function isLiteralTextProducer(argv: string[]): boolean {
383
549
 
384
550
  function isAwkExecution(argv: string[]): boolean {
385
551
  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);
552
+ // Only a literal inline program establishes passive behavior. File/unknown options do not.
553
+ const program = argv[1];
554
+ return (
555
+ !program ||
556
+ program.startsWith("-") ||
557
+ /\b(?:system|getline|ENVIRON)\b|\||\b(?:print|printf)\b[^\n]*>|@(?:include|load)/.test(
558
+ program,
559
+ ) ||
560
+ argv.slice(2).some((arg) => arg.startsWith("-"))
561
+ );
388
562
  }
389
563
 
390
564
  function isJqObjectConstruction(argv: string[]): boolean {
565
+ if (argv[0]?.split("/").at(-1) !== "jq") return false;
566
+ let index = 1;
567
+ // Options with values, program files, and unknown options cannot prove an inline filter.
568
+ while (/^-[rRcMsScn]+$/.test(argv[index] ?? "")) index++;
569
+ if (argv[index] === "--") index++;
570
+ const filter = argv[index] ?? "";
571
+ if (argv.slice(index + 1).some((arg) => arg.startsWith("-"))) return false;
391
572
  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))
573
+ /^\{\s*[A-Za-z_][A-Za-z0-9_-]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_-]*)*\s*\}$/.test(filter) ||
574
+ /^\{\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(
575
+ filter,
576
+ )
397
577
  );
398
578
  }
399
579
 
580
+ function jqFileArguments(argv: string[]): string[] {
581
+ const files: string[] = [];
582
+ let index = 1;
583
+ for (; index < argv.length; index++) {
584
+ const arg = argv[index] ?? "";
585
+ if (arg === "--") {
586
+ index++;
587
+ break;
588
+ }
589
+ if (/^-[rRcMsScn]+$/.test(arg)) continue;
590
+ if (arg === "--arg" || arg === "--argjson") {
591
+ index += 2;
592
+ continue;
593
+ }
594
+ if (arg === "--rawfile" || arg === "--slurpfile") {
595
+ files.push(argv[index + 2] ?? "");
596
+ index += 2;
597
+ continue;
598
+ }
599
+ if (arg.startsWith("-")) return argv.slice(1);
600
+ break;
601
+ }
602
+ return [...files, ...argv.slice(index + 1)];
603
+ }
604
+
400
605
  /** Index in `args` where the command wrapped by `env` starts, or null when `env` only lists. */
401
606
  function environmentCommandStart(args: string[]): number | null {
402
607
  for (let i = 0; i < args.length; i++) {
@@ -408,7 +613,8 @@ function environmentCommandStart(args: string[]): number | null {
408
613
  continue;
409
614
  }
410
615
  if (arg === "-i" || arg === "--ignore-environment" || arg.startsWith("--chdir=")) continue;
411
- if (arg.startsWith("-")) continue;
616
+ if (arg.startsWith("--unset=")) continue;
617
+ if (arg.startsWith("-")) return null;
412
618
  return i;
413
619
  }
414
620
  return null;
@@ -444,13 +650,25 @@ function isSafeLiteralConsumer(argv: string[]): boolean {
444
650
  }
445
651
 
446
652
  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();
653
+ let argv = [...words];
654
+ while (argv.length) {
655
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0] ?? "")) {
656
+ argv.shift();
657
+ continue;
658
+ }
659
+ const executable = argv[0]?.split("/").at(-1);
660
+ if (executable === "env") {
661
+ const wrapped = unwrapEnvironmentCommand(argv);
662
+ if (!wrapped) break;
663
+ argv = wrapped;
664
+ } else if (executable === "rtk" || executable === "command") {
665
+ argv.shift();
666
+ if (executable === "rtk") {
667
+ if (argv[0] === "proxy") argv.shift();
668
+ else if (argv[0] === "read") argv[0] = "cat";
669
+ }
670
+ if (executable === "command" && argv[0] === "--") argv.shift();
671
+ } else break;
454
672
  }
455
673
  return argv;
456
674
  }
@@ -473,25 +691,62 @@ function hasExecutionOption(args: string[], longOptions: string[], shortOption?:
473
691
  });
474
692
  }
475
693
 
694
+ function passivePrintfFormatIndex(argv: string[]): number | undefined {
695
+ const index = argv[1] === "--" ? 2 : 1;
696
+ const format = argv[index];
697
+ if (format === undefined || (index === 1 && format.startsWith("-"))) return undefined;
698
+ // Ignore escaped percent signs; %n writes a shell variable named by a data argument.
699
+ return /%[-+ #0]*(?:\d+|\*)?(?:\.(?:\d+|\*))?[hljztL]*n/.test(format.replaceAll("%%", ""))
700
+ ? undefined
701
+ : index;
702
+ }
703
+
704
+ /** Unknown values are permitted only in operands that navigate without displaying the value. */
705
+ function isNavigationOperand(argv: string[], marker: string): boolean {
706
+ const name = argv[0]?.split("/").at(-1);
707
+ if (name === "cd") {
708
+ const index = argv[1] === "--" ? 2 : 1;
709
+ return argv.length === index + 1 && !argv[0]?.includes(marker);
710
+ }
711
+ if (name !== "git") return false;
712
+ let index = 1;
713
+ while (argv[index] === "-C" && argv[index + 1]) index += 2;
714
+ return (
715
+ ["status", "rev-parse", "rev-list"].includes(argv[index] ?? "") &&
716
+ !argv.slice(index).some((arg) => arg.includes(marker)) &&
717
+ !argv[0]?.includes(marker)
718
+ );
719
+ }
720
+
476
721
  function isPassiveTextCommand(argv: string[]): boolean {
477
722
  const name = argv[0]?.split("/").at(-1) ?? "";
478
723
  const args = argv.slice(1);
479
724
  // These commands consume literal text. Execution options are not exceptions.
480
- if (["echo", "printf", "grep", "head"].includes(name)) {
725
+ if (name === "printf") return passivePrintfFormatIndex(argv) !== undefined;
726
+ if (["echo", "grep", "head"].includes(name)) {
481
727
  return true;
482
728
  }
483
729
  if (name === "rg") return !hasExecutionOption(args, ["pre", "hostname-bin"]);
484
730
  if (name === "awk") return !isAwkExecution(argv);
485
731
  if (name === "jq") return isJqObjectConstruction(argv);
486
732
  return (
487
- name === "git" &&
488
- args[0] === "grep" &&
489
- !hasExecutionOption(args, ["open-files-in-pager", "textconv"], "O")
733
+ (name === "git" && args[0] === "status") ||
734
+ (name === "git" &&
735
+ args[0] === "grep" &&
736
+ !hasExecutionOption(args, ["open-files-in-pager", "textconv"], "O"))
490
737
  );
491
738
  }
492
739
 
493
740
  function hasStaticEnvironmentRead(argv: string[], allowedNames: Set<string>): boolean {
494
741
  const name = argv[0]?.split("/").at(-1);
742
+ if (
743
+ ["sh", "bash", "zsh"].includes(name ?? "") &&
744
+ hasExecutionOption(argv.slice(1), ["command"], "c") &&
745
+ argv.slice(1).some((arg) => /\$\{?[A-Za-z_]/.test(arg))
746
+ ) {
747
+ return true;
748
+ }
749
+ if (name === "awk" && argv.slice(1).some((arg) => /\bENVIRON\b/.test(arg))) return true;
495
750
  if (name === "printenv") return !isAllowedEnvironmentRead(argv, allowedNames);
496
751
  if (name === "env") {
497
752
  const wrapped = unwrapEnvironmentCommand(argv);
@@ -505,21 +760,20 @@ function hasStaticEnvironmentRead(argv: string[], allowedNames: Set<string>): bo
505
760
  }
506
761
 
507
762
  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
763
  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";
764
+ if (!parsed || typeof parsed === "string") return false;
765
+ const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
766
+ const prompts = commands.filter(
767
+ (argv) => argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt",
768
+ );
769
+ return (
770
+ prompts.length > 0 &&
771
+ commands.every((argv) =>
772
+ argv[0] === "herdr" && argv[1] === "agent" && argv[2] === "prompt"
773
+ ? argv.length >= 5
774
+ : isPassiveTextCommand(argv),
775
+ )
776
+ );
523
777
  }
524
778
 
525
779
  function isHerdrPrompt(command: string): boolean {
@@ -547,6 +801,23 @@ function hasEnvironmentRead(command: string, allowedNames: Set<string>): boolean
547
801
  }
548
802
  const pipelines = parsed.pipelines.map((commands) => commands.map(unwrapStaticCommand));
549
803
  const unwrapped = pipelines.flat();
804
+ if (
805
+ command.includes(">") &&
806
+ unwrapped.some((argv) => isAllowedEnvironmentRead(argv, allowedNames))
807
+ ) {
808
+ return true;
809
+ }
810
+ if (
811
+ command.includes(">") &&
812
+ pipelines.some((pipeline) =>
813
+ pipeline.some(
814
+ (argv) => isLiteralTextProducer(argv) && mentionsEnvironmentRead(argv.join(" ")),
815
+ ),
816
+ ) &&
817
+ unwrapped.some((argv) => ["sh", "bash", "zsh", "xargs", "eval"].includes(argv[0] ?? ""))
818
+ ) {
819
+ return true;
820
+ }
550
821
  if (unwrapped.some((argv) => hasStaticEnvironmentRead(argv, allowedNames))) {
551
822
  return true;
552
823
  }
@@ -568,22 +839,62 @@ function hasEnvironmentRead(command: string, allowedNames: Set<string>): boolean
568
839
  return result;
569
840
  }
570
841
 
842
+ /** Find an actual heredoc operator outside shell quotes/comments, then parse one bounded body. */
843
+ function boundedHeredoc(command: string) {
844
+ let quote: "'" | '"' | undefined;
845
+ let wordStarted = false;
846
+ for (let i = 0; i < command.length; i++) {
847
+ const char = command[i];
848
+ if (quote === "'") {
849
+ if (char === quote) quote = undefined;
850
+ } else if (char === "\\") {
851
+ i++;
852
+ wordStarted = true;
853
+ } else if (quote === '"') {
854
+ if (char === quote) quote = undefined;
855
+ } else if (char === "'" || char === '"') {
856
+ quote = char;
857
+ wordStarted = true;
858
+ } else if (char === "#" && !wordStarted) {
859
+ while (i + 1 < command.length && command[i + 1] !== "\n") i++;
860
+ } else if (command.startsWith("<<", i)) {
861
+ const match =
862
+ /^<<(-?)[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2[ \t]*(?:#[^\r\n]*)?(?:\r?\n|$)/.exec(
863
+ command.slice(i),
864
+ );
865
+ const lines = match
866
+ ? command
867
+ .slice(i + match[0].length)
868
+ .trimEnd()
869
+ .split(/\r?\n/)
870
+ : [];
871
+ const end = lines.findIndex(
872
+ (line) => (match?.[1] ? line.replace(/^\t+/, "") : line) === match?.[3],
873
+ );
874
+ return {
875
+ header: command.slice(0, i),
876
+ quoted: Boolean(match?.[2]),
877
+ closed: Boolean(match) && end >= 0,
878
+ body: lines.slice(0, end >= 0 ? end : undefined).join("\n"),
879
+ following: end >= 0 ? lines.slice(end + 1).join("\n") : "",
880
+ };
881
+ } else {
882
+ wordStarted = !/[\s;&|()<>]/.test(char ?? "");
883
+ }
884
+ }
885
+ return undefined;
886
+ }
887
+
571
888
  /** A single literal writer cannot execute its body or feed another command. */
572
889
  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] ?? "");
890
+ const heredoc = boundedHeredoc(command);
575
891
  let header = command;
576
892
  let writers = ["echo", "printf"];
577
893
  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 ?? "";
894
+ // Only a complete body with no following commands is a literal-write exception.
895
+ if (!heredoc.closed || heredoc.following) return false;
896
+ if (!heredoc.quoted && /[$`\\]/.test(heredoc.body)) return false;
897
+ header = heredoc.header;
587
898
  writers = ["cat", "tee"];
588
899
  } else if (!command.includes(">")) {
589
900
  return false;
@@ -591,14 +902,7 @@ function isLiteralTextWrite(command: string): boolean {
591
902
 
592
903
  // Output redirection does not execute text. Other unsupported syntax still fails parsing.
593
904
  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
- }
905
+ if (!parsed || parsed === "brace-expansion") return false;
602
906
  const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
603
907
  const writerIndexes = commands
604
908
  .map((argv, index) => (writers.includes(argv[0]?.split("/").at(-1) ?? "") ? index : -1))
@@ -613,18 +917,21 @@ function isLiteralTextWrite(command: string): boolean {
613
917
  );
614
918
  }
615
919
 
616
- function hasSensitiveFileRead(command: string): boolean {
920
+ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) => boolean): boolean {
617
921
  const sensitivePath =
618
922
  /(?:^|[/\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
923
  const sensitiveCamelCase =
620
924
  /(?:^|[/\s'"])[A-Za-z0-9_.-]*(?:(?:secret|credential)[A-Z]|(?:Secret|Credential)[A-Z])[^/\s'"]*/;
621
925
  const sensitiveName = /secret|credential/i;
622
926
  const isSensitivePath = (text: string) =>
623
- sensitivePath.test(text) || sensitiveCamelCase.test(text) || sensitiveName.test(text);
927
+ sensitivePath.test(text) ||
928
+ sensitiveCamelCase.test(text) ||
929
+ (sensitiveName.test(text) && !/(?:^|\/)credential-guard(?:-[^/]+)?\.(?:ts|md)$/.test(text));
624
930
  if (/(?:^|[\s;&|])--env-file(?:=|\s)[^;&|]*\.env\b/i.test(command)) return true;
625
931
  if (/\bcat\b[^;&|]*<<-?\s*\S+[\s\S]*\b(?:secret|credential)\b/i.test(command)) return true;
626
932
  const parsed = parseStaticShellCommands(command);
627
- const readers = /\b(?:cat|cp|grep|rg|sed|awk|head|tail|less|more|source|ls)\b/i;
933
+ const readers =
934
+ /\b(?:cat|cp|grep|rg|sed|awk|jq|head|tail|less|more|source|ls|sort|uniq|cut|wc|mv|find)\b/i;
628
935
  if (parsed && parsed !== "brace-expansion") {
629
936
  return parsed.pipelines.flat().some((words) => {
630
937
  const argv = unwrapStaticCommand(words);
@@ -633,6 +940,17 @@ function hasSensitiveFileRead(command: string): boolean {
633
940
  if (inspectedArgv === null) return false;
634
941
  const inspected = unwrapStaticCommand(inspectedArgv);
635
942
  const name = inspected[0]?.split("/").at(-1) ?? "";
943
+ if (name === "bun" && inspected[1] === "run" && inspected[2] === "gh-tool") {
944
+ return inspected.some((arg, index) => {
945
+ const path =
946
+ arg === "--body-file"
947
+ ? inspected[index + 1]
948
+ : arg.startsWith("--body-file=")
949
+ ? arg.slice("--body-file=".length)
950
+ : undefined;
951
+ return path !== undefined && (isPathBlocked(path) || isSensitivePath(path));
952
+ });
953
+ }
636
954
  if (!readers.test(name)) return false;
637
955
  if (name === "rg" || name === "grep") {
638
956
  const args = inspected.slice(1);
@@ -722,11 +1040,23 @@ function hasSensitiveFileRead(command: string): boolean {
722
1040
  }
723
1041
  paths.push(arg);
724
1042
  }
725
- return paths.some(isSensitivePath);
1043
+ return paths.some((path) => isPathBlocked(path) || isSensitivePath(path));
726
1044
  }
727
- return inspected.slice(1).some(isSensitivePath);
1045
+ const paths = name === "jq" ? jqFileArguments(inspected) : inspected.slice(1);
1046
+ return paths.some((path) => isPathBlocked(path) || isSensitivePath(path));
728
1047
  });
729
1048
  }
1049
+ const operands = parseStaticShellCommands(command, true);
1050
+ if (
1051
+ operands &&
1052
+ typeof operands !== "string" &&
1053
+ operands.pipelines.flat().some((words) => {
1054
+ const argv = unwrapStaticCommand(words);
1055
+ return readers.test(argv[0] ?? "") && argv.slice(1).some(isPathBlocked);
1056
+ })
1057
+ )
1058
+ return true;
1059
+ if (readers.test(command) && command.split(/[\s'"`()<>;&|]+/).some(isPathBlocked)) return true;
730
1060
  const unquoted = command.replace(/'(?:\\.|[^'])*'/g, " ").replace(/"(?:\\.|[^"$`])*"/g, " ");
731
1061
  if (/\b(?:bun|node|python3?|ruby)\b/i.test(command) && isSensitivePath(command)) {
732
1062
  return true;
@@ -738,9 +1068,149 @@ function hasSensitiveFileRead(command: string): boolean {
738
1068
  );
739
1069
  }
740
1070
 
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);
1071
+ function hasEnvironmentVariableExpansionRead(
1072
+ command: string,
1073
+ allowedNames: Set<string>,
1074
+ isPathBlocked: (path: string) => boolean,
1075
+ ): boolean {
1076
+ let heredoc = boundedHeredoc(command);
1077
+ while (heredoc) {
1078
+ // In an unquoted heredoc, # and quote characters are data, not shell syntax.
1079
+ if (!heredoc.closed || (!heredoc.quoted && /[$`\\]/.test(heredoc.body))) return true;
1080
+ command = [heredoc.header, heredoc.following].join("\n");
1081
+ heredoc = boundedHeredoc(command);
1082
+ }
1083
+ if (!command.includes("$")) return false;
1084
+ let invalidExpansion = false;
1085
+ const assignments = getLeadingLiteralAssignments(command);
1086
+ const localValues = new Map<string, string>();
1087
+ let localIndex = 0;
1088
+ let expansions = 0;
1089
+ let normalized = "";
1090
+ let quote: "'" | '"' | undefined;
1091
+ let wordStarted = false;
1092
+ for (let i = 0; i < command.length; i++) {
1093
+ const char = command[i] ?? "";
1094
+ if (!quote && char === "#" && !wordStarted) {
1095
+ while (i + 1 < command.length && command[i + 1] !== "\n") i++;
1096
+ continue;
1097
+ }
1098
+ if (!quote && /[\s;&|()<>]/.test(char)) wordStarted = false;
1099
+ else wordStarted = true;
1100
+ if (char === "\\" && quote !== "'") {
1101
+ normalized += char + (command[++i] ?? "");
1102
+ continue;
1103
+ }
1104
+ if (quote === "'") {
1105
+ normalized += char;
1106
+ if (char === "'") quote = undefined;
1107
+ continue;
1108
+ }
1109
+ if (char === "'" && quote !== '"') {
1110
+ normalized += char;
1111
+ quote = "'";
1112
+ continue;
1113
+ }
1114
+ if (char === '"') {
1115
+ normalized += char;
1116
+ quote = quote === '"' ? undefined : '"';
1117
+ continue;
1118
+ }
1119
+ if (char !== "$") {
1120
+ normalized += char;
1121
+ continue;
1122
+ }
1123
+ const variable = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}|^\$([A-Za-z_][A-Za-z0-9_]*)/.exec(
1124
+ command.slice(i),
1125
+ );
1126
+ if (!variable) {
1127
+ if (command[i + 1] === "{" || /[$][0-9@*#?!]/.test(command.slice(i, i + 2))) {
1128
+ invalidExpansion = true;
1129
+ }
1130
+ normalized += char;
1131
+ continue;
1132
+ }
1133
+ expansions++;
1134
+ const name = variable[1] ?? variable[2] ?? "";
1135
+ const assignment = assignments.get(name);
1136
+ if (assignment && assignment.end >= i) invalidExpansion = true;
1137
+ if (assignment && assignment.end < i) {
1138
+ const marker = `LOCALVALUE${localIndex++}`;
1139
+ localValues.set(marker, assignment.value);
1140
+ normalized += marker;
1141
+ } else if (allowedNames.has(name)) {
1142
+ normalized += quote ? "ENVVALUE" : "ENVVALUEUNQUOTED";
1143
+ } else {
1144
+ normalized += quote ? "UNKNOWNVALUE" : "UNKNOWNVALUEUNQUOTED";
1145
+ }
1146
+ i += variable[0].length - 1;
1147
+ }
1148
+ if (!expansions) return invalidExpansion;
1149
+ if (invalidExpansion) return true;
1150
+
1151
+ const localMaterialized = [...localValues].reduce(
1152
+ (text, [marker, value]) => text.replaceAll(marker, value),
1153
+ normalized,
1154
+ );
1155
+ // Options must be checked after local literals become actual argv, never as placeholders.
1156
+ if (!hasSafeLocalAssignmentCommands(localMaterialized, assignments)) return true;
1157
+ const parsed = parseStaticShellCommands(localMaterialized);
1158
+ if (!parsed || typeof parsed === "string") return true;
1159
+ for (const words of parsed.pipelines.flat()) {
1160
+ if (!words.some((arg) => arg.includes("UNKNOWNVALUE"))) continue;
1161
+ if (words.some((arg) => arg.includes("UNKNOWNVALUEUNQUOTED"))) return true;
1162
+ const argv = unwrapStaticCommand(words);
1163
+ if (
1164
+ words.slice(0, words.length - argv.length).some((arg) => arg.includes("UNKNOWNVALUE")) ||
1165
+ !isNavigationOperand(argv, "UNKNOWNVALUE")
1166
+ )
1167
+ return true;
1168
+ }
1169
+ if (!localMaterialized.includes("ENVVALUE")) {
1170
+ return (
1171
+ hasSensitiveFileRead(localMaterialized, isPathBlocked) ||
1172
+ hasSensitivePathRedirect(localMaterialized, isPathBlocked) ||
1173
+ hasEnvironmentRead(localMaterialized, allowedNames)
1174
+ );
1175
+ }
1176
+
1177
+ for (const [index, pipeline] of parsed.pipelines.entries()) {
1178
+ const hasEnvironmentValue = pipeline.some((argv) =>
1179
+ argv.some((arg) => arg.includes("ENVVALUE")),
1180
+ );
1181
+ if (!hasEnvironmentValue) continue;
1182
+ if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
1183
+ if (
1184
+ pipeline.some((argv) => {
1185
+ const unwrapped = unwrapStaticCommand(argv);
1186
+ const name = unwrapped[0]?.split("/").at(-1) ?? "";
1187
+ if (argv.some((arg) => arg.includes("ENVVALUE"))) {
1188
+ if (argv.slice(0, argv.length - unwrapped.length).some((arg) => arg.includes("ENVVALUE")))
1189
+ return true;
1190
+ if (isNavigationOperand(unwrapped, "ENVVALUE")) {
1191
+ return unwrapped.some((arg) => arg.includes("ENVVALUEUNQUOTED"));
1192
+ }
1193
+ const formatIndex = name === "printf" ? passivePrintfFormatIndex(unwrapped) : undefined;
1194
+ return (
1195
+ !["echo", "printf"].includes(name) ||
1196
+ !isPassiveTextCommand(unwrapped) ||
1197
+ unwrapped[0]?.includes("ENVVALUE") === true ||
1198
+ (name === "printf" &&
1199
+ (formatIndex === undefined ||
1200
+ unwrapped.slice(0, formatIndex + 1).some((arg) => arg.includes("ENVVALUE"))))
1201
+ );
1202
+ }
1203
+ return !isPassiveTextCommand(unwrapped);
1204
+ })
1205
+ ) {
1206
+ return true;
1207
+ }
1208
+ }
1209
+ const materialized = [...localValues].reduce(
1210
+ (text, [marker, value]) => text.replaceAll(marker, value),
1211
+ normalized.replaceAll("ENVVALUE", "SAFEENVVALUE"),
1212
+ );
1213
+ return hasSensitiveFileRead(materialized, isPathBlocked);
744
1214
  }
745
1215
 
746
1216
  function hasEnvironmentSourceAccess(command: string): boolean {
@@ -849,37 +1319,44 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
849
1319
  }
850
1320
 
851
1321
  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
- );
1322
+ return getDangerousBashReason(command) !== null;
859
1323
  }
860
1324
 
861
- function isGhCommandAllowed(command: string): boolean {
862
- if (!/ -R\s+\S+/.test(command) && !/ --repo\s+\S+/.test(command)) {
863
- return false;
1325
+ function getDangerousBashReason(command: string): string | null {
1326
+ if (
1327
+ hasSensitiveFileRead(command, isPathBlocked) ||
1328
+ hasSensitivePathRedirect(command, isPathBlocked)
1329
+ ) {
1330
+ return "accesses a sensitive file path";
864
1331
  }
865
-
866
- const ghMatch = command.match(/(?:^|[;&|]\s*)gh\s+(\S+(?:\s+\S+)?)/);
867
- if (!ghMatch) {
868
- return false;
1332
+ if (
1333
+ !isLiteralTextWrite(command) &&
1334
+ hasEnvironmentVariableExpansionRead(command, allowedEnvironmentVariables, isPathBlocked)
1335
+ ) {
1336
+ return "expands an unapproved or executable environment variable value";
869
1337
  }
1338
+ if (hasEnvironmentSourceAccess(command)) return "reads process environment from a script";
1339
+ if (hasEnvironmentRead(command, allowedEnvironmentVariables)) {
1340
+ return "reads environment variables or passes them to an executor";
1341
+ }
1342
+ if (dangerousBashPatterns.some((pattern) => pattern.test(command))) {
1343
+ return "matches a configured dangerous-command pattern";
1344
+ }
1345
+ return null;
1346
+ }
870
1347
 
871
- const subcommand = ghMatch[1];
872
-
873
- return GH_ALLOWED_READONLY_SUBCOMMANDS.some(
874
- (allowed) => subcommand === allowed || subcommand.startsWith(`${allowed} `),
875
- );
1348
+ function isGhCommandAllowed(command: string): boolean {
1349
+ const parsed = parseStaticShellCommands(command);
1350
+ if (!parsed || typeof parsed === "string") return false;
1351
+ const ghCommands = parsed.pipelines
1352
+ .flat()
1353
+ .map(unwrapStaticCommand)
1354
+ .filter((argv) => argv[0]?.split("/").at(-1) === "gh");
1355
+ return ghCommands.length > 0 && ghCommands.every(isAllowedGhArgv);
876
1356
  }
877
1357
 
878
1358
  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()));
1359
+ return isGhCommandAllowed(command);
883
1360
  }
884
1361
 
885
1362
  function detectSleepPolling(command: string): string | null {
@@ -894,6 +1371,27 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
894
1371
  }
895
1372
 
896
1373
  function getBlockedCliTool(command: string): { name: string; wrapper: string } | null {
1374
+ const staticCommands = parseStaticShellCommands(command);
1375
+ const parsed = staticCommands ?? parseStaticShellCommands(command, true);
1376
+ if (parsed && typeof parsed !== "string") {
1377
+ const commands = parsed.pipelines.flat().map(unwrapStaticCommand);
1378
+ for (const argv of commands) {
1379
+ const executable = argv[0]?.split("/").at(-1) ?? "";
1380
+ const match = blockedCliTools.find(({ name }) =>
1381
+ name === "curl (Azure DevOps)"
1382
+ ? executable === "curl" && argv.slice(1).join(" ").includes("dev.azure.com")
1383
+ : name === "az" || name === "az (Azure DevOps)"
1384
+ ? executable === "az" &&
1385
+ (name !== "az (Azure DevOps)" ||
1386
+ /^(?:devops|pipelines|repos|boards|artifacts)$/.test(argv[1] ?? ""))
1387
+ : executable === name,
1388
+ );
1389
+ if (!match) continue;
1390
+ if (match.name === "gh" && allGhCommandsAllowed(command)) continue;
1391
+ return { name: match.name, wrapper: match.wrapper };
1392
+ }
1393
+ if (staticCommands) return null;
1394
+ }
897
1395
  for (const { pattern, name, wrapper } of blockedCliTools) {
898
1396
  if (pattern.test(command)) {
899
1397
  if (name === "gh" && allGhCommandsAllowed(command)) {
@@ -946,9 +1444,10 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
946
1444
  if (tool === "bash") {
947
1445
  const command = extractCommand(args);
948
1446
 
949
- if (isDangerousBashCommand(command)) {
1447
+ const dangerousReason = getDangerousBashReason(command);
1448
+ if (dangerousReason) {
950
1449
  throw new Error(
951
- `\u{1F6AB} Command blocked: This command might expose secrets.\n\n` +
1450
+ `\u{1F6AB} Command blocked: ${dangerousReason}; this command might expose secrets.\n\n` +
952
1451
  `Command: ${command}\n\n` +
953
1452
  `If you need environment info, ask the user directly.\n\n` +
954
1453
  `Think this is wrong? Review the project repository, adjust the patterns, and submit a PR.`,