vigiles 12.5.0 → 12.6.0

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.
@@ -67,6 +67,19 @@ export interface NormalizedLeaf {
67
67
  readonly flags: ReadonlySet<string>;
68
68
  /** True iff ANY of `names` is present in {@link flags} (short or long). */
69
69
  hasFlag(...names: readonly string[]): boolean;
70
+ /**
71
+ * Command-level env-assignments that apply to THIS leaf, keyed by variable
72
+ * name → static value (empty string for a naked `NAME=`). Populated from both
73
+ * the mvdan leading `CallExpr.Assigns` (`PIP_INDEX_URL=… pip install …`) AND
74
+ * the `NAME=value` words consumed by an `env` wrapper (`env NPM_CONFIG_REGISTRY=… npm i`).
75
+ *
76
+ * These carry supply-chain / behavior-altering configuration (`PIP_INDEX_URL`,
77
+ * `NPM_CONFIG_REGISTRY`, …) that an argv-only extractor never sees because the
78
+ * assignment is not an argv word. A dynamic RHS (command substitution, non-HOME
79
+ * parameter) is recorded with value `null` — present but unresolved. */
80
+ readonly assigns: ReadonlyMap<string, string | null>;
81
+ /** True iff a command-level assignment for ANY of `names` is present (resolved or not). */
82
+ hasAssign(...names: readonly string[]): boolean;
70
83
  }
71
84
  /**
72
85
  * Extract every simple command as a {@link NormalizedLeaf} — the operation-level
@@ -517,6 +517,151 @@ function buildFlags(args) {
517
517
  }
518
518
  return flags;
519
519
  }
520
+ // ---------------------------------------------------------------------------
521
+ // Command-wrapper stripping.
522
+ //
523
+ // A wrapper is a command whose JOB is to run ANOTHER command: `env FOO=bar CMD`,
524
+ // `command CMD`, `sudo CMD`, `nice -n5 CMD`, `timeout 5 CMD`, `xargs CMD`,
525
+ // `nohup CMD`. Because the normalized leaf keys on the leaf HEAD, a wrapper head
526
+ // hides the real operation — `env GIT_SSH= git push --force` and `command rm -rf /`
527
+ // look like `env`/`command` leaves, so a head-keyed matcher never sees the
528
+ // `git push --force` / `rm -rf /` it must block. We resolve THROUGH the wrapper:
529
+ // skip the wrapper's own options (and their values) and any `NAME=value` words it
530
+ // consumes, then treat the next word as the real command head (recursing so
531
+ // `sudo timeout 5 rm -rf /` unwraps fully). A wrapper with no following command
532
+ // (bare `env`, `env -i`) is preserved as-is, so the env-dump predicate still fires.
533
+ // ---------------------------------------------------------------------------
534
+ const WRAPPER_HEADS = new Set([
535
+ "env",
536
+ "command",
537
+ "nice",
538
+ "timeout",
539
+ "sudo",
540
+ "xargs",
541
+ "nohup",
542
+ ]);
543
+ /**
544
+ * Per-wrapper short/long options that consume a SEPARATE following token as their
545
+ * value (so the value is not mistaken for the wrapped command). Attached forms
546
+ * (`-n5`, `--kill-after=5`) are single tokens and need no entry.
547
+ */
548
+ const WRAPPER_VALUE_OPTS = {
549
+ env: new Set(["-u", "--unset", "-C", "--chdir", "-S", "--split-string"]),
550
+ command: new Set(),
551
+ nice: new Set(["-n", "--adjustment"]),
552
+ timeout: new Set(["-s", "--signal", "-k", "--kill-after"]),
553
+ sudo: new Set([
554
+ "-u",
555
+ "--user",
556
+ "-g",
557
+ "--group",
558
+ "-p",
559
+ "--prompt",
560
+ "-C",
561
+ "--close-from",
562
+ "-h",
563
+ "--host",
564
+ "-r",
565
+ "--role",
566
+ "-t",
567
+ "--type",
568
+ "-U",
569
+ "--other-user",
570
+ "-R",
571
+ "--chroot",
572
+ "-D",
573
+ "--chdir",
574
+ ]),
575
+ xargs: new Set([
576
+ "-I",
577
+ "-i",
578
+ "--replace",
579
+ "-n",
580
+ "--max-args",
581
+ "-P",
582
+ "--max-procs",
583
+ "-d",
584
+ "--delimiter",
585
+ "-E",
586
+ "-e",
587
+ "--eof",
588
+ "-s",
589
+ "--max-chars",
590
+ "-L",
591
+ "-l",
592
+ "--max-lines",
593
+ "-a",
594
+ "--arg-file",
595
+ ]),
596
+ nohup: new Set(),
597
+ };
598
+ /** Count of leading NON-option positionals a wrapper consumes before the command (timeout DURATION). */
599
+ const WRAPPER_SKIP_POSITIONALS = {
600
+ timeout: 1,
601
+ };
602
+ /** True iff a normalized word is a `NAME=value` env-assignment (used by `env`). */
603
+ function isAssignmentWord(word) {
604
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
605
+ }
606
+ /** Split a `NAME=value` word into its name and value. */
607
+ function splitAssignmentWord(word) {
608
+ const eq = word.indexOf("=");
609
+ return [word.slice(0, eq), word.slice(eq + 1)];
610
+ }
611
+ /**
612
+ * Resolve a wrapped command through one or more wrapper layers. Given a leaf's
613
+ * full normalized argv (`[head, ...args]`), returns the effective argv of the
614
+ * REAL command plus any `NAME=value` words an `env` wrapper consumed (so the
615
+ * caller can fold them into the leaf's assignment map). If the head is not a
616
+ * wrapper, or the wrapper has no following command, the argv is returned
617
+ * unchanged with an empty assignment set.
618
+ */
619
+ function stripWrappers(argv) {
620
+ const envAssigns = new Map();
621
+ let cur = argv;
622
+ for (let guard = 0; guard < 8; guard++) {
623
+ const head = cur[0];
624
+ if (head === undefined || !WRAPPER_HEADS.has(head))
625
+ break;
626
+ const valueOpts = WRAPPER_VALUE_OPTS[head] ?? new Set();
627
+ let positionalsToSkip = WRAPPER_SKIP_POSITIONALS[head] ?? 0;
628
+ let i = 1; // start after the wrapper head
629
+ let ended = false;
630
+ for (; i < cur.length; i++) {
631
+ const a = cur[i];
632
+ if (a === undefined)
633
+ break;
634
+ if (a === "--") {
635
+ i++;
636
+ ended = true;
637
+ break;
638
+ }
639
+ if (a.length > 1 && a.startsWith("-")) {
640
+ if (valueOpts.has(a))
641
+ i++; // skip this option's separate value too
642
+ continue;
643
+ }
644
+ if (head === "env" && isAssignmentWord(a)) {
645
+ const [name, value] = splitAssignmentWord(a);
646
+ envAssigns.set(name, value);
647
+ continue;
648
+ }
649
+ if (positionalsToSkip > 0) {
650
+ positionalsToSkip--;
651
+ continue;
652
+ }
653
+ break; // cur[i] is the real command head
654
+ }
655
+ void ended;
656
+ if (i >= cur.length)
657
+ break; // wrapper with no following command → keep as-is
658
+ const next = cur.slice(i);
659
+ if (next.length === cur.length)
660
+ break; // no progress → stop
661
+ cur = next;
662
+ }
663
+ return { argv: cur, envAssigns };
664
+ }
520
665
  /**
521
666
  * Extract every simple command as a {@link NormalizedLeaf} — the operation-level
522
667
  * twin of {@link leafCommands}. Same AST-backed structural coverage (a leaf nested
@@ -546,6 +691,19 @@ function leafCommandsNormalized(command) {
546
691
  });
547
692
  return out;
548
693
  }
694
+ /** Collect a CallExpr's leading `NAME=value` env-assignments into a name→value map. */
695
+ function collectAssigns(node) {
696
+ const assigns = new Map();
697
+ for (const a of node.Assigns ?? []) {
698
+ const name = a.Name?.Value;
699
+ if (!name)
700
+ continue;
701
+ // A naked `NAME=` has no Value word → empty string; a dynamic RHS → null.
702
+ const value = a.Value ? normalizeParts(a.Value.Parts) : "";
703
+ assigns.set(name, value);
704
+ }
705
+ return assigns;
706
+ }
549
707
  /** Normalize a single CallExpr node to a {@link NormalizedLeaf}, or null if it isn't one / has a dynamic head. */
550
708
  function normalizeCallExpr(node) {
551
709
  if (sh.syntax.NodeType(node) !== "CallExpr" || !node.Args?.length)
@@ -553,10 +711,20 @@ function normalizeCallExpr(node) {
553
711
  const headRaw = normalizeParts(node.Args[0]?.Parts);
554
712
  if (headRaw === null)
555
713
  return null; // dynamic head → not normalizable
556
- const head = normalizeHead(headRaw);
557
- const args = node.Args.slice(1)
714
+ const rawHead = normalizeHead(headRaw);
715
+ const rawArgs = node.Args.slice(1)
558
716
  .map((w) => normalizeParts(w.Parts))
559
717
  .filter((w) => w !== null);
718
+ // Resolve through any command-wrapper (`env`/`command`/`sudo`/`timeout`/…) so
719
+ // the leaf reflects the REAL operation, not the wrapper head.
720
+ const stripped = stripWrappers([rawHead, ...rawArgs]);
721
+ const head = normalizeHead(stripped.argv[0] ?? rawHead);
722
+ const args = stripped.argv.slice(1);
723
+ // Command-level assignments: mvdan leading `CallExpr.Assigns` plus any
724
+ // `NAME=value` words an `env` wrapper consumed on the way to the real command.
725
+ const assigns = collectAssigns(node);
726
+ for (const [k, v] of stripped.envAssigns)
727
+ assigns.set(k, v);
560
728
  const flags = buildFlags(args);
561
729
  return {
562
730
  head,
@@ -564,6 +732,8 @@ function normalizeCallExpr(node) {
564
732
  args,
565
733
  flags,
566
734
  hasFlag: (...names) => names.some((n) => flags.has(n)),
735
+ assigns,
736
+ hasAssign: (...names) => names.some((n) => assigns.has(n)),
567
737
  };
568
738
  }
569
739
  //# sourceMappingURL=bash-effects.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "12.5.0",
3
+ "version": "12.6.0",
4
4
  "description": "Lint & test the harness your AI agent runs on — verify the references in your CLAUDE.md / AGENTS.md and test that your hooks and skills actually work.",
5
5
  "keywords": [
6
6
  "claude-code",