@blogic-cz/agent-tools 1.6.10 → 1.6.12

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
@@ -654,7 +654,7 @@ Claude Code uses shell command hooks. The package ships a ready-made wrapper scr
654
654
 
655
655
  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).
656
656
 
657
- 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. Approved environment values may be displayed, but redirecting their pipeline or passing them to an executor stays blocked.
657
+ The guard permits static `printenv` reads and simple `echo`/`printf` expansions only for exact names in `credentialGuard.allowedEnvironmentVariables`. Quoted empty defaults such as `"${NAME:-}"` follow the same display rules, including `printf` data arguments with a literal format; unquoted defaults, nonempty defaults, and other parameter operators stay blocked. The default list is empty. Multiple names are allowed only when each name is approved. Approved environment values may be displayed, but redirecting their pipeline or passing them to an executor stays blocked.
658
658
 
659
659
  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 import unaliased `os`, plus `json` for JSON display, followed by `print(...)` statements. `json.dumps(...)` supports dictionaries and lists of approved reads, including comprehensions over a nonempty literal list of approved names. Every dictionary key and environment read must use an approved name. Each projection has at most 16 entries; the program has at most 65,536 characters. Computed or unbounded names, aliases, escaped or interpolated strings, additional statements, and whole-environment output remain unsupported. The same redirection and pipeline restrictions apply.
660
660
 
@@ -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;AAsvFF,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,CA4VrF;AAQD,kEAAkE;AAClE,eAAO,MAAM,uBAAuB,UA5nGD,SAAS,UAAU,UAAU,KAAK,IA4nGM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YA9nGC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IA8nGjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aAhoGE,MAAM,KAAK,OAgoGgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aAloGE,MAAM,KAAK,OAkoGgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YApoGC,MAAM,KAAK,OAooG0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YAtoGC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAsoGf,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YAxoGC,MAAM,KAAK,OAwoGsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YA1oGC,MAAM,KAAK,MAAM,GAAG,IA0oGa,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;AAg4FF,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,CAoWrF;AAQD,kEAAkE;AAClE,eAAO,MAAM,uBAAuB,UA9wGD,SAAS,UAAU,UAAU,KAAK,IA8wGM,CAAC;AAE5E,oDAAoD;AACpD,eAAO,MAAM,aAAa,YAhxGC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,IAgxGjB,CAAC;AAExD,yEAAyE;AACzE,eAAO,MAAM,aAAa,aAlxGE,MAAM,KAAK,OAkxGgB,CAAC;AAExD,yDAAyD;AACzD,eAAO,MAAM,aAAa,aApxGE,MAAM,KAAK,OAoxGgB,CAAC;AAExD,oEAAoE;AACpE,eAAO,MAAM,sBAAsB,YAtxGC,MAAM,KAAK,OAsxG0B,CAAC;AAE1E,iDAAiD;AACjD,eAAO,MAAM,iBAAiB,YAxxGC,MAAM,KAAK;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,IAwxGf,CAAC;AAEhE,wDAAwD;AACxD,eAAO,MAAM,kBAAkB,YA1xGC,MAAM,KAAK,OA0xGsB,CAAC;AAElE,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,YA5xGC,MAAM,KAAK,MAAM,GAAG,IA4xGa,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "1.6.10",
3
+ "version": "1.6.12",
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",
@@ -226,11 +226,13 @@ function isSafeHerdrTabCreate(argv: string[]): boolean {
226
226
  function hasSafeLocalAssignmentCommands(
227
227
  command: string,
228
228
  assignments: Map<string, { value: string; end: number }>,
229
+ statusCaptures: Set<string>,
229
230
  ): boolean {
230
231
  if (!assignments.size) return true;
231
232
  const parsed = parseStaticShellCommands(command);
232
233
  if (!parsed || typeof parsed === "string") return false;
233
234
  return parsed.pipelines.flat().every((words) => {
235
+ if (words.length === 1 && statusCaptures.has(words[0] ?? "")) return true;
234
236
  const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=([A-Za-z0-9_./-]+)$/.exec(words[0] ?? "");
235
237
  if (assignment) {
236
238
  return (
@@ -1890,6 +1892,37 @@ function isStaticNavigationCommand(argv: string[]): boolean {
1890
1892
  );
1891
1893
  }
1892
1894
 
1895
+ /** Literal wrappers and exact directory-operation roles; unknown consumers keep refusal. */
1896
+ function isProvedDirectoryCommand(words: string[]): boolean {
1897
+ const argv = unwrapStaticCommand(words);
1898
+ if (
1899
+ !words
1900
+ .slice(0, words.length - argv.length)
1901
+ .every((word) => ["rtk", "proxy", "command", "--"].includes(word))
1902
+ )
1903
+ return false;
1904
+ if (argv[0] === "cd") return isStaticNavigationCommand(argv);
1905
+ if (argv[0] !== "git") return false;
1906
+ let index = 1;
1907
+ while (argv[index] === "-C") {
1908
+ if (!isStaticNavigationCommand(["cd", argv[index + 1] ?? ""])) return false;
1909
+ index += 2;
1910
+ }
1911
+ const args = argv.slice(index);
1912
+ if (args[0] === "status")
1913
+ return args.length === 1 || (args.length === 2 && args[1] === "--short");
1914
+ if (args[0] === "fetch")
1915
+ return (
1916
+ args.length >= 2 &&
1917
+ args.length <= 3 &&
1918
+ args.slice(1).every((arg) => /^[A-Za-z0-9_][A-Za-z0-9_./-]*$/.test(arg))
1919
+ );
1920
+ return (
1921
+ (args.length === 2 && args[0] === "rev-parse" && args[1] === "HEAD") ||
1922
+ (args.length === 3 && args[0] === "rev-list" && args[1] === "--count" && args[2] === "HEAD")
1923
+ );
1924
+ }
1925
+
1893
1926
  /** Prove a single cd whose uses are gated on success, without simulating shell state. */
1894
1927
  function hasProvedNavigationSequence(command: string): boolean {
1895
1928
  const literal = literalBindingText(command, "", "");
@@ -2279,6 +2312,7 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
2279
2312
  if (parsed && parsed !== "brace-expansion") {
2280
2313
  let directory = "";
2281
2314
  const trackDirectory = hasProvedNavigationSequence(command);
2315
+ const safeDirectorySequence = parsed.pipelines.flat().every(isProvedDirectoryCommand);
2282
2316
  return parsed.pipelines.flat().some((words) => {
2283
2317
  const argv = unwrapStaticCommand(words);
2284
2318
  const inspectedArgv =
@@ -2291,19 +2325,36 @@ function hasSensitiveFileRead(command: string, isPathBlocked: (path: string) =>
2291
2325
  path.startsWith("/") ? posix.normalize(path) : posix.join(commandDirectory, path);
2292
2326
  const blockedOperand = (path: string) =>
2293
2327
  [path, inDirectory(path)].some((value) => isPathBlocked(value) || isSensitivePath(value));
2294
- const blockedDirectory = (path: string) => blockedOperand(path) || blockedOperand(path + "/");
2328
+ // Directory slots use path policies, not the broad credential-file name heuristic.
2329
+ const blockedDirectory = (path: string) =>
2330
+ [path, inDirectory(path)].some(
2331
+ (value) => isPathBlocked(value) || isPathBlocked(value + "/"),
2332
+ );
2295
2333
  if (name === "cd") {
2296
2334
  const path = inspected[inspected[1] === "--" ? 2 : 1];
2297
2335
  if (path === undefined) return false;
2298
2336
  if (blockedDirectory(path)) return true;
2337
+ // A successful cd alone proves neither its later readers nor pipeline consumers safe.
2338
+ if (
2339
+ (!trackDirectory || !safeDirectorySequence) &&
2340
+ (blockedOperand(path) || blockedOperand(path + "/"))
2341
+ )
2342
+ return true;
2299
2343
  if (trackDirectory && isStaticNavigationCommand(inspected)) directory = inDirectory(path);
2300
2344
  return false;
2301
2345
  }
2302
2346
  if (name === "git") {
2303
2347
  let index = 1;
2304
2348
  while (inspected[index] === "-C" && inspected[index + 1]) {
2305
- if (blockedDirectory(inspected[index + 1] ?? "")) return true;
2306
- commandDirectory = inDirectory(inspected[index + 1] ?? "");
2349
+ const path = inspected[index + 1] ?? "";
2350
+ if (blockedDirectory(path)) return true;
2351
+ // Git -C affects this command only; following consumers keep the shell directory.
2352
+ if (
2353
+ !isProvedDirectoryCommand(words) &&
2354
+ (blockedOperand(path) || blockedOperand(path + "/"))
2355
+ )
2356
+ return true;
2357
+ commandDirectory = inDirectory(path);
2307
2358
  index += 2;
2308
2359
  }
2309
2360
  // Git grep has the same pattern-versus-file roles as grep below.
@@ -2483,6 +2534,7 @@ function hasEnvironmentVariableExpansionRead(
2483
2534
  command: string,
2484
2535
  allowedNames: Set<string>,
2485
2536
  isPathBlocked: (path: string) => boolean,
2537
+ onProvedStatus?: (command: string) => void,
2486
2538
  ): boolean {
2487
2539
  let heredoc = boundedHeredoc(command);
2488
2540
  while (heredoc) {
@@ -2494,8 +2546,15 @@ function hasEnvironmentVariableExpansionRead(
2494
2546
  if (!command.includes("$") && !command.includes("`")) return false;
2495
2547
  let invalidExpansion = false;
2496
2548
  const assignments = getLeadingLiteralAssignments(command);
2497
- const localValues = new Map<string, string>();
2498
2549
  let localIndex = 0;
2550
+ const directCaptures = new Map<string, string>();
2551
+ const statusReferences = new Map<string, { name: string; fallback: string }>();
2552
+ const marker = (prefix: string): string => {
2553
+ let value: string;
2554
+ do value = `${prefix}${localIndex++}END`;
2555
+ while (command.includes(value));
2556
+ return value;
2557
+ };
2499
2558
  let expansions = 0;
2500
2559
  let normalized = "";
2501
2560
  let quote: "'" | '"' | undefined;
@@ -2534,7 +2593,18 @@ function hasEnvironmentVariableExpansionRead(
2534
2593
  }
2535
2594
  if (command[i + 1] === "?") {
2536
2595
  expansions++;
2537
- normalized += "NUMERICSTATUSVALUE";
2596
+ const value = marker("NUMERICSTATUSVALUE");
2597
+ // Only an exact standalone assignment token captures in the parent shell.
2598
+ // Quoting/escaping the name can instead produce an executable command word.
2599
+ const capture = /(?:^|[;\n])[ \t]*([A-Za-z_][A-Za-z0-9_]*)=("?)$/.exec(command.slice(0, i));
2600
+ const ending = capture?.[2] === '"' ? '"' : "";
2601
+ if (
2602
+ capture &&
2603
+ command.slice(i + 2).startsWith(ending) &&
2604
+ /^[ \t]*(?:[;\n]|$)/.test(command.slice(i + 2 + ending.length))
2605
+ )
2606
+ directCaptures.set(value, capture[1] ?? "");
2607
+ normalized += value;
2538
2608
  i++;
2539
2609
  continue;
2540
2610
  }
@@ -2552,32 +2622,107 @@ function hasEnvironmentVariableExpansionRead(
2552
2622
  const name = variable[1] ?? variable[3] ?? "";
2553
2623
  if (variable[2]) {
2554
2624
  if (quote !== '"' || !allowedNames.has(name)) invalidExpansion = true;
2555
- normalized += "ENVTESTVALUE";
2625
+ normalized += "ENVVALUE";
2556
2626
  i += variable[0].length - 1;
2557
2627
  continue;
2558
2628
  }
2559
2629
  const assignment = assignments.get(name);
2560
2630
  if (assignment && assignment.end >= i) invalidExpansion = true;
2561
- if (assignment && assignment.end < i) {
2562
- const marker = `LOCALVALUE${localIndex++}`;
2563
- localValues.set(marker, assignment.value);
2564
- normalized += marker;
2565
- } else if (allowedNames.has(name)) {
2566
- normalized += quote ? "ENVVALUE" : "ENVVALUEUNQUOTED";
2567
- } else {
2568
- normalized += quote ? "UNKNOWNVALUE" : "UNKNOWNVALUEUNQUOTED";
2569
- }
2631
+ const reference = marker("STATUSREFERENCE");
2632
+ const fallback =
2633
+ assignment && assignment.end < i
2634
+ ? assignment.value
2635
+ : allowedNames.has(name)
2636
+ ? quote
2637
+ ? "ENVVALUE"
2638
+ : "ENVVALUEUNQUOTED"
2639
+ : quote
2640
+ ? "UNKNOWNVALUE"
2641
+ : "UNKNOWNVALUEUNQUOTED";
2642
+ statusReferences.set(reference, { name, fallback });
2643
+ normalized += reference;
2570
2644
  i += variable[0].length - 1;
2571
2645
  }
2572
2646
  if (!expansions) return invalidExpansion;
2573
2647
  if (invalidExpansion) return true;
2574
2648
 
2575
- const localMaterialized = [...localValues].reduce(
2576
- (text, [marker, value]) => text.replaceAll(marker, () => value),
2577
- normalized,
2578
- );
2649
+ // Resolve every reference at its command position; a later capture cannot prove
2650
+ // an earlier read, and only proved passive intermediates preserve the binding.
2651
+ const initial = parseStaticShellCommands(normalized);
2652
+ if (!initial || typeof initial === "string") return true;
2653
+ const captures = new Map<string, string>();
2654
+ for (const words of initial.pipelines.flat()) {
2655
+ const capture = /^([A-Za-z_][A-Za-z0-9_]*)=(.+)$/.exec(words[0] ?? "");
2656
+ if (capture && directCaptures.get(capture[2] ?? "") === capture[1])
2657
+ captures.set(words[0] ?? "", capture[1] ?? "");
2658
+ }
2659
+ const statusCaptures = new Set<string>();
2660
+ const localStatuses = new Set<string>();
2661
+ const resolutions = new Map<string, string>();
2662
+ const capturedNames = new Set(captures.values());
2663
+ const bound = new Set<string>();
2664
+ if (captures.size) {
2665
+ const syntax = literalBindingText(normalized, "", "")?.syntax;
2666
+ if (syntax === undefined || /&&|\|\||&/.test(syntax.replace(/[<>]&[0-9-]+/g, ""))) return true;
2667
+ }
2668
+ for (const [index, pipeline] of initial.pipelines.entries()) {
2669
+ for (const words of pipeline) {
2670
+ for (const arg of words) {
2671
+ for (const [reference, { name, fallback }] of statusReferences) {
2672
+ if (!arg.includes(reference)) continue;
2673
+ if (bound.has(name)) {
2674
+ const value = marker("NUMERICSTATUSVALUE");
2675
+ localStatuses.add(value);
2676
+ resolutions.set(reference, value);
2677
+ } else {
2678
+ if (capturedNames.has(name)) return true;
2679
+ resolutions.set(reference, fallback);
2680
+ }
2681
+ }
2682
+ }
2683
+ const capture = captures.get(words[0] ?? "");
2684
+ if (capture) {
2685
+ if (
2686
+ pipeline.length !== 1 ||
2687
+ words.length !== 1 ||
2688
+ initial.redirects.some((redirect) => redirect.pipeline === index) ||
2689
+ // Inherited export attributes can make a numeric value select code or paths.
2690
+ /^(?:LD_|DYLD_|_RLD_|LDR_|GIT_CONFIG_(?:KEY|VALUE)_)/.test(capture) ||
2691
+ /^(?:PATH|IFS|CDPATH|BASH_ENV|ENV|FPATH|ZDOTDIR|SHELLOPTS|BASHOPTS|PROMPT_COMMAND|PS[0-4]|GLOBIGNORE|RANDOM|SECONDS|PIPESTATUS|UID|EUID|BASH_XTRACEFD|HOME|XDG_CONFIG_HOME|LIBPATH|SHLIB_PATH|NODE_OPTIONS|NODE_PATH|BUN_OPTIONS|BUN_PRELOAD|PYTHONPATH|PYTHONSTARTUP|PYTHONHOME|RUBYOPT|RUBYLIB|GEM_HOME|GEM_PATH|PERL5OPT|PERL5LIB|PERLLIB|JAVA_TOOL_OPTIONS|JDK_JAVA_OPTIONS|CLASSPATH|_JAVA_OPTIONS|JAVA_HOME|GIT_SSH_COMMAND|GIT_SSH|GIT_PROXY_COMMAND|GIT_EXEC_PATH|GIT_CONFIG|GIT_CONFIG_GLOBAL|GIT_CONFIG_SYSTEM|GIT_CONFIG_PARAMETERS|GIT_CONFIG_COUNT|GIT_ASKPASS|SSH_ASKPASS|GIT_EDITOR|GIT_SEQUENCE_EDITOR|GIT_PAGER|PAGER|EDITOR|VISUAL)$/.test(
2692
+ capture,
2693
+ )
2694
+ )
2695
+ return true;
2696
+ bound.add(capture);
2697
+ statusCaptures.add(words[0] ?? "");
2698
+ continue;
2699
+ }
2700
+ const argv = unwrapStaticCommand(words);
2701
+ const name = argv[0]?.split("/").at(-1) ?? "";
2702
+ if (
2703
+ captures.size &&
2704
+ /^(?:alias|unalias|readonly|declare|typeset|local|export|eval|source|\.|trap|set|read|unset|let)$/.test(
2705
+ name,
2706
+ )
2707
+ )
2708
+ return true;
2709
+ if (!isPassiveTextCommand(argv) && name !== "cat") bound.clear();
2710
+ }
2711
+ for (const redirect of initial.redirects.filter((value) => value.pipeline === index)) {
2712
+ if (
2713
+ [...statusReferences].some(
2714
+ ([reference, { name }]) => capturedNames.has(name) && redirect.target.includes(reference),
2715
+ )
2716
+ )
2717
+ return true;
2718
+ }
2719
+ }
2720
+ for (const [reference, { fallback }] of statusReferences) {
2721
+ normalized = normalized.replaceAll(reference, () => resolutions.get(reference) ?? fallback);
2722
+ }
2723
+ const localMaterialized = normalized;
2579
2724
  // Options must be checked after local literals become actual argv, never as placeholders.
2580
- if (!hasSafeLocalAssignmentCommands(localMaterialized, assignments)) return true;
2725
+ if (!hasSafeLocalAssignmentCommands(localMaterialized, assignments, statusCaptures)) return true;
2581
2726
  const parsed = parseStaticShellCommands(localMaterialized);
2582
2727
  if (!parsed || typeof parsed === "string") return true;
2583
2728
  for (const [index, pipeline] of parsed.pipelines.entries()) {
@@ -2586,6 +2731,7 @@ function hasEnvironmentVariableExpansionRead(
2586
2731
  if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
2587
2732
  if (
2588
2733
  pipeline.some((words) => {
2734
+ if (words.length === 1 && statusCaptures.has(words[0] ?? "")) return false;
2589
2735
  const argv = unwrapStaticCommand(words);
2590
2736
  if (!words.some((arg) => arg.includes("NUMERICSTATUSVALUE")))
2591
2737
  return !isPassiveTextCommand(argv);
@@ -2594,6 +2740,9 @@ function hasEnvironmentVariableExpansionRead(
2594
2740
  return (
2595
2741
  !["echo", "printf"].includes(name ?? "") ||
2596
2742
  !isPassiveTextCommand(argv) ||
2743
+ argv.some(
2744
+ (arg) => arg.startsWith("-") && [...localStatuses].some((value) => arg.includes(value)),
2745
+ ) ||
2597
2746
  words
2598
2747
  .slice(0, words.length - argv.length)
2599
2748
  .some((arg) => arg.includes("NUMERICSTATUSVALUE")) ||
@@ -2606,13 +2755,6 @@ function hasEnvironmentVariableExpansionRead(
2606
2755
  )
2607
2756
  return true;
2608
2757
  }
2609
- for (const words of parsed.pipelines.flat()) {
2610
- if (
2611
- words.some((arg) => arg.includes("ENVTESTVALUE")) &&
2612
- !isApprovedEnvironmentPredicate(unwrapStaticCommand(words))
2613
- )
2614
- return true;
2615
- }
2616
2758
  for (const words of parsed.pipelines.flat()) {
2617
2759
  if (!words.some((arg) => arg.includes("UNKNOWNVALUE"))) continue;
2618
2760
  if (words.some((arg) => arg.includes("UNKNOWNVALUEUNQUOTED"))) return true;
@@ -2623,17 +2765,18 @@ function hasEnvironmentVariableExpansionRead(
2623
2765
  )
2624
2766
  return true;
2625
2767
  }
2626
- if (!localMaterialized.includes("ENVVALUE") && !localMaterialized.includes("ENVTESTVALUE")) {
2627
- return (
2768
+ if (!localMaterialized.includes("ENVVALUE")) {
2769
+ const blocked =
2628
2770
  hasSensitiveFileRead(localMaterialized, isPathBlocked) ||
2629
2771
  hasSensitivePathRedirect(localMaterialized, isPathBlocked) ||
2630
- hasEnvironmentRead(localMaterialized, allowedNames)
2631
- );
2772
+ hasEnvironmentRead(localMaterialized, allowedNames);
2773
+ if (!blocked && statusCaptures.size) onProvedStatus?.(localMaterialized);
2774
+ return blocked;
2632
2775
  }
2633
2776
 
2634
2777
  for (const [index, pipeline] of parsed.pipelines.entries()) {
2635
2778
  const hasEnvironmentValue = pipeline.some((argv) =>
2636
- argv.some((arg) => arg.includes("ENVVALUE") || arg.includes("ENVTESTVALUE")),
2779
+ argv.some((arg) => arg.includes("ENVVALUE")),
2637
2780
  );
2638
2781
  if (!hasEnvironmentValue) continue;
2639
2782
  if (parsed.redirects.some((redirect) => redirect.pipeline === index)) return true;
@@ -2641,12 +2784,8 @@ function hasEnvironmentVariableExpansionRead(
2641
2784
  pipeline.some((argv) => {
2642
2785
  const unwrapped = unwrapStaticCommand(argv);
2643
2786
  const name = unwrapped[0]?.split("/").at(-1) ?? "";
2644
- if (argv.some((arg) => arg.includes("ENVVALUE") || arg.includes("ENVTESTVALUE"))) {
2645
- if (
2646
- argv
2647
- .slice(0, argv.length - unwrapped.length)
2648
- .some((arg) => arg.includes("ENVVALUE") || arg.includes("ENVTESTVALUE"))
2649
- )
2787
+ if (argv.some((arg) => arg.includes("ENVVALUE"))) {
2788
+ if (argv.slice(0, argv.length - unwrapped.length).some((arg) => arg.includes("ENVVALUE")))
2650
2789
  return true;
2651
2790
  if (isApprovedEnvironmentPredicate(unwrapped)) return false;
2652
2791
  if (isNavigationOperand(unwrapped, "ENVVALUE")) {
@@ -2668,11 +2807,10 @@ function hasEnvironmentVariableExpansionRead(
2668
2807
  return true;
2669
2808
  }
2670
2809
  }
2671
- const materialized = [...localValues].reduce(
2672
- (text, [marker, value]) => text.replaceAll(marker, () => value),
2673
- normalized.replaceAll("ENVVALUE", "SAFEENVVALUE"),
2674
- );
2675
- return hasSensitiveFileRead(materialized, isPathBlocked);
2810
+ const materialized = normalized.replaceAll("ENVVALUE", "SAFEENVVALUE");
2811
+ const blocked = hasSensitiveFileRead(materialized, isPathBlocked);
2812
+ if (!blocked && statusCaptures.size) onProvedStatus?.(materialized);
2813
+ return blocked;
2676
2814
  }
2677
2815
 
2678
2816
  /** Quoted approved metadata can be compared or checked for blankness, never evaluated. */
@@ -2682,7 +2820,7 @@ function isApprovedEnvironmentPredicate(argv: string[]): boolean {
2682
2820
  if (args.at(-1) !== "]") return false;
2683
2821
  args = args.slice(0, -1);
2684
2822
  } else if (argv[0] !== "test") return false;
2685
- const value = (arg: string | undefined) => arg === "ENVVALUE" || arg === "ENVTESTVALUE";
2823
+ const value = (arg: string | undefined) => arg === "ENVVALUE";
2686
2824
  const literal = (arg: string | undefined) =>
2687
2825
  /^[A-Za-z0-9_.:-]*$/.test(arg ?? "") && arg !== undefined;
2688
2826
  return (
@@ -3010,16 +3148,24 @@ export function createCredentialGuard(config?: CredentialGuardConfig): Credentia
3010
3148
  hasSensitivePathRedirect(command, isPathBlocked)
3011
3149
  )
3012
3150
  return "blocked by the file-access policy; a sensitive path matched or this command form could not be verified";
3151
+ let environmentCommand = command;
3013
3152
  if (
3014
3153
  !isLiteralTextWrite(command) &&
3015
- hasEnvironmentVariableExpansionRead(command, allowedEnvironmentVariables, isPathBlocked)
3154
+ hasEnvironmentVariableExpansionRead(
3155
+ command,
3156
+ allowedEnvironmentVariables,
3157
+ isPathBlocked,
3158
+ (proved) => {
3159
+ environmentCommand = proved;
3160
+ },
3161
+ )
3016
3162
  ) {
3017
3163
  return "expands an unapproved or executable environment variable value";
3018
3164
  }
3019
3165
  if (
3020
3166
  hasEnvironmentSourceAccess(command, rawEnvironmentSource, allowedEnvironmentVariables) ||
3021
3167
  hasEnvironmentRead(
3022
- command,
3168
+ environmentCommand,
3023
3169
  allowedEnvironmentVariables,
3024
3170
  allowPackageInventory,
3025
3171
  shellBodies.filter((body) => {