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.
- package/dist/core/bash-effects.d.ts +13 -0
- package/dist/core/bash-effects.js +172 -2
- package/package.json +1 -1
|
@@ -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
|
|
557
|
-
const
|
|
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.
|
|
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",
|