vigiles 25.1.0 → 26.0.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/README.md +1 -1
- package/dist/adapters/claude-code/run-scripts.d.ts +5 -2
- package/dist/adapters/claude-code/run-scripts.js +6 -3
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +157 -74
- package/dist/core/adopt.d.ts +50 -1
- package/dist/core/adopt.js +100 -6
- package/dist/core/bash-effects.d.ts +11 -0
- package/dist/core/bash-effects.js +52 -12
- package/dist/core/bash-equivalents.d.ts +18 -0
- package/dist/core/bash-equivalents.js +239 -0
- package/dist/core/coverage.d.ts +3 -3
- package/dist/core/coverage.js +10 -6
- package/dist/core/hook-program.js +48 -2
- package/dist/core/orphans.d.ts +8 -1
- package/dist/core/orphans.js +8 -2
- package/dist/core/types.d.ts +14 -6
- package/dist/exclude.d.ts +25 -0
- package/dist/exclude.js +132 -0
- package/dist/guardrail-check.d.ts +43 -24
- package/dist/guardrail-check.js +57 -0
- package/dist/test.d.ts +1 -1
- package/dist/test.js +3 -2
- package/package.json +1 -1
package/dist/core/coverage.js
CHANGED
|
@@ -36,7 +36,7 @@ function readNpmScripts(basePath) {
|
|
|
36
36
|
* Collect commands documented in specs by loading spec source files directly.
|
|
37
37
|
* Reads the structured `commands` field — no markdown parsing.
|
|
38
38
|
*/
|
|
39
|
-
function collectDocumentedCommands(basePath, specs) {
|
|
39
|
+
function collectDocumentedCommands(basePath, specs, ignore) {
|
|
40
40
|
const commands = new Set();
|
|
41
41
|
if (specs) {
|
|
42
42
|
for (const spec of specs) {
|
|
@@ -50,8 +50,12 @@ function collectDocumentedCommands(basePath, specs) {
|
|
|
50
50
|
// Fallback: scan compiled markdown for spec file references, then
|
|
51
51
|
// load the compiled JS spec from dist/. If that fails, try to
|
|
52
52
|
// extract commands from the compiled output (last resort).
|
|
53
|
+
// `ignore` is the repo's ExcludeSet string face (src/exclude.ts): the floor
|
|
54
|
+
// plus `.vigilesrc.json#exclude`, required so this fallback cannot walk a
|
|
55
|
+
// vendored corpus the repo excluded (#192). The CLI always passes `specs`,
|
|
56
|
+
// so this branch is reached by the library path and tests only.
|
|
53
57
|
const mdFiles = (0, glob_1.globSync)("**/*.md", {
|
|
54
|
-
ignore: [
|
|
58
|
+
ignore: [...ignore],
|
|
55
59
|
cwd: basePath,
|
|
56
60
|
});
|
|
57
61
|
for (const mdFile of mdFiles) {
|
|
@@ -92,9 +96,9 @@ function collectDocumentedCommands(basePath, specs) {
|
|
|
92
96
|
/**
|
|
93
97
|
* Compute script coverage: what % of npm scripts are documented in specs.
|
|
94
98
|
*/
|
|
95
|
-
function computeScriptCoverage(basePath, threshold, specs) {
|
|
99
|
+
function computeScriptCoverage(basePath, threshold, specs, ignore) {
|
|
96
100
|
const allScripts = readNpmScripts(basePath);
|
|
97
|
-
const documented = collectDocumentedCommands(basePath, specs);
|
|
101
|
+
const documented = collectDocumentedCommands(basePath, specs, ignore);
|
|
98
102
|
const covered = [];
|
|
99
103
|
const uncovered = [];
|
|
100
104
|
for (const script of allScripts) {
|
|
@@ -146,13 +150,13 @@ function computeLinterRuleCoverage(enabled, documented, threshold) {
|
|
|
146
150
|
/**
|
|
147
151
|
* Check all coverage metrics against thresholds.
|
|
148
152
|
*/
|
|
149
|
-
function checkCoverage(basePath, thresholds, linterEnabled, linterDocumented, specs) {
|
|
153
|
+
function checkCoverage(basePath, thresholds, linterEnabled, linterDocumented, specs, ignore) {
|
|
150
154
|
const metrics = [];
|
|
151
155
|
// Linter rule coverage
|
|
152
156
|
const linterMetric = computeLinterRuleCoverage(linterEnabled, linterDocumented, thresholds.linterRules);
|
|
153
157
|
metrics.push(linterMetric);
|
|
154
158
|
// Script coverage
|
|
155
|
-
const scriptMetric = computeScriptCoverage(basePath, thresholds.scripts, specs);
|
|
159
|
+
const scriptMetric = computeScriptCoverage(basePath, thresholds.scripts, specs, ignore);
|
|
156
160
|
metrics.push(scriptMetric);
|
|
157
161
|
const passing = metrics.every((m) => m.passing);
|
|
158
162
|
return { metrics, passing };
|
|
@@ -489,7 +489,48 @@ function commandView(raw, root) {
|
|
|
489
489
|
raw,
|
|
490
490
|
runs(program, opts) {
|
|
491
491
|
const tokens = program.split(/\s+/).filter(Boolean);
|
|
492
|
-
|
|
492
|
+
// 🔴 READS RAW **AND** NORMALIZED ARGV — the same union `touches` already
|
|
493
|
+
// uses, and for the same reason. This matcher read only `leaves` (raw), so
|
|
494
|
+
// three ordinary rewrites walked past a guard that blocks the plain form.
|
|
495
|
+
// MEASURED 2026-09-02 on the shipped dogfood artifact behind the public
|
|
496
|
+
// 7/7 claim (`examples/harness/safe-bash-guard.mjs`):
|
|
497
|
+
// git push --force origin main exit 2 blocked
|
|
498
|
+
// git push "--force" origin main exit 0 ALLOWED
|
|
499
|
+
// sudo git push --force origin main exit 0 ALLOWED
|
|
500
|
+
// /usr/bin/git push --force origin main exit 0 ALLOWED
|
|
501
|
+
// Across 30 shell-equivalent variants of the seven catalog seeds the guard
|
|
502
|
+
// blocked 8. The seeds were 7/7 — the number was true and measured on the
|
|
503
|
+
// only forms anyone had written down.
|
|
504
|
+
//
|
|
505
|
+
// The quoting case is the sharpest: `getLiteral` (bash-effects) returns
|
|
506
|
+
// null for a quoted word, and `leafCommands` FILTERS nulls, so
|
|
507
|
+
// `git push "--force" origin main` arrives as [git, push, origin, main] —
|
|
508
|
+
// a NON-force push. The force check was answering correctly about an argv
|
|
509
|
+
// that had already lost the flag.
|
|
510
|
+
//
|
|
511
|
+
// Force is asked of the NORMALIZED leaf via `hasFlag`, not by re-scanning
|
|
512
|
+
// strings: short clusters are already split (`-rf`→r,f) and each flag is
|
|
513
|
+
// recorded in both short and long form, so `-f` and `--force` are one
|
|
514
|
+
// question. The raw half stays because it is what an exact-literal match
|
|
515
|
+
// is for; neither half alone is enough, which is exactly what the comment
|
|
516
|
+
// on `touches` says about its own union.
|
|
517
|
+
const rawHit = leaves.some((argv) => runsSeq(argv, tokens) && (opts?.force ? hasForce(argv) : true));
|
|
518
|
+
if (rawHit)
|
|
519
|
+
return true;
|
|
520
|
+
// A FLAG named in the pattern is asked of `hasFlag`, not matched as a
|
|
521
|
+
// literal token. The normalized leaf already records every flag in BOTH
|
|
522
|
+
// short and long form, so `runs("git commit --no-verify")` also catches
|
|
523
|
+
// `git commit -n` — which the literal path cannot, because `-n` is simply
|
|
524
|
+
// a different string. Found by the generated battery on its first real
|
|
525
|
+
// run: 72/73, and the one miss was exactly this
|
|
526
|
+
// (`git commit -n -m 'skip hooks'`). The `{force:true}` option exists
|
|
527
|
+
// because force needed this for `-f`; every other flag needed it too and
|
|
528
|
+
// nobody noticed, since nobody wrote the short form down by hand.
|
|
529
|
+
const flagTokens = tokens.filter((t) => t.startsWith("-"));
|
|
530
|
+
const plainTokens = tokens.filter((t) => !t.startsWith("-"));
|
|
531
|
+
return normalized.some((leaf) => runsSeq(leaf.argv, plainTokens) &&
|
|
532
|
+
flagTokens.every((t) => leaf.hasFlag(t.replace(/^-+/, ""))) &&
|
|
533
|
+
(opts?.force ? leaf.hasFlag("force", "f") : true));
|
|
493
534
|
},
|
|
494
535
|
isSideEffecting: () => (0, bash_effects_js_1.classifyBashCommand)(raw) === "side-effecting",
|
|
495
536
|
// Both of these are DENYLIST matchers, so both break an undecidable verdict
|
|
@@ -532,7 +573,12 @@ function commandView(raw, root) {
|
|
|
532
573
|
// normalized ones, and a gate built on both had a silent hole).
|
|
533
574
|
writesTo: (prefixes) => matchedWriteTargets(prefixes).length > 0,
|
|
534
575
|
writeTargets: matchedWriteTargets,
|
|
535
|
-
|
|
576
|
+
// Same union, same reason: `curl … | /bin/sh` and `curl … | sudo sh` are a
|
|
577
|
+
// pipe-to-shell, and a raw-only check misses both (`isBareShellLeaf` tests
|
|
578
|
+
// the head literally, and the normalizer is what reduces `/bin/sh`→`sh` and
|
|
579
|
+
// strips the wrapper).
|
|
580
|
+
pipesToShell: () => leaves.some(isBareShellLeaf) ||
|
|
581
|
+
normalized.some((leaf) => isBareShellLeaf(leaf.argv)),
|
|
536
582
|
};
|
|
537
583
|
}
|
|
538
584
|
const tool = (name) => ({ tool: name });
|
package/dist/core/orphans.d.ts
CHANGED
|
@@ -32,8 +32,15 @@ export interface FindOrphansOptions {
|
|
|
32
32
|
* or to your project's doc globs (e.g. `["wiki/**\/*.md"]`) to override.
|
|
33
33
|
*/
|
|
34
34
|
readonly include?: readonly string[];
|
|
35
|
-
/** Glob patterns to exclude within the include scope. */
|
|
35
|
+
/** Glob patterns to exclude within the include scope (orphan CANDIDACY only). */
|
|
36
36
|
readonly exclude?: readonly string[];
|
|
37
|
+
/**
|
|
38
|
+
* The repo-wide `.vigilesrc.json#exclude` (the ExcludeSet string face,
|
|
39
|
+
* src/exclude.ts). Applied to BOTH walks — candidates AND the reference scan —
|
|
40
|
+
* so an excluded corpus can neither be an orphan nor keep one alive (#192).
|
|
41
|
+
* The CLI always passes it; a direct library caller may omit it.
|
|
42
|
+
*/
|
|
43
|
+
readonly repoExclude?: readonly string[];
|
|
37
44
|
/**
|
|
38
45
|
* Harnesses whose surface files (instruction file, `SKILL.md`, subagents,
|
|
39
46
|
* commands) are load-bearing by location and thus never orphan CANDIDATES
|
package/dist/core/orphans.js
CHANGED
|
@@ -153,12 +153,18 @@ function findOrphanDocs(options = {}) {
|
|
|
153
153
|
const basePath = options.basePath ?? process.cwd();
|
|
154
154
|
const include = options.include ?? DEFAULT_INCLUDE;
|
|
155
155
|
const userExclude = options.exclude ?? [];
|
|
156
|
-
|
|
156
|
+
// Two exclusions with two jobs (#192). `repoExclude` is the repo-wide
|
|
157
|
+
// `.vigilesrc.json#exclude` — a vendored corpus is neither an orphan
|
|
158
|
+
// CANDIDATE nor a SOURCE of references (a link from inside it must not keep a
|
|
159
|
+
// doc alive). The rule's own `exclude` only narrows candidacy: a doc kept out
|
|
160
|
+
// of the orphan list can still reference others. Union, never override.
|
|
161
|
+
const repoExclude = options.repoExclude ?? [];
|
|
162
|
+
const ignore = [...DEFAULT_IGNORE, ...repoExclude, ...userExclude];
|
|
157
163
|
const layouts = options.layouts ?? [];
|
|
158
164
|
const allDocs = collectDocs(basePath, include, ignore, layouts);
|
|
159
165
|
const allMarkdown = (0, glob_1.globSync)("**/*.md", {
|
|
160
166
|
cwd: basePath,
|
|
161
|
-
ignore: [...DEFAULT_IGNORE],
|
|
167
|
+
ignore: [...DEFAULT_IGNORE, ...repoExclude],
|
|
162
168
|
});
|
|
163
169
|
const referencedBy = new Map();
|
|
164
170
|
for (const mdPath of allMarkdown) {
|
package/dist/core/types.d.ts
CHANGED
|
@@ -409,12 +409,20 @@ export interface VigilesConfig {
|
|
|
409
409
|
bundles?: "root" | "all";
|
|
410
410
|
orphans?: OrphansConfig;
|
|
411
411
|
/**
|
|
412
|
-
*
|
|
413
|
-
* (tsconfig-style, relative to the
|
|
414
|
-
*
|
|
415
|
-
* `
|
|
416
|
-
*
|
|
417
|
-
*
|
|
412
|
+
* Paths and globs the repo's own tooling does NOT police — vendored corpora,
|
|
413
|
+
* benchmark fixtures, frozen reproductions (tsconfig-style, relative to the
|
|
414
|
+
* repo root; a bare directory name such as `"bench"` excludes its subtree, as
|
|
415
|
+
* do `"bench/"` and `"bench/**"`). `node_modules`/`dist`/`.git`/`.vigiles` are
|
|
416
|
+
* always excluded.
|
|
417
|
+
*
|
|
418
|
+
* ONE filter, every pass (#192): `compile` does not load an excluded spec,
|
|
419
|
+
* `lint` does not discover an excluded instruction file, nested bundle, doc, or
|
|
420
|
+
* surface, `audit` does not read an excluded instruction file, and
|
|
421
|
+
* `test`/`eval` do not discover an excluded script. It filters DISCOVERY only:
|
|
422
|
+
* a path you name on the command line is still processed, and one line says
|
|
423
|
+
* which pattern it matched. The rule-level `orphans.exclude` and
|
|
424
|
+
* `untested-*` `exclude` NARROW their own rule further and never re-admit a
|
|
425
|
+
* path excluded here (union, not override). Parsed once in `src/exclude.ts`.
|
|
418
426
|
*/
|
|
419
427
|
exclude?: readonly string[];
|
|
420
428
|
/**
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { IgnoreLike } from "glob";
|
|
2
|
+
/** Always excluded, whatever the config says. Root-relative, like `exclude`. */
|
|
3
|
+
export declare const EXCLUDE_FLOOR: readonly string[];
|
|
4
|
+
/** The parsed `.vigilesrc.json#exclude`, in the forms a walk consumes. */
|
|
5
|
+
export interface ExcludeSet {
|
|
6
|
+
/** Absolute repo root every pattern is relative to. */
|
|
7
|
+
readonly root: string;
|
|
8
|
+
/** The user's patterns, as written (for messages). */
|
|
9
|
+
readonly patterns: readonly string[];
|
|
10
|
+
/**
|
|
11
|
+
* The string-list face for a glob rooted AT `root`: the floor, then each user
|
|
12
|
+
* pattern normalized so a bare directory name excludes its subtree (`bench` →
|
|
13
|
+
* `bench`, `bench/**`), which is what "tsconfig-style" promises.
|
|
14
|
+
*/
|
|
15
|
+
readonly ignore: readonly string[];
|
|
16
|
+
/** The function face for `globSync`, correct whatever the glob's `cwd` is. */
|
|
17
|
+
readonly globIgnore: IgnoreLike;
|
|
18
|
+
/** Is this root-relative path excluded (floor or user pattern)? */
|
|
19
|
+
matches(rel: string): boolean;
|
|
20
|
+
/** The pattern that excludes this root-relative path, or null when none does. */
|
|
21
|
+
explain(rel: string): string | null;
|
|
22
|
+
}
|
|
23
|
+
/** Parse `.vigilesrc.json#exclude` once, against `root`. */
|
|
24
|
+
export declare function excludeSet(root: string, patterns: readonly string[] | undefined): ExcludeSet;
|
|
25
|
+
//# sourceMappingURL=exclude.d.ts.map
|
package/dist/exclude.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EXCLUDE_FLOOR = void 0;
|
|
4
|
+
exports.excludeSet = excludeSet;
|
|
5
|
+
/**
|
|
6
|
+
* The ONE exclusion policy for every walk that polices the user's repository.
|
|
7
|
+
*
|
|
8
|
+
* `.vigilesrc.json#exclude` is documented as "tsconfig-style" — a list of paths or
|
|
9
|
+
* globs the repo's own lint should not police (vendored corpora, benchmark
|
|
10
|
+
* fixtures, frozen reproductions). Issue #192: it was honoured by three walks,
|
|
11
|
+
* ignored by six, and the three that honoured it did not agree with each other.
|
|
12
|
+
*
|
|
13
|
+
* 🔴 TWO DIALECTS WERE ALREADY IN `main`, AND THEY DISAGREED ON `"bench"`.
|
|
14
|
+
* `glob`'s string `ignore` treats a bare directory name as a file pattern:
|
|
15
|
+
* measured 2026-09-03 on glob 13, `ignore: ["bench"]` and `["bench/"]` exclude
|
|
16
|
+
* NOTHING, only `["bench/**"]` works. The minimatch helper inside
|
|
17
|
+
* `discoverNestedBundles` accepted the bare name. tsc and ESLint both treat the
|
|
18
|
+
* bare name as the directory (measured the same day). So a user who wrote the
|
|
19
|
+
* key the way its JSDoc promised got the nested-bundle pass filtered and every
|
|
20
|
+
* glob-backed pass unfiltered — with no way to tell from the output.
|
|
21
|
+
*
|
|
22
|
+
* This module is the fix in the shape the fence-parser fix took
|
|
23
|
+
* (`core/markdown.ts`): not one WALK — the walks legitimately have different
|
|
24
|
+
* scopes — but ONE PREDICATE, parsed once from config, that every walk consumes.
|
|
25
|
+
* It offers the predicate in the two forms a walk needs and nothing else:
|
|
26
|
+
*
|
|
27
|
+
* - `globIgnore` — an `IgnoreLike` for `globSync`, keyed on the path's position
|
|
28
|
+
* RELATIVE TO THE REPO ROOT, so a glob rooted below the root (`vigiles lint
|
|
29
|
+
* some/dir`) still applies a root-relative `exclude` correctly. The string
|
|
30
|
+
* list could not: `bench/**` relative to `some/dir` matches nothing.
|
|
31
|
+
* - `ignore` — the normalized string list, for pure detectors in `core/` that
|
|
32
|
+
* take an ignore list by injection and glob from the repo root themselves.
|
|
33
|
+
* - `matches(rel)` / `explain(rel)` — for `readdirSync`-style walks and for the
|
|
34
|
+
* one line printed when an explicitly named path is processed anyway.
|
|
35
|
+
*
|
|
36
|
+
* 🔴 EVERY IN-SCOPE WALK TAKES AN `ExcludeSet` AS A REQUIRED PARAMETER. The
|
|
37
|
+
* shape that let `findSpecs` go a year without the key was
|
|
38
|
+
* `exclude: readonly string[] = []` — optional-with-default lets a call site
|
|
39
|
+
* forget, and a forgotten argument is indistinguishable from an empty config.
|
|
40
|
+
* Do not reintroduce an optional `ExcludeSet` anywhere in scope.
|
|
41
|
+
*
|
|
42
|
+
* EXPLICITLY NAMED PATHS WIN, LOUDLY. Four tools were measured (2026-09-03):
|
|
43
|
+
* ripgrep and tsc process an explicitly named ignored file silently; ESLint
|
|
44
|
+
* skips it with a warning; prettier skips it and prints "All matched files use
|
|
45
|
+
* Prettier code style!" — the silent no-op this repo has burned itself on. vigiles
|
|
46
|
+
* takes the rg/tsc semantics (an argument is an instruction) with ESLint's
|
|
47
|
+
* loudness: the path is processed and ONE line says which pattern it matched.
|
|
48
|
+
* `exclude` filters DISCOVERY, never an argument.
|
|
49
|
+
*
|
|
50
|
+
* WHAT DELIBERATELY DOES NOT GO THROUGH THIS MODULE — so the next reader does not
|
|
51
|
+
* "fix" it (the classification is issue #192's comment):
|
|
52
|
+
*
|
|
53
|
+
* - `core/compile.ts#validateGlobRef` — verifies a spec's own `glob()` reference
|
|
54
|
+
* resolves to ≥1 file. That is reference verification of the user's claim
|
|
55
|
+
* about the repo, not lint scope; excluding a dir must not make a true ref
|
|
56
|
+
* false.
|
|
57
|
+
* - `cli.ts#specReferencedElsewhere` — eject's "is this spec compiled anywhere
|
|
58
|
+
* else" safety check. Wider is safer: a target under an excluded dir is still
|
|
59
|
+
* a target that would be orphaned.
|
|
60
|
+
* - `core/validate.ts#expandGlobs` — expands a pattern the user TYPED. An
|
|
61
|
+
* argument wins (see above); it is not discovery.
|
|
62
|
+
* - Surface walks INSIDE a bundle (`plugin-loader.ts#readTree`,
|
|
63
|
+
* `skill-reachability.ts`): `exclude` applies at BUNDLE granularity via
|
|
64
|
+
* `discoverNestedBundles`; a skill inside your own `skills/` is yours.
|
|
65
|
+
* - Internal machinery that never enumerates the user's repo as lint surface:
|
|
66
|
+
* eval temp installs (`eval.ts`, `adapters/codex/eval.ts`), the eval cache and
|
|
67
|
+
* locks (`eval-cache.ts`, `eval-lock.ts`, `run-script.ts#snapshotTree`),
|
|
68
|
+
* `.vigiles/hooks/` discovery (`hook-install.ts`, a fixed dir), sidecars
|
|
69
|
+
* (`core/sidecar.ts`), linter catalogs / rulesDirs / toolchain paths
|
|
70
|
+
* (`core/linters.ts`, `core/generate-schema.ts`, `core/generate-types.ts`),
|
|
71
|
+
* `init`'s shallow adoptable-surface sweep (`cli.ts#discoverAdoptableSurfaces`)
|
|
72
|
+
* and lint-config collection (`cli.ts#safeReaddir`), and
|
|
73
|
+
* `core/generate-harness.ts` (an explicit, non-recursive dir argument).
|
|
74
|
+
*
|
|
75
|
+
* The floor (`node_modules`, `dist`, `.git`, `.vigiles`) lives here too, so a
|
|
76
|
+
* walk cannot carry its own private copy of it — the original `findSpecs` list
|
|
77
|
+
* lacked `.vigiles/**` while the three `core/` detectors had it.
|
|
78
|
+
*/
|
|
79
|
+
const minimatch_1 = require("minimatch");
|
|
80
|
+
const node_path_1 = require("node:path");
|
|
81
|
+
/** Always excluded, whatever the config says. Root-relative, like `exclude`. */
|
|
82
|
+
exports.EXCLUDE_FLOOR = [
|
|
83
|
+
"node_modules/**",
|
|
84
|
+
"dist/**",
|
|
85
|
+
".git/**",
|
|
86
|
+
".vigiles/**",
|
|
87
|
+
];
|
|
88
|
+
/** `a\b\c` → `a/b/c`, drop a leading `./`, drop a trailing `/`. */
|
|
89
|
+
function normalizeRel(rel) {
|
|
90
|
+
let r = rel
|
|
91
|
+
.split(node_path_1.sep)
|
|
92
|
+
.join("/")
|
|
93
|
+
.replace(/^(?:\.\/)+/, "");
|
|
94
|
+
while (r.endsWith("/"))
|
|
95
|
+
r = r.slice(0, -1);
|
|
96
|
+
return r;
|
|
97
|
+
}
|
|
98
|
+
/** Parse `.vigilesrc.json#exclude` once, against `root`. */
|
|
99
|
+
function excludeSet(root, patterns) {
|
|
100
|
+
const user = (patterns ?? []).map(normalizeRel).filter((p) => p !== "");
|
|
101
|
+
const all = [...exports.EXCLUDE_FLOOR, ...user];
|
|
102
|
+
// One compiled matcher pair per pattern: the pattern itself and its subtree.
|
|
103
|
+
const compiled = all.map((p) => ({
|
|
104
|
+
pattern: p,
|
|
105
|
+
self: new minimatch_1.Minimatch(p, { dot: true }),
|
|
106
|
+
subtree: new minimatch_1.Minimatch(`${p}/**`, { dot: true }),
|
|
107
|
+
}));
|
|
108
|
+
const explain = (rel) => {
|
|
109
|
+
const r = normalizeRel(rel);
|
|
110
|
+
if (r === "" || r === "." || r.startsWith("../"))
|
|
111
|
+
return null;
|
|
112
|
+
for (const c of compiled) {
|
|
113
|
+
if (c.self.match(r) || c.subtree.match(r))
|
|
114
|
+
return c.pattern;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
};
|
|
118
|
+
const matches = (rel) => explain(rel) !== null;
|
|
119
|
+
const relOf = (p) => normalizeRel((0, node_path_1.relative)(root, p.fullpath()));
|
|
120
|
+
return {
|
|
121
|
+
root,
|
|
122
|
+
patterns: user,
|
|
123
|
+
ignore: [...exports.EXCLUDE_FLOOR, ...user.flatMap((p) => [p, `${p}/**`])],
|
|
124
|
+
globIgnore: {
|
|
125
|
+
ignored: (p) => matches(relOf(p)),
|
|
126
|
+
childrenIgnored: (p) => matches(relOf(p)),
|
|
127
|
+
},
|
|
128
|
+
matches,
|
|
129
|
+
explain,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=exclude.js.map
|
|
@@ -1,27 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Guardrail verification — "prove your safety hook ACTUALLY blocks."
|
|
3
|
-
*
|
|
4
|
-
* The #1 verified Claude Code hook pain is FALSE CONFIDENCE: a developer ships a
|
|
5
|
-
* PreToolUse safety hook, believes they're protected, and finds out otherwise only
|
|
6
|
-
* when the agent force-pushes to main. The failure is silent — exit 1 instead of
|
|
7
|
-
* exit 2, the wrong JSON field, PostToolUse-can't-block, a wrong `jq` path, a missing
|
|
8
|
-
* `chmod +x` — all produce a hook that LOOKS like a guard and enforces nothing, with
|
|
9
|
-
* no error. (Crosley: "three different teams believed they had blocked force pushes";
|
|
10
|
-
* RFC #45427, closed not-planned. Full corpus: research/hook-pain-points.md.)
|
|
11
|
-
*
|
|
12
|
-
* This is the deterministic answer: feed a curated **disaster event** (`git push
|
|
13
|
-
* --force`, `rm -rf /`, `git commit --no-verify`, `cat ~/.ssh/*`, `curl … | sh`) to
|
|
14
|
-
* the hook via {@link runHook} and check the normalized decision is BLOCK. No model,
|
|
15
|
-
* no API key, runs in CI, works on a hand-written hook with NO vigiles spec — it
|
|
16
|
-
* verifies the hook's decision LOGIC, so it sidesteps CC's runtime delivery bugs
|
|
17
|
-
* (the model routing around a tool entirely, #45427 / #32376) which it deliberately
|
|
18
|
-
* does NOT claim to fix. (#34692, the old subagent-delivery gap, is fixed as of CC
|
|
19
|
-
* 2.1.241 — see src/subagent-delivery.test.ts.)
|
|
20
|
-
*
|
|
21
|
-
* Pure-ish (wraps the existing runHook tier). The catalog is harness-neutral data;
|
|
22
|
-
* the scaffold-test generator emits a test that calls these, and the same engine
|
|
23
|
-
* backs an informational coverage report.
|
|
24
|
-
*/
|
|
25
1
|
import { type RunHookOptions } from "./run-hook.js";
|
|
26
2
|
/** A category of dangerous action a guard might be meant to block. */
|
|
27
3
|
export type DisasterCategory = "destructive-git" | "destructive-fs" | "bypass-verification" | "secret-exfiltration" | "remote-code";
|
|
@@ -61,6 +37,49 @@ export interface VerifyGuardrailOptions extends RunHookOptions {
|
|
|
61
37
|
/** The PreToolUse event name to wrap each disaster in (default "PreToolUse"). */
|
|
62
38
|
readonly event?: string;
|
|
63
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The same dangerous commands, spelled the other ways a shell reads identically.
|
|
42
|
+
*
|
|
43
|
+
* Takes a battery of hook test cases (shell commands wrapped as `PreToolUse`
|
|
44
|
+
* events — `DISASTER_CATALOG` is the shipped one) and returns MORE test cases:
|
|
45
|
+
* every command re-spelled with a quoted flag (`git push "--force"`), the short
|
|
46
|
+
* form of a flag (`-f`), an absolute or escaped head (`/usr/bin/git`, `\git`), or a
|
|
47
|
+
* pass-through wrapper (`sudo …`, `env …`). The shell runs each rewrite exactly as
|
|
48
|
+
* it runs the original. Feed them to `assertBlocksDisasters` alongside the
|
|
49
|
+
* originals:
|
|
50
|
+
*
|
|
51
|
+
* assertBlocksDisasters(hook, {
|
|
52
|
+
* events: [...DISASTER_CATALOG, ...experimental_alternateSpellings(DISASTER_CATALOG)],
|
|
53
|
+
* });
|
|
54
|
+
*
|
|
55
|
+
* WHAT BREAKS WITHOUT IT. A guard whose rule is "the command contains `--force`"
|
|
56
|
+
* blocks all seven catalog commands, so the battery is green — and lets
|
|
57
|
+
* `git push "--force"` through, because the quotes make it a different string.
|
|
58
|
+
* Measured 2026-09-02 on the shipped dogfood guard BEFORE this existed: 7/7
|
|
59
|
+
* originals blocked, **8 of 30** hand-written re-spellings blocked.
|
|
60
|
+
*
|
|
61
|
+
* WHY THE OUTPUT IS TRUSTWORTHY. Nothing new is judged. "Dangerous" is inherited
|
|
62
|
+
* from the original a human put in the battery; "the same command" is decided by
|
|
63
|
+
* the shell parser vigiles already uses for `runs()`/`touches()` (see
|
|
64
|
+
* {@link sameOperation}). A rewrite that fails that check throws rather than being
|
|
65
|
+
* emitted, so the battery can never quietly shrink.
|
|
66
|
+
*
|
|
67
|
+
* It returns ONLY the rewrites (never the originals), so pass the originals
|
|
68
|
+
* alongside as above. Each rewrite keeps its original's `tool` and `category`
|
|
69
|
+
* and takes the original's id with an index suffix (`force-push~4`), so a report
|
|
70
|
+
* names which spelling got through.
|
|
71
|
+
*
|
|
72
|
+
* In promptfoo's vocabulary each rewrite rule here is a "strategy"; the difference
|
|
73
|
+
* is that promptfoo's encodings (base64, leetspeak) may or may not be decoded by the
|
|
74
|
+
* target, whereas every rewrite here is one the shell provably executes identically
|
|
75
|
+
* — a miss is a guard bug, never an ambiguous input.
|
|
76
|
+
*
|
|
77
|
+
* @experimental Days old with a single consumer (this repo's own dogfood) and no
|
|
78
|
+
* external use. The set of rewrite rules and the id-suffix shape are the parts most
|
|
79
|
+
* likely to move; the prefix says so at every call site, which an import line or a
|
|
80
|
+
* doc note cannot.
|
|
81
|
+
*/
|
|
82
|
+
export declare function experimental_alternateSpellings(events: readonly DisasterEvent[]): readonly DisasterEvent[];
|
|
64
83
|
/**
|
|
65
84
|
* Run a hook command against the disaster battery and report which events it blocks.
|
|
66
85
|
* `hookCommand` is the exact shell the hook registers (e.g. `bash hooks/guard.sh` or
|
package/dist/guardrail-check.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.DISASTER_CATALOG = void 0;
|
|
4
|
+
exports.experimental_alternateSpellings = experimental_alternateSpellings;
|
|
4
5
|
exports.verifyGuardrail = verifyGuardrail;
|
|
5
6
|
exports.unblockedDisasters = unblockedDisasters;
|
|
6
7
|
exports.assertBlocksDisasters = assertBlocksDisasters;
|
|
@@ -29,6 +30,7 @@ exports.formatGuardrailReport = formatGuardrailReport;
|
|
|
29
30
|
* the scaffold-test generator emits a test that calls these, and the same engine
|
|
30
31
|
* backs an informational coverage report.
|
|
31
32
|
*/
|
|
33
|
+
const bash_equivalents_js_1 = require("./core/bash-equivalents.js");
|
|
32
34
|
const run_hook_js_1 = require("./run-hook.js");
|
|
33
35
|
/**
|
|
34
36
|
* The curated battery. Deliberately small and high-signal: each is a textbook
|
|
@@ -99,6 +101,61 @@ function selectEvents(opts) {
|
|
|
99
101
|
}
|
|
100
102
|
return exports.DISASTER_CATALOG;
|
|
101
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* The same dangerous commands, spelled the other ways a shell reads identically.
|
|
106
|
+
*
|
|
107
|
+
* Takes a battery of hook test cases (shell commands wrapped as `PreToolUse`
|
|
108
|
+
* events — `DISASTER_CATALOG` is the shipped one) and returns MORE test cases:
|
|
109
|
+
* every command re-spelled with a quoted flag (`git push "--force"`), the short
|
|
110
|
+
* form of a flag (`-f`), an absolute or escaped head (`/usr/bin/git`, `\git`), or a
|
|
111
|
+
* pass-through wrapper (`sudo …`, `env …`). The shell runs each rewrite exactly as
|
|
112
|
+
* it runs the original. Feed them to `assertBlocksDisasters` alongside the
|
|
113
|
+
* originals:
|
|
114
|
+
*
|
|
115
|
+
* assertBlocksDisasters(hook, {
|
|
116
|
+
* events: [...DISASTER_CATALOG, ...experimental_alternateSpellings(DISASTER_CATALOG)],
|
|
117
|
+
* });
|
|
118
|
+
*
|
|
119
|
+
* WHAT BREAKS WITHOUT IT. A guard whose rule is "the command contains `--force`"
|
|
120
|
+
* blocks all seven catalog commands, so the battery is green — and lets
|
|
121
|
+
* `git push "--force"` through, because the quotes make it a different string.
|
|
122
|
+
* Measured 2026-09-02 on the shipped dogfood guard BEFORE this existed: 7/7
|
|
123
|
+
* originals blocked, **8 of 30** hand-written re-spellings blocked.
|
|
124
|
+
*
|
|
125
|
+
* WHY THE OUTPUT IS TRUSTWORTHY. Nothing new is judged. "Dangerous" is inherited
|
|
126
|
+
* from the original a human put in the battery; "the same command" is decided by
|
|
127
|
+
* the shell parser vigiles already uses for `runs()`/`touches()` (see
|
|
128
|
+
* {@link sameOperation}). A rewrite that fails that check throws rather than being
|
|
129
|
+
* emitted, so the battery can never quietly shrink.
|
|
130
|
+
*
|
|
131
|
+
* It returns ONLY the rewrites (never the originals), so pass the originals
|
|
132
|
+
* alongside as above. Each rewrite keeps its original's `tool` and `category`
|
|
133
|
+
* and takes the original's id with an index suffix (`force-push~4`), so a report
|
|
134
|
+
* names which spelling got through.
|
|
135
|
+
*
|
|
136
|
+
* In promptfoo's vocabulary each rewrite rule here is a "strategy"; the difference
|
|
137
|
+
* is that promptfoo's encodings (base64, leetspeak) may or may not be decoded by the
|
|
138
|
+
* target, whereas every rewrite here is one the shell provably executes identically
|
|
139
|
+
* — a miss is a guard bug, never an ambiguous input.
|
|
140
|
+
*
|
|
141
|
+
* @experimental Days old with a single consumer (this repo's own dogfood) and no
|
|
142
|
+
* external use. The set of rewrite rules and the id-suffix shape are the parts most
|
|
143
|
+
* likely to move; the prefix says so at every call site, which an import line or a
|
|
144
|
+
* doc note cannot.
|
|
145
|
+
*/
|
|
146
|
+
function experimental_alternateSpellings(events) {
|
|
147
|
+
return events.flatMap((event) => {
|
|
148
|
+
const command = event.input["command"];
|
|
149
|
+
if (typeof command !== "string")
|
|
150
|
+
return [];
|
|
151
|
+
return (0, bash_equivalents_js_1.equivalentCommands)(command).map((variant, i) => ({
|
|
152
|
+
...event,
|
|
153
|
+
id: `${event.id}~${String(i + 1)}`,
|
|
154
|
+
label: `${event.label} — spelled: ${variant}`,
|
|
155
|
+
input: { ...event.input, command: variant },
|
|
156
|
+
}));
|
|
157
|
+
});
|
|
158
|
+
}
|
|
102
159
|
/**
|
|
103
160
|
* Run a hook command against the disaster battery and report which events it blocks.
|
|
104
161
|
* `hookCommand` is the exact shell the hook registers (e.g. `bash hooks/guard.sh` or
|
package/dist/test.d.ts
CHANGED
|
@@ -60,7 +60,7 @@ export { experimental_emitTool, experimental_parseEmitted, experimental_assertEm
|
|
|
60
60
|
export { loadHook } from "./load-hook.js";
|
|
61
61
|
export { evalChecks, assertChecks, tool, toolWith, notTool, onlyTools, skill, output, hookFired, received, turns, wrote, didNotWrite, subagent, blocked, allowed, mcp, cost, latency, tokens, inputTokens, outputTokens, cacheTokens, } from "./check.js";
|
|
62
62
|
export type { ArgMatcher, Check, CheckJSON, CheckResult, JudgeFn, } from "./check.js";
|
|
63
|
-
export { DISASTER_CATALOG, verifyGuardrail, unblockedDisasters, assertBlocksDisasters, formatGuardrailReport, } from "./guardrail-check.js";
|
|
63
|
+
export { DISASTER_CATALOG, verifyGuardrail, unblockedDisasters, assertBlocksDisasters, formatGuardrailReport, experimental_alternateSpellings, } from "./guardrail-check.js";
|
|
64
64
|
export type { DisasterEvent, DisasterCategory, GuardrailResult, VerifyGuardrailOptions, } from "./guardrail-check.js";
|
|
65
65
|
export * from "./tool-stub.js";
|
|
66
66
|
export { runHarnessTest, runHarness, parseToolCalls, parseSubagents, parseResultEvent, parseOutput, parseHooks, decideSandbox, specTrusted, sandboxAvailable, } from "./harness-test.js";
|
package/dist/test.js
CHANGED
|
@@ -66,8 +66,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
66
66
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
67
67
|
};
|
|
68
68
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
69
|
-
exports.
|
|
70
|
-
exports.experimental_makeDockerRuntime = exports.experimental_dockerRuntime = exports.experimental_withServices = exports.experimental_startServices = exports.stubSkillBody = exports.parseClaudeRun = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.defineEval = exports.formatContainment = exports.compareContainment = exports.skillContract = exports.mustNotInclude = exports.mustInclude = exports.commandsIn = void 0;
|
|
69
|
+
exports.specTrusted = exports.decideSandbox = exports.parseHooks = exports.parseOutput = exports.parseResultEvent = exports.parseSubagents = exports.parseToolCalls = exports.runHarness = exports.runHarnessTest = exports.experimental_alternateSpellings = exports.formatGuardrailReport = exports.assertBlocksDisasters = exports.unblockedDisasters = exports.verifyGuardrail = exports.DISASTER_CATALOG = exports.cacheTokens = exports.outputTokens = exports.inputTokens = exports.tokens = exports.latency = exports.cost = exports.mcp = exports.allowed = exports.blocked = exports.subagent = exports.didNotWrite = exports.wrote = exports.turns = exports.received = exports.hookFired = exports.output = exports.skill = exports.onlyTools = exports.notTool = exports.toolWith = exports.tool = exports.assertChecks = exports.evalChecks = exports.loadHook = exports.experimental_assertEmittedOk = exports.experimental_parseEmitted = exports.experimental_emitTool = exports.egressRoutes = exports.fileToolEvents = exports.propertyHook = exports.decideHook = exports.parseHookOutput = exports.runHook = exports.runScript = exports.recordCheck = void 0;
|
|
70
|
+
exports.experimental_makeDockerRuntime = exports.experimental_dockerRuntime = exports.experimental_withServices = exports.experimental_startServices = exports.stubSkillBody = exports.parseClaudeRun = exports.formatTriggerRateReport = exports.formatEvalReport = exports.formatCheckReport = exports.checkReportToJUnit = exports.checkPromptDiversity = exports.assertPromptDiversity = exports.assertRates = exports.defineEval = exports.formatContainment = exports.compareContainment = exports.skillContract = exports.mustNotInclude = exports.mustInclude = exports.commandsIn = exports.sandboxAvailable = void 0;
|
|
71
71
|
// --- reporting: how much did this script actually do? ---
|
|
72
72
|
// `vigiles test` can otherwise see only an exit code, so a file that runs NOTHING
|
|
73
73
|
// prints the same `✓` as one that ran and passed (measured 2026-08-08 on a file
|
|
@@ -152,6 +152,7 @@ Object.defineProperty(exports, "verifyGuardrail", { enumerable: true, get: funct
|
|
|
152
152
|
Object.defineProperty(exports, "unblockedDisasters", { enumerable: true, get: function () { return guardrail_check_js_1.unblockedDisasters; } });
|
|
153
153
|
Object.defineProperty(exports, "assertBlocksDisasters", { enumerable: true, get: function () { return guardrail_check_js_1.assertBlocksDisasters; } });
|
|
154
154
|
Object.defineProperty(exports, "formatGuardrailReport", { enumerable: true, get: function () { return guardrail_check_js_1.formatGuardrailReport; } });
|
|
155
|
+
Object.defineProperty(exports, "experimental_alternateSpellings", { enumerable: true, get: function () { return guardrail_check_js_1.experimental_alternateSpellings; } });
|
|
155
156
|
// Tool stubs on PATH (rung R2): shadow a CLI tool with a recorded canned result.
|
|
156
157
|
__exportStar(require("./tool-stub.js"), exports);
|
|
157
158
|
// The assembled machine — AGNOSTIC SURFACE ONLY. The Claude-Code transport
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vigiles",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "26.0.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",
|