vigiles 12.4.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.
@@ -50,4 +50,48 @@ export declare function isReadOnlyBash(command: string): boolean;
50
50
  * native `Bash(git:*)` glob (issue #30519) and a hand-written `grep` both miss.
51
51
  */
52
52
  export declare function leafCommands(command: string): string[][];
53
+ /** A single simple command, normalized to its operation form. */
54
+ export interface NormalizedLeaf {
55
+ /** Head normalized to its basename, backslash-stripped: `/bin/rm`→`rm`, `\rm`→`rm`. */
56
+ readonly head: string;
57
+ /** `[head, ...args]` — every word quote-unwrapped and $HOME/~-canonicalized. */
58
+ readonly argv: readonly string[];
59
+ /** The normalized args (argv without the head). */
60
+ readonly args: readonly string[];
61
+ /**
62
+ * Canonical flag tokens present on this leaf. Short clusters are split
63
+ * (`-rf`→`r`,`f`) and each flag is recorded in BOTH short and long form via the
64
+ * alias table, so a caller can test `--force`/`-f` or `--no-verify`/`-n`
65
+ * uniformly. Long values are split on `=` (`--index-url=x`→`index-url`).
66
+ */
67
+ readonly flags: ReadonlySet<string>;
68
+ /** True iff ANY of `names` is present in {@link flags} (short or long). */
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;
83
+ }
84
+ /**
85
+ * Extract every simple command as a {@link NormalizedLeaf} — the operation-level
86
+ * twin of {@link leafCommands}. Same AST-backed structural coverage (a leaf nested
87
+ * in a pipeline / `&&` / subshell is still found), but each word is quote-unwrapped
88
+ * and $HOME-canonicalized, the head is reduced to its backslash-stripped basename,
89
+ * and flags are expanded to short+long canonical forms. A matcher built on this
90
+ * compares against the OPERATION rather than the surface tokens, so it is robust
91
+ * to quoting, interpreter path, backslash escaping, flag aliasing, and $HOME/~.
92
+ *
93
+ * Purely additive: reuses the same mvdan-sh parse, changes nothing above. Parse
94
+ * failure → []. A leaf with a dynamic head is skipped (can't be normalized).
95
+ */
96
+ export declare function leafCommandsNormalized(command: string): NormalizedLeaf[];
53
97
  //# sourceMappingURL=bash-effects.d.ts.map
@@ -27,6 +27,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
27
27
  exports.classifyBashCommand = classifyBashCommand;
28
28
  exports.isReadOnlyBash = isReadOnlyBash;
29
29
  exports.leafCommands = leafCommands;
30
+ exports.leafCommandsNormalized = leafCommandsNormalized;
30
31
  // mvdan-sh is a CJS package (GopherJS build) with no bundled TypeScript types.
31
32
  // The project compiles to CommonJS (Node16, no "type":"module"), so plain
32
33
  // require() works and is the idiomatic pattern here (see linters.ts).
@@ -433,4 +434,306 @@ function leafCommands(command) {
433
434
  });
434
435
  return out;
435
436
  }
437
+ /**
438
+ * Known short↔long flag aliases. Deliberately small and operation-relevant:
439
+ * a caller always gates on the head (e.g. only treats `index-url` as supply-chain
440
+ * when the head is `pip`), so recording both forms unconditionally is safe.
441
+ */
442
+ const SHORT_TO_LONG = {
443
+ f: "force",
444
+ n: "no-verify",
445
+ r: "recursive",
446
+ i: "index-url",
447
+ };
448
+ const LONG_TO_SHORT = {
449
+ force: "f",
450
+ "no-verify": "n",
451
+ recursive: "r",
452
+ "index-url": "i",
453
+ };
454
+ /**
455
+ * Reconstruct a Word's static text, unwrapping single/double quotes and
456
+ * canonicalizing `$HOME`/`${HOME}` to `~`. Returns null if the word contains a
457
+ * truly dynamic segment (command substitution, arithmetic, a non-HOME parameter)
458
+ * — such a word can't be soundly reduced to a literal operation token.
459
+ */
460
+ function normalizeParts(parts) {
461
+ if (!parts)
462
+ return null;
463
+ let out = "";
464
+ for (const p of parts) {
465
+ const t = sh.syntax.NodeType(p);
466
+ if (t === "Lit" || t === "SglQuoted") {
467
+ out += p.Value ?? "";
468
+ }
469
+ else if (t === "DblQuoted") {
470
+ const inner = normalizeParts(p.Parts);
471
+ if (inner === null)
472
+ return null;
473
+ out += inner;
474
+ }
475
+ else if (t === "ParamExp") {
476
+ // Canonicalize the home directory; any other parameter is dynamic.
477
+ if (p.Param?.Value === "HOME")
478
+ out += "~";
479
+ else
480
+ return null;
481
+ }
482
+ else {
483
+ return null; // CmdSubst / ArithmExp / ProcSubst / … → dynamic
484
+ }
485
+ }
486
+ return out;
487
+ }
488
+ /** Normalize a command head to its basename, stripping one leading backslash. */
489
+ function normalizeHead(raw) {
490
+ const unescaped = raw.startsWith("\\") ? raw.slice(1) : raw;
491
+ const slash = unescaped.lastIndexOf("/");
492
+ return slash >= 0 ? unescaped.slice(slash + 1) : unescaped;
493
+ }
494
+ /** Build the canonical flag set for a leaf's args (short-cluster + alias expansion). */
495
+ function buildFlags(args) {
496
+ const flags = new Set();
497
+ const add = (name) => {
498
+ if (!name)
499
+ return;
500
+ flags.add(name);
501
+ if (name.length === 1 && SHORT_TO_LONG[name])
502
+ flags.add(SHORT_TO_LONG[name]);
503
+ const short = LONG_TO_SHORT[name];
504
+ if (short)
505
+ flags.add(short);
506
+ };
507
+ for (const a of args) {
508
+ if (a.startsWith("--")) {
509
+ add(a.slice(2).split("=")[0] ?? "");
510
+ }
511
+ else if (a.length > 1 && a[0] === "-") {
512
+ for (const ch of a.slice(1)) {
513
+ if (/[a-zA-Z]/.test(ch))
514
+ add(ch);
515
+ }
516
+ }
517
+ }
518
+ return flags;
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
+ }
665
+ /**
666
+ * Extract every simple command as a {@link NormalizedLeaf} — the operation-level
667
+ * twin of {@link leafCommands}. Same AST-backed structural coverage (a leaf nested
668
+ * in a pipeline / `&&` / subshell is still found), but each word is quote-unwrapped
669
+ * and $HOME-canonicalized, the head is reduced to its backslash-stripped basename,
670
+ * and flags are expanded to short+long canonical forms. A matcher built on this
671
+ * compares against the OPERATION rather than the surface tokens, so it is robust
672
+ * to quoting, interpreter path, backslash escaping, flag aliasing, and $HOME/~.
673
+ *
674
+ * Purely additive: reuses the same mvdan-sh parse, changes nothing above. Parse
675
+ * failure → []. A leaf with a dynamic head is skipped (can't be normalized).
676
+ */
677
+ function leafCommandsNormalized(command) {
678
+ let file;
679
+ try {
680
+ file = sh.syntax.NewParser().Parse(command, "cmd.sh");
681
+ }
682
+ catch {
683
+ return [];
684
+ }
685
+ const out = [];
686
+ sh.syntax.Walk(file, (node) => {
687
+ const leaf = normalizeCallExpr(node);
688
+ if (leaf)
689
+ out.push(leaf);
690
+ return true;
691
+ });
692
+ return out;
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
+ }
707
+ /** Normalize a single CallExpr node to a {@link NormalizedLeaf}, or null if it isn't one / has a dynamic head. */
708
+ function normalizeCallExpr(node) {
709
+ if (sh.syntax.NodeType(node) !== "CallExpr" || !node.Args?.length)
710
+ return null;
711
+ const headRaw = normalizeParts(node.Args[0]?.Parts);
712
+ if (headRaw === null)
713
+ return null; // dynamic head → not normalizable
714
+ const rawHead = normalizeHead(headRaw);
715
+ const rawArgs = node.Args.slice(1)
716
+ .map((w) => normalizeParts(w.Parts))
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);
728
+ const flags = buildFlags(args);
729
+ return {
730
+ head,
731
+ argv: [head, ...args],
732
+ args,
733
+ flags,
734
+ hasFlag: (...names) => names.some((n) => flags.has(n)),
735
+ assigns,
736
+ hasAssign: (...names) => names.some((n) => assigns.has(n)),
737
+ };
738
+ }
436
739
  //# sourceMappingURL=bash-effects.js.map
package/dist/hook.d.ts CHANGED
@@ -49,4 +49,6 @@ export { defineHook, defineFileGate, definePromptGate, defineStopGate, tool, too
49
49
  export type { Decision, HookMode, GateAction, CommandView, PathView, ResponseView, BashToolEvent, FileToolEvent, PromptEvent, StopEvent, ReactEvent, SessionEvent, HookProgram, FileGateHook, PromptGateHook, StopGateHook, InjectHook, ReactHook, AnyHook, DispatchKind, Injection, Reaction, RunReaction, CompiledHookProgram, CompileHookOptions, RawHookEvent, HookProgramOutcome, } from "./core/hook-program.js";
50
50
  export { provide, dangerously, defineProvider, provider, } from "./core/hook-providers.js";
51
51
  export type { ProviderName, ProviderResults, HookCtx, NeedSpec, InlineProvider, RegisteredProvider, RegisteredRef, ProviderRegistry, } from "./core/hook-providers.js";
52
+ export { leafCommandsNormalized } from "./core/bash-effects.js";
53
+ export type { NormalizedLeaf } from "./core/bash-effects.js";
52
54
  //# sourceMappingURL=hook.d.ts.map
package/dist/hook.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.provider = exports.defineProvider = exports.dangerously = exports.provide = exports.HookCompileError = exports.verifyHookStamp = exports.stampHook = exports.checkHookImports = exports.compileHookProgram = exports.hookNeeds = exports.hookRouting = exports.dispatchKind = exports.decisionExitCode = exports.runHookProgram = exports.runReact = exports.runInject = exports.decideStopGate = exports.decidePromptGate = exports.decideFileGate = exports.decideProgram = exports.responseView = exports.nothing = exports.notice = exports.run = exports.defineReact = exports.inject = exports.defineInject = exports.hookMode = exports.gateAction = exports.pathView = exports.commandView = exports.ask = exports.deny = exports.allow = exports.tools = exports.tool = exports.defineStopGate = exports.definePromptGate = exports.defineFileGate = exports.defineHook = void 0;
3
+ exports.leafCommandsNormalized = exports.provider = exports.defineProvider = exports.dangerously = exports.provide = exports.HookCompileError = exports.verifyHookStamp = exports.stampHook = exports.checkHookImports = exports.compileHookProgram = exports.hookNeeds = exports.hookRouting = exports.dispatchKind = exports.decisionExitCode = exports.runHookProgram = exports.runReact = exports.runInject = exports.decideStopGate = exports.decidePromptGate = exports.decideFileGate = exports.decideProgram = exports.responseView = exports.nothing = exports.notice = exports.run = exports.defineReact = exports.inject = exports.defineInject = exports.hookMode = exports.gateAction = exports.pathView = exports.commandView = exports.ask = exports.deny = exports.allow = exports.tools = exports.tool = exports.defineStopGate = exports.definePromptGate = exports.defineFileGate = exports.defineHook = void 0;
4
4
  /**
5
5
  * `vigiles/hook` — the **closed vocabulary** for authoring a compiled hook.
6
6
  *
@@ -95,4 +95,8 @@ Object.defineProperty(exports, "provide", { enumerable: true, get: function () {
95
95
  Object.defineProperty(exports, "dangerously", { enumerable: true, get: function () { return hook_providers_js_1.dangerously; } });
96
96
  Object.defineProperty(exports, "defineProvider", { enumerable: true, get: function () { return hook_providers_js_1.defineProvider; } });
97
97
  Object.defineProperty(exports, "provider", { enumerable: true, get: function () { return hook_providers_js_1.provider; } });
98
+ // Operation-normalized leaf extraction — the robust matching primitive a
99
+ // hardened guard is built on (see examples/harness/safe-bash-guard-v2.mjs).
100
+ var bash_effects_js_1 = require("./core/bash-effects.js");
101
+ Object.defineProperty(exports, "leafCommandsNormalized", { enumerable: true, get: function () { return bash_effects_js_1.leafCommandsNormalized; } });
98
102
  //# sourceMappingURL=hook.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "12.4.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",