vigiles 15.1.0 → 15.2.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.
@@ -112,6 +112,26 @@ export interface NormalizedLeaf {
112
112
  * {@link LeafRedirect}. Empty for a command with no redirection.
113
113
  */
114
114
  readonly redirects: readonly LeafRedirect[];
115
+ /**
116
+ * The directory a chdir WRAPPER moved this leaf into before exec'ing it —
117
+ * `env -C dir`, `env --chdir=dir`, `sudo -D dir` — or `null` when there was
118
+ * none. Nested wrappers accumulate (`sudo -D a env -C b cmd` → `a/b`).
119
+ *
120
+ * The parser already READ this token in order to skip past it, then threw the
121
+ * value away — so every relative operand of the wrapped command resolved
122
+ * against the wrong directory for every consumer. Same shape as the
123
+ * redirection targets the leaf used to drop: the parser knew, the leaf did
124
+ * not carry it. `git -C` is deliberately NOT here — `git` is not a wrapper
125
+ * (it does not exec the rest of its argv as a command).
126
+ *
127
+ * ⚠️ ONE LEAF'S OWN WRAPPER, NOT A CWD MODEL. A directory changed by a
128
+ * PRECEDING statement (`cd x && …`, `pushd`, a subshell) is not reported:
129
+ * connectors do not survive leaf extraction, and `cd x; cmd` writes into the
130
+ * OLD directory when the `cd` fails — that is a model with failure semantics,
131
+ * not a field. A dynamic value (`env -C "$DIR" …`) is not resolvable and the
132
+ * whole leaf is already unnormalizable in that case.
133
+ */
134
+ readonly chdir: string | null;
115
135
  }
116
136
  /**
117
137
  * Extract every simple command as a {@link NormalizedLeaf} — the operation-level
@@ -617,6 +617,38 @@ const WRAPPER_VALUE_OPTS = {
617
617
  ]),
618
618
  nohup: new Set(),
619
619
  };
620
+ /**
621
+ * Per-wrapper options whose value is a DIRECTORY the wrapper chdirs into before
622
+ * exec'ing the command. A subset of {@link WRAPPER_VALUE_OPTS}, and keyed by head
623
+ * for a reason: `-C` is `--chdir` for `env` but `--close-from` (a file
624
+ * descriptor) for `sudo`, whose chdir is `-D`. One shared set would read a
625
+ * number as a directory.
626
+ */
627
+ const WRAPPER_CHDIR_OPTS = {
628
+ env: new Set(["-C", "--chdir"]),
629
+ sudo: new Set(["-D", "--chdir"]),
630
+ };
631
+ /**
632
+ * The directory carried by an option WORD, when that word is one of this
633
+ * wrapper's chdir options — covering both spellings, since `--chdir=dir` is a
634
+ * single token that never reaches the separate-value table.
635
+ */
636
+ function chdirValue(word, next, chdirOpts) {
637
+ if (chdirOpts.has(word))
638
+ return next; // `-C dir`, `--chdir dir`
639
+ const eq = word.indexOf("=");
640
+ return eq > 0 && chdirOpts.has(word.slice(0, eq))
641
+ ? word.slice(eq + 1) // `--chdir=dir`
642
+ : undefined;
643
+ }
644
+ /** Layer a wrapper's chdir onto the one an outer wrapper already applied. */
645
+ function nestChdir(outer, inner) {
646
+ if (inner === undefined || inner === "")
647
+ return outer;
648
+ if (outer === null || inner.startsWith("/") || /^[A-Za-z]:\//.test(inner))
649
+ return inner;
650
+ return `${outer.replace(/\/+$/, "")}/${inner}`;
651
+ }
620
652
  /** Count of leading NON-option positionals a wrapper consumes before the command (timeout DURATION). */
621
653
  const WRAPPER_SKIP_POSITIONALS = {
622
654
  timeout: 1,
@@ -640,12 +672,14 @@ function splitAssignmentWord(word) {
640
672
  */
641
673
  function stripWrappers(argv) {
642
674
  const envAssigns = new Map();
675
+ let chdir = null;
643
676
  let cur = argv;
644
677
  for (let guard = 0; guard < 8; guard++) {
645
678
  const head = cur[0];
646
679
  if (head === undefined || !WRAPPER_HEADS.has(head))
647
680
  break;
648
681
  const valueOpts = WRAPPER_VALUE_OPTS[head] ?? new Set();
682
+ const chdirOpts = WRAPPER_CHDIR_OPTS[head] ?? new Set();
649
683
  let positionalsToSkip = WRAPPER_SKIP_POSITIONALS[head] ?? 0;
650
684
  let i = 1; // start after the wrapper head
651
685
  let ended = false;
@@ -659,6 +693,11 @@ function stripWrappers(argv) {
659
693
  break;
660
694
  }
661
695
  if (a.length > 1 && a.startsWith("-")) {
696
+ // The chdir value is CAPTURED before it is skipped. It was always read
697
+ // here — reading it is how the loop knows to skip past it — and then
698
+ // discarded, so every relative operand of the wrapped command resolved
699
+ // against the wrong directory downstream.
700
+ chdir = nestChdir(chdir, chdirValue(a, cur[i + 1], chdirOpts));
662
701
  if (valueOpts.has(a))
663
702
  i++; // skip this option's separate value too
664
703
  continue;
@@ -682,7 +721,7 @@ function stripWrappers(argv) {
682
721
  break; // no progress → stop
683
722
  cur = next;
684
723
  }
685
- return { argv: cur, envAssigns };
724
+ return { argv: cur, envAssigns, chdir };
686
725
  }
687
726
  /**
688
727
  * Extract every simple command as a {@link NormalizedLeaf} — the operation-level
@@ -1056,6 +1095,7 @@ function normalizeCallExpr(node, redirs) {
1056
1095
  assigns,
1057
1096
  hasAssign: (...names) => names.some((n) => assigns.has(n)),
1058
1097
  redirects: normalizeRedirects(redirs),
1098
+ chdir: stripped.chdir,
1059
1099
  };
1060
1100
  }
1061
1101
  //# sourceMappingURL=bash-effects.js.map
@@ -169,8 +169,33 @@ export interface CommandView {
169
169
  *
170
170
  * Deletion is a different question and is deliberately NOT reported here —
171
171
  * pair with `runs("rm")` if a gate cares about removal too.
172
+ *
173
+ * Exactly `writeTargets(prefixes).length > 0`, and implemented as that — reach
174
+ * for {@link writeTargets} when the gate needs to know WHICH file.
172
175
  */
173
176
  writesTo(prefixes: readonly string[]): boolean;
177
+ /**
178
+ * The write targets of this command that fall under one of the prefixes — the
179
+ * "WHICH file is written" counterpart to {@link writesTo}. Same two AST-backed
180
+ * sources (redirection targets + file-writing programs' argv positions), same
181
+ * denylist bias (an undecidable placement is INCLUDED).
182
+ *
183
+ * Spelling is as-written after normalization — quote-unwrapped,
184
+ * `$HOME`-canonicalized, and resolved against the leaf's own chdir wrapper
185
+ * (`env -C dir sed -i x` reports `dir/x`) — in order of appearance, exact
186
+ * duplicates collapsed. Filter by basename/suffix; never re-match the prefixes
187
+ * by hand, because hand-rolled prefix matching is the exact source of the
188
+ * trailing-slash, absolute-path and root-blindness defects this vocabulary
189
+ * exists to remove.
190
+ *
191
+ * An empty array ⇔ `writesTo(prefixes) === false`, so the natural
192
+ * `writeTargets(P).some(pred)` needs no emptiness check to behave correctly.
193
+ *
194
+ * `prefixes` is REQUIRED and there is no unfiltered overload: the raw list
195
+ * does not cross the API boundary, because a consumer holding it has to
196
+ * re-implement the matching that {@link prefixVerdict} exists to own.
197
+ */
198
+ writeTargets(prefixes: readonly string[]): readonly string[];
174
199
  /**
175
200
  * True iff the command pipes into a BARE shell interpreter (`curl … | sh`,
176
201
  * `… | bash -s`) — the remote-code-execution shape. High-signal: a shell leaf
@@ -440,6 +440,18 @@ function writeTargetsOf(leaf) {
440
440
  return [];
441
441
  }
442
442
  }
443
+ /**
444
+ * A write target as it lands on disk, given the chdir wrapper the leaf ran under
445
+ * (`env -C migratsiya sed -i s/a/b/ papers/x.tex` writes `migratsiya/papers/x.tex`).
446
+ *
447
+ * Absolute targets and `~`-rooted ones already name their directory and are
448
+ * returned untouched. The join itself is {@link resolveRef} rather than a fresh
449
+ * `a + "/" + b`, because two functions normalising the same string differently is
450
+ * the defect class this file keeps finding.
451
+ */
452
+ const underChdir = (target, chdir) => chdir === null || target === "" || target.startsWith("~")
453
+ ? target
454
+ : resolveRef(chdir, target);
443
455
  /**
444
456
  * An AST-backed view of a Bash command.
445
457
  *
@@ -457,10 +469,20 @@ function commandView(raw, root) {
457
469
  // The operation-normalized leaves carry the redirections (and quote-unwrapped,
458
470
  // wrapper-resolved argv) that `writesTo` needs; `leafCommands` cannot see them.
459
471
  const normalized = (0, bash_effects_js_1.leafCommandsNormalized)(raw);
460
- const writeTargets = normalized.flatMap((leaf) => [
472
+ const allWriteTargets = normalized.flatMap((leaf) => [
473
+ // 🔴 A REDIRECTION IS NOT JOINED ONTO THE LEAF'S CHDIR, AND THAT IS THE
474
+ // SHELL'S RULE, NOT A SHORTCUT. `env -C dir cmd > out.txt` opens `out.txt`
475
+ // in the SHELL's directory — the redirection happens before `env` ever runs
476
+ // and `-C` only moves the process `env` execs. Joining here would report a
477
+ // file the command never writes.
461
478
  ...leaf.redirects.flatMap((r) => r.writes && r.target !== null ? [r.target] : []),
462
- ...writeTargetsOf(leaf),
479
+ // The wrapped program's own operands DO resolve against it — see
480
+ // `NormalizedLeaf.chdir` for why the value was being read and discarded.
481
+ ...writeTargetsOf(leaf).map((t) => underChdir(t, leaf.chdir)),
463
482
  ]);
483
+ const matchedWriteTargets = (prefixes) => [
484
+ ...new Set(allWriteTargets.filter((t) => prefixes.some((p) => matchesPrefix(prefixVerdict(t, p, root), "match")))),
485
+ ];
464
486
  return {
465
487
  raw,
466
488
  runs(program, opts) {
@@ -501,7 +523,13 @@ function commandView(raw, root) {
501
523
  ? [tok, tok.slice(tok.indexOf("=") + 1)]
502
524
  : [tok])
503
525
  .some((tok) => prefixes.some((p) => matchesPrefix(prefixVerdict(tok, p, root), "match"))),
504
- writesTo: (prefixes) => writeTargets.some((t) => prefixes.some((p) => matchesPrefix(prefixVerdict(t, p, root), "match"))),
526
+ // DERIVED, not a second implementation of the same rule. The boolean stays
527
+ // because `writesTo(secrets) ? deny() : allow()` is the common gate shape,
528
+ // but it is a PROJECTION of the list — one code path, so the two can never
529
+ // drift the way `runs()` and `writesTo` did (one reads raw leaves, the other
530
+ // normalized ones, and a gate built on both had a silent hole).
531
+ writesTo: (prefixes) => matchedWriteTargets(prefixes).length > 0,
532
+ writeTargets: matchedWriteTargets,
505
533
  pipesToShell: () => leaves.some(isBareShellLeaf),
506
534
  };
507
535
  }
@@ -1157,6 +1185,48 @@ function runHookProgram(hook, event, ctx = {}, root = event.cwd) {
1157
1185
  function isAbsoluteRef(ref) {
1158
1186
  return ref.startsWith("/") || /^[A-Za-z]:\//.test(ref);
1159
1187
  }
1188
+ /**
1189
+ * Does this ROOT name a Windows filesystem? One predicate, because the two
1190
+ * questions that ask it — how many segments `..` may not pop through, and
1191
+ * whether a `//` leader is a share or a stutter — must never disagree about the
1192
+ * same root. `//x` is caught by the second arm: too short to be a share, but
1193
+ * still not something to read as POSIX.
1194
+ */
1195
+ const namesWindowsFs = (root) => WINDOWS_ROOT.test(root) || root.startsWith("//");
1196
+ /**
1197
+ * How many leading segments of a resolved path ARE its root — the floor a `..`
1198
+ * must not pop through. `0` on POSIX, `1` for a drive (`C:`), `2` for a UNC
1199
+ * share (`//server/share`), and 2/4 for the `//?/` spellings of those two.
1200
+ *
1201
+ * 🔴 A `//` LEADER IS THE ROOT'S ANSWER, NEVER THE OPERAND'S — the round-37
1202
+ * lesson at a third site (after the case fold in {@link caseInsensitiveFs} and
1203
+ * the UNC leader in {@link resolveRef}). Read from the string alone,
1204
+ * `//repo/a/../src/x.ts` looks share-rooted, so a count taken off the operand
1205
+ * would guard `repo/a` and stop `..` collapsing at all — under a POSIX root that
1206
+ * doubled slash is a stutter, not a share. The `//` forms are therefore gated on
1207
+ * the root, exactly as the leader is.
1208
+ *
1209
+ * ⚠️ A DRIVE LETTER IS THE ONE THING THAT NAMES A WINDOWS FILESYSTEM BY ITSELF,
1210
+ * and that is not a second rule — {@link isAtOrUnder} already says it about a
1211
+ * base (`WINDOWS_ROOT.test(rawBase)`), because `C:/x` has no POSIX reading the
1212
+ * way `//x` does. It earns its place at a real call site rather than in the
1213
+ * abstract: {@link absoluteSpelling} resolves a ROOTLESS absolute path against
1214
+ * the literal `"/"`, so gating the drive on the root too would leave
1215
+ * `C:/../repo/x` still losing its drive right there — the sibling call site a
1216
+ * fix written only where the defect was found would have missed.
1217
+ *
1218
+ * The count itself is read off {@link WINDOWS_ROOT} rather than re-derived,
1219
+ * because that regex already IS this file's single answer to "what is a Windows
1220
+ * root"; the two forms and the `//?/` spelling are enumerated there, once.
1221
+ */
1222
+ const rootSegmentCount = (joined, root) => {
1223
+ const matched = WINDOWS_ROOT.exec(joined)?.[0];
1224
+ if (matched === undefined)
1225
+ return 0; // POSIX, or relative: no root inside `out`
1226
+ if (/^[/\\]/.test(matched) && !namesWindowsFs(root))
1227
+ return 0; // stutter, not share
1228
+ return matched.split(/[/\\]+/).filter((s) => s !== "").length;
1229
+ };
1160
1230
  /**
1161
1231
  * Resolve a path reference against a root, without node:path (core stays
1162
1232
  * dependency-free). Mirrors `resolve(root, ref)`: an absolute ref wins, a
@@ -1178,12 +1248,27 @@ function resolveRef(root, ref) {
1178
1248
  const joined = isAbsoluteRef(r)
1179
1249
  ? r
1180
1250
  : `${slashes(root).replace(/\/+$/, "")}/${r}`;
1251
+ // 🔴 `..` CLAMPS AT THE ROOT, IT DOES NOT EAT IT. A real filesystem holds
1252
+ // still at the top: on Windows `C:/..` is `C:/`, on POSIX `/..` is `/`. An
1253
+ // unconditional `out.pop()` popped the DRIVE LETTER out of `C:/../repo/src/x`,
1254
+ // leaving the relative-looking `repo/src/x` — which then failed to resolve
1255
+ // against `C:/repo`, so a gate stopped recognising a path naming its own
1256
+ // repository. A UNC share went one worse: two `..` ate `share` and then
1257
+ // `server`.
1258
+ //
1259
+ // ⚠️ POSIX WAS ALREADY CORRECT BY ACCIDENT, and the accident is worth naming
1260
+ // so nobody "simplifies" it back: its leader `/` is held OUTSIDE `out` (see
1261
+ // the leader below), so popping an empty array is already the clamp. The
1262
+ // Windows forms broke precisely because their root lives INSIDE `out` —
1263
+ // exactly the segments the loop treats as ordinary directories.
1264
+ const floor = rootSegmentCount(joined, root);
1181
1265
  const out = [];
1182
1266
  for (const seg of joined.split("/")) {
1183
1267
  if (seg === "" || seg === ".")
1184
1268
  continue;
1185
1269
  if (seg === "..") {
1186
- out.pop();
1270
+ if (out.length > floor)
1271
+ out.pop();
1187
1272
  continue;
1188
1273
  }
1189
1274
  out.push(seg);
@@ -1201,8 +1286,7 @@ function resolveRef(root, ref) {
1201
1286
  // stutter, so preserving the pair unconditionally made it stop resolving
1202
1287
  // against a POSIX root and an allowlist gate denied a valid edit. Semantics
1203
1288
  // belong to the filesystem, and only the root knows which one that is.
1204
- const unc = joined.startsWith("//") &&
1205
- (WINDOWS_ROOT.test(root) || root.startsWith("//"));
1289
+ const unc = joined.startsWith("//") && namesWindowsFs(root);
1206
1290
  const leader = unc ? "//" : joined.startsWith("/") ? "/" : "";
1207
1291
  return leader + out.join("/");
1208
1292
  }
@@ -30,6 +30,22 @@ export declare function computePerFileHashes(inputFiles: string[], basePath: str
30
30
  * Recurses so that nested targets (e.g. `.github/copilot-instructions.md`,
31
31
  * which lives at `.vigiles/.github/copilot-instructions.md.inputs.json`)
32
32
  * are not silently skipped.
33
+ *
34
+ * 🔴 A DIRECTORY SYMLINK IS NOT DESCENDED INTO — this walk classified entries
35
+ * with a bare `statSync().isDirectory()`, which FOLLOWS a link, so a link inside
36
+ * `.vigiles/` pointing at a directory OUTSIDE it was walked (manifests from a
37
+ * foreign tree entering the audit under reconstructed target names), and a cycle
38
+ * — `.vigiles/loop -> .vigiles`, or a link to any ancestor — re-walked the same
39
+ * tree once per lap. Measured 2026-08-13 by reverting this call: ONE manifest was
40
+ * reported FORTY-ONE times, as `Real.md`, `loop/Real.md`, `loop/loop/Real.md` …
41
+ * until the kernel's link limit stopped it. Unlike the loader's walk it did not
42
+ * throw, and that is not a mitigation: the `catch` below swallowed the `ELOOP` and
43
+ * turned a crash into a silently multiplied audit. Third instance of the class in
44
+ * this repo; the decision is {@link entryOf} in `fs-walk.ts`, which carries the
45
+ * full rationale: a symlinked FILE is still read (it cannot recurse, and a
46
+ * manifest reached through one is a real manifest), termination is by
47
+ * construction rather than a visited-set, and an unreadable entry is `"skip"`
48
+ * rather than a throw — which is exactly what the `try`/`continue` here did.
33
49
  */
34
50
  export declare function iterateSidecars(basePath: string, fn: (target: string, manifest: SidecarManifest) => void): void;
35
51
  //# sourceMappingURL=sidecar.d.ts.map
@@ -18,6 +18,7 @@ exports.computePerFileHashes = computePerFileHashes;
18
18
  exports.iterateSidecars = iterateSidecars;
19
19
  const node_fs_1 = require("node:fs");
20
20
  const node_path_1 = require("node:path");
21
+ const fs_walk_js_1 = require("../fs-walk.js");
21
22
  const hash_js_1 = require("./hash.js");
22
23
  const SIDECAR_ROOT = ".vigiles";
23
24
  const MANIFEST_SUFFIX = ".inputs.json";
@@ -60,6 +61,22 @@ function computePerFileHashes(inputFiles, basePath) {
60
61
  * Recurses so that nested targets (e.g. `.github/copilot-instructions.md`,
61
62
  * which lives at `.vigiles/.github/copilot-instructions.md.inputs.json`)
62
63
  * are not silently skipped.
64
+ *
65
+ * 🔴 A DIRECTORY SYMLINK IS NOT DESCENDED INTO — this walk classified entries
66
+ * with a bare `statSync().isDirectory()`, which FOLLOWS a link, so a link inside
67
+ * `.vigiles/` pointing at a directory OUTSIDE it was walked (manifests from a
68
+ * foreign tree entering the audit under reconstructed target names), and a cycle
69
+ * — `.vigiles/loop -> .vigiles`, or a link to any ancestor — re-walked the same
70
+ * tree once per lap. Measured 2026-08-13 by reverting this call: ONE manifest was
71
+ * reported FORTY-ONE times, as `Real.md`, `loop/Real.md`, `loop/loop/Real.md` …
72
+ * until the kernel's link limit stopped it. Unlike the loader's walk it did not
73
+ * throw, and that is not a mitigation: the `catch` below swallowed the `ELOOP` and
74
+ * turned a crash into a silently multiplied audit. Third instance of the class in
75
+ * this repo; the decision is {@link entryOf} in `fs-walk.ts`, which carries the
76
+ * full rationale: a symlinked FILE is still read (it cannot recurse, and a
77
+ * manifest reached through one is a real manifest), termination is by
78
+ * construction rather than a visited-set, and an unreadable entry is `"skip"`
79
+ * rather than a throw — which is exactly what the `try`/`continue` here did.
63
80
  */
64
81
  function iterateSidecars(basePath, fn) {
65
82
  const root = (0, node_path_1.resolve)(basePath, SIDECAR_ROOT);
@@ -77,17 +94,15 @@ function walk(dir, root, basePath, fn) {
77
94
  }
78
95
  for (const entry of entries) {
79
96
  const fullPath = (0, node_path_1.resolve)(dir, entry);
80
- let isDir;
81
- try {
82
- isDir = (0, node_fs_1.statSync)(fullPath).isDirectory();
83
- }
84
- catch {
85
- continue;
86
- }
87
- if (isDir) {
97
+ const { kind } = (0, fs_walk_js_1.entryOf)(fullPath);
98
+ if (kind === "dir") {
88
99
  walk(fullPath, root, basePath, fn);
89
100
  continue;
90
101
  }
102
+ // "skip" covers what the old bare `statSync` handled by throwing (a dangling
103
+ // link, an unreadable entry) AND the symlinked directory it silently followed.
104
+ if (kind === "skip")
105
+ continue;
91
106
  if (!entry.endsWith(MANIFEST_SUFFIX))
92
107
  continue;
93
108
  // Reconstruct the target name from the path relative to .vigiles/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "15.1.0",
3
+ "version": "15.2.0",
4
4
  "description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
5
5
  "keywords": [
6
6
  "claude-code",