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.
- package/dist/core/bash-effects.d.ts +20 -0
- package/dist/core/bash-effects.js +41 -1
- package/dist/core/hook-program.d.ts +25 -0
- package/dist/core/hook-program.js +90 -6
- package/dist/core/sidecar.d.ts +16 -0
- package/dist/core/sidecar.js +23 -8
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/dist/core/sidecar.d.ts
CHANGED
|
@@ -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
|
package/dist/core/sidecar.js
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
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.
|
|
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",
|