vigiles 25.0.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/adapters/codex/eval.d.ts +18 -0
- package/dist/adapters/codex/eval.js +27 -0
- 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/eval-cache.d.ts +7 -0
- package/dist/eval-lock.d.ts +20 -0
- package/dist/eval.d.ts +117 -4
- package/dist/eval.js +164 -32
- 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/scan-behavioral.d.ts +10 -0
- package/dist/scan-behavioral.js +1 -0
- package/dist/test.d.ts +1 -1
- package/dist/test.js +3 -2
- package/package.json +1 -1
package/dist/core/adopt.d.ts
CHANGED
|
@@ -23,6 +23,12 @@ export type AdoptTier = "structured" | "raw";
|
|
|
23
23
|
export interface AdoptResult {
|
|
24
24
|
/** Generated `.spec.ts` source (compiles back to ~the original file). */
|
|
25
25
|
source: string;
|
|
26
|
+
/**
|
|
27
|
+
* Backticked paths that RESOLVED at adoption time and were emitted as verified
|
|
28
|
+
* `file()` refs. Empty when no `exists` predicate was supplied (the faithful,
|
|
29
|
+
* extract-nothing behaviour) or when nothing resolved.
|
|
30
|
+
*/
|
|
31
|
+
adoptedRefs?: readonly string[];
|
|
26
32
|
/**
|
|
27
33
|
* `structured` = a clean `##`-headed file mapped 1:1 to sections (the diff is
|
|
28
34
|
* just the canonical h1 + whitespace). `raw` = a heading-less or
|
|
@@ -48,6 +54,47 @@ export interface AdoptedSpec {
|
|
|
48
54
|
maxSectionLines?: number;
|
|
49
55
|
tier: AdoptTier;
|
|
50
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Render a section as a template, turning every backticked path that RESOLVES
|
|
59
|
+
* TODAY into a verified `${file("…")}` reference.
|
|
60
|
+
*
|
|
61
|
+
* WHY THIS EXISTS. Adoption used to transcribe faithfully and extract NOTHING —
|
|
62
|
+
* `rules: {}`, every reference left as inert prose — on the stated grounds that
|
|
63
|
+
* cross-referencing is `strengthen`'s later job. An adopter measured what that
|
|
64
|
+
* trade actually costs (2026-08-28, a 51-skill monorepo): running
|
|
65
|
+
* `vigiles init --target=CLAUDE.md` turned the file into one opaque template with
|
|
66
|
+
* zero refs extracted, so "the price is paid immediately — the file becomes a
|
|
67
|
+
* build artifact, hand edits are blocked by a hook, every backtick is escaped —
|
|
68
|
+
* while the benefit is deferred until a human rewrites every reference by hand."
|
|
69
|
+
* The checker was never the problem: he hand-wrote two `file()` calls and compile
|
|
70
|
+
* correctly reported `[stale-file]`, exit 1. The ADOPTION PATH did not populate it,
|
|
71
|
+
* so nobody reached the value and the second step never happened.
|
|
72
|
+
*
|
|
73
|
+
* WHY RESOLVE-NOW IS THE RIGHT FILTER, and not a heuristic. The undecidable
|
|
74
|
+
* question is "is this OUR path or a path inside the third-party repo this
|
|
75
|
+
* document DESCRIBES?" — the same wall that made `doc-refs` default to off after
|
|
76
|
+
* scoring 0 true positives, and that the adopter hit independently (1560
|
|
77
|
+
* path-shaped strings in his corpus, 907 unresolvable, single-digit true
|
|
78
|
+
* positives after filtering; almost all the rest were paths in repos his skills
|
|
79
|
+
* merely describe). Asking instead "does it resolve HERE, right now?" sidesteps
|
|
80
|
+
* it: a described repo's path does not exist locally, so it is never emitted.
|
|
81
|
+
*
|
|
82
|
+
* The consequences are the ones adoption needs:
|
|
83
|
+
* - ZERO new failures at adoption time — only already-green refs are emitted, so
|
|
84
|
+
* `compile` cannot start red on a file that was fine a second earlier;
|
|
85
|
+
* - value from the FIRST compile rather than after a manual pass — the ref goes
|
|
86
|
+
* red exactly when the file moves, which is the entire point of marking it;
|
|
87
|
+
* - a false emit is benign (an extra verified ref), while a false SKIP costs
|
|
88
|
+
* only what the old behaviour already cost.
|
|
89
|
+
*
|
|
90
|
+
* Fence-awareness goes through the shared `fencedLineFlags` oracle, never a
|
|
91
|
+
* private toggle: a path inside a fenced block is example code, and hand-rolled
|
|
92
|
+
* fence state is the exact defect that oracle exists to make unrepresentable.
|
|
93
|
+
*/
|
|
94
|
+
export declare function refInterpolatedTemplate(content: string, exists: (p: string) => boolean): {
|
|
95
|
+
template: string;
|
|
96
|
+
refs: string[];
|
|
97
|
+
};
|
|
51
98
|
/**
|
|
52
99
|
* Parse an instruction file's markdown into the faithful `instructionFile()` spec FIELDS.
|
|
53
100
|
* The shared core of {@link adoptMarkdown} and the round-trip tests.
|
|
@@ -62,7 +109,9 @@ export declare function adoptToSpec(markdown: string, target: string): AdoptedSp
|
|
|
62
109
|
* Convert an instruction file's markdown into a faithful `instructionFile()` spec source
|
|
63
110
|
* (the deliverable `init` writes).
|
|
64
111
|
*/
|
|
65
|
-
export declare function adoptMarkdown(markdown: string, target: string
|
|
112
|
+
export declare function adoptMarkdown(markdown: string, target: string, opts?: {
|
|
113
|
+
readonly exists?: (p: string) => boolean;
|
|
114
|
+
}): AdoptResult;
|
|
66
115
|
export interface AdoptSurfaceResult {
|
|
67
116
|
/** Generated `.spec.ts` source. */
|
|
68
117
|
source: string;
|
package/dist/core/adopt.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* `strengthen`'s separate, later job; adoption is lossless transcription.
|
|
21
21
|
*/
|
|
22
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.refInterpolatedTemplate = refInterpolatedTemplate;
|
|
23
24
|
exports.adoptToSpec = adoptToSpec;
|
|
24
25
|
exports.adoptMarkdown = adoptMarkdown;
|
|
25
26
|
exports.adoptSkill = adoptSkill;
|
|
@@ -107,28 +108,119 @@ function tsTemplate(s) {
|
|
|
107
108
|
.replace(/\$\{/g, "\\${");
|
|
108
109
|
return "`" + esc + "`";
|
|
109
110
|
}
|
|
110
|
-
|
|
111
|
+
/**
|
|
112
|
+
* A backticked token that looks like a repo-relative path: contains a slash and
|
|
113
|
+
* no whitespace. Deliberately loose — the DECIDING filter is not the shape, it is
|
|
114
|
+
* whether the path RESOLVES (see {@link refInterpolatedTemplate}).
|
|
115
|
+
*/
|
|
116
|
+
const PATH_LIKE = /`([^`\s]+)`/g;
|
|
117
|
+
/** Whether a backticked token may be adopted as a verified `file()` reference. */
|
|
118
|
+
function adoptableRef(token, exists) {
|
|
119
|
+
// A path claim, not a symbol or a command: it has a separator.
|
|
120
|
+
if (!token.includes("/"))
|
|
121
|
+
return false;
|
|
122
|
+
// Someone else's world — a URL, an absolute path, a home path, an escape.
|
|
123
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(token))
|
|
124
|
+
return false;
|
|
125
|
+
if (token.startsWith("/") || token.startsWith("~"))
|
|
126
|
+
return false;
|
|
127
|
+
if (token.includes(".."))
|
|
128
|
+
return false;
|
|
129
|
+
// 🔴 THE WHOLE DESIGN IS THIS LINE. Adoption emits a ref ONLY for a path that
|
|
130
|
+
// resolves in THIS repo right now.
|
|
131
|
+
return exists(token);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Render a section as a template, turning every backticked path that RESOLVES
|
|
135
|
+
* TODAY into a verified `${file("…")}` reference.
|
|
136
|
+
*
|
|
137
|
+
* WHY THIS EXISTS. Adoption used to transcribe faithfully and extract NOTHING —
|
|
138
|
+
* `rules: {}`, every reference left as inert prose — on the stated grounds that
|
|
139
|
+
* cross-referencing is `strengthen`'s later job. An adopter measured what that
|
|
140
|
+
* trade actually costs (2026-08-28, a 51-skill monorepo): running
|
|
141
|
+
* `vigiles init --target=CLAUDE.md` turned the file into one opaque template with
|
|
142
|
+
* zero refs extracted, so "the price is paid immediately — the file becomes a
|
|
143
|
+
* build artifact, hand edits are blocked by a hook, every backtick is escaped —
|
|
144
|
+
* while the benefit is deferred until a human rewrites every reference by hand."
|
|
145
|
+
* The checker was never the problem: he hand-wrote two `file()` calls and compile
|
|
146
|
+
* correctly reported `[stale-file]`, exit 1. The ADOPTION PATH did not populate it,
|
|
147
|
+
* so nobody reached the value and the second step never happened.
|
|
148
|
+
*
|
|
149
|
+
* WHY RESOLVE-NOW IS THE RIGHT FILTER, and not a heuristic. The undecidable
|
|
150
|
+
* question is "is this OUR path or a path inside the third-party repo this
|
|
151
|
+
* document DESCRIBES?" — the same wall that made `doc-refs` default to off after
|
|
152
|
+
* scoring 0 true positives, and that the adopter hit independently (1560
|
|
153
|
+
* path-shaped strings in his corpus, 907 unresolvable, single-digit true
|
|
154
|
+
* positives after filtering; almost all the rest were paths in repos his skills
|
|
155
|
+
* merely describe). Asking instead "does it resolve HERE, right now?" sidesteps
|
|
156
|
+
* it: a described repo's path does not exist locally, so it is never emitted.
|
|
157
|
+
*
|
|
158
|
+
* The consequences are the ones adoption needs:
|
|
159
|
+
* - ZERO new failures at adoption time — only already-green refs are emitted, so
|
|
160
|
+
* `compile` cannot start red on a file that was fine a second earlier;
|
|
161
|
+
* - value from the FIRST compile rather than after a manual pass — the ref goes
|
|
162
|
+
* red exactly when the file moves, which is the entire point of marking it;
|
|
163
|
+
* - a false emit is benign (an extra verified ref), while a false SKIP costs
|
|
164
|
+
* only what the old behaviour already cost.
|
|
165
|
+
*
|
|
166
|
+
* Fence-awareness goes through the shared `fencedLineFlags` oracle, never a
|
|
167
|
+
* private toggle: a path inside a fenced block is example code, and hand-rolled
|
|
168
|
+
* fence state is the exact defect that oracle exists to make unrepresentable.
|
|
169
|
+
*/
|
|
170
|
+
function refInterpolatedTemplate(content, exists) {
|
|
171
|
+
const fenced = (0, markdown_js_1.fencedLineFlags)(content);
|
|
172
|
+
const refs = [];
|
|
173
|
+
const rendered = content
|
|
174
|
+
.split("\n")
|
|
175
|
+
.map((line, i) => {
|
|
176
|
+
if (fenced[i])
|
|
177
|
+
return tsTemplate(line).slice(1, -1);
|
|
178
|
+
let out = "";
|
|
179
|
+
let last = 0;
|
|
180
|
+
for (const m of line.matchAll(PATH_LIKE)) {
|
|
181
|
+
const token = m[1];
|
|
182
|
+
if (!adoptableRef(token, exists))
|
|
183
|
+
continue;
|
|
184
|
+
out += tsTemplate(line.slice(last, m.index)).slice(1, -1);
|
|
185
|
+
out += "${file(" + JSON.stringify(token) + ")}";
|
|
186
|
+
refs.push(token);
|
|
187
|
+
last = m.index + m[0].length;
|
|
188
|
+
}
|
|
189
|
+
return out + tsTemplate(line.slice(last)).slice(1, -1);
|
|
190
|
+
})
|
|
191
|
+
.join("\n");
|
|
192
|
+
return { template: "`" + rendered + "`", refs };
|
|
193
|
+
}
|
|
194
|
+
function renderSpecSource(spec, exists) {
|
|
111
195
|
const targetLine = spec.target !== "CLAUDE.md"
|
|
112
196
|
? `\n target: ${JSON.stringify(spec.target)},`
|
|
113
197
|
: "";
|
|
114
198
|
const maxLine = spec.maxSectionLines !== undefined
|
|
115
199
|
? `\n maxSectionLines: ${String(spec.maxSectionLines)},`
|
|
116
200
|
: "";
|
|
201
|
+
const adopted = [];
|
|
117
202
|
const entries = Object.entries(spec.sections)
|
|
118
|
-
.map(([key, content]) =>
|
|
203
|
+
.map(([key, content]) => {
|
|
204
|
+
if (!exists)
|
|
205
|
+
return ` ${JSON.stringify(key)}: ${tsTemplate(content)},`;
|
|
206
|
+
const { template, refs } = refInterpolatedTemplate(content, exists);
|
|
207
|
+
adopted.push(...refs);
|
|
208
|
+
return ` ${JSON.stringify(key)}: ${template},`;
|
|
209
|
+
})
|
|
119
210
|
.join("\n");
|
|
120
211
|
const sectionsBlock = entries
|
|
121
212
|
? `\n sections: {\n${entries}\n },`
|
|
122
213
|
: `\n sections: {},`;
|
|
123
|
-
|
|
214
|
+
const source = `// Adopted from ${spec.target} by \`vigiles init\` — faithful by default.
|
|
124
215
|
// Each heading became a prose section; no rules were inferred. Run the
|
|
125
216
|
// \`/strengthen\` skill to upgrade prose to verified enforce()/guard() rules.
|
|
126
|
-
import { instructionFile } from "vigiles/spec";
|
|
217
|
+
import { instructionFile${adopted.length ? ", file" : ""} } from "vigiles/spec";
|
|
127
218
|
|
|
128
219
|
export default instructionFile({${targetLine}${maxLine}${sectionsBlock}
|
|
129
220
|
rules: {},
|
|
130
221
|
});
|
|
131
222
|
`;
|
|
223
|
+
return { source, refs: adopted };
|
|
132
224
|
}
|
|
133
225
|
/**
|
|
134
226
|
* Parse an instruction file's markdown into the faithful `instructionFile()` spec FIELDS.
|
|
@@ -192,12 +284,14 @@ function adoptToSpec(markdown, target) {
|
|
|
192
284
|
* Convert an instruction file's markdown into a faithful `instructionFile()` spec source
|
|
193
285
|
* (the deliverable `init` writes).
|
|
194
286
|
*/
|
|
195
|
-
function adoptMarkdown(markdown, target) {
|
|
287
|
+
function adoptMarkdown(markdown, target, opts = {}) {
|
|
196
288
|
const spec = adoptToSpec(markdown, target);
|
|
289
|
+
const { source, refs } = renderSpecSource(spec, opts.exists);
|
|
197
290
|
return {
|
|
198
|
-
source
|
|
291
|
+
source,
|
|
199
292
|
tier: spec.tier,
|
|
200
293
|
sectionCount: Object.keys(spec.sections).length,
|
|
294
|
+
adoptedRefs: refs,
|
|
201
295
|
};
|
|
202
296
|
}
|
|
203
297
|
// Consumes the WHOLE leading frontmatter block (through its closing `---` and the
|
|
@@ -133,6 +133,17 @@ export interface NormalizedLeaf {
|
|
|
133
133
|
*/
|
|
134
134
|
readonly chdir: string | null;
|
|
135
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Known short↔long flag aliases. Deliberately small and operation-relevant:
|
|
138
|
+
* a caller always gates on the head (e.g. only treats `index-url` as supply-chain
|
|
139
|
+
* when the head is `pip`), so recording both forms unconditionally is safe.
|
|
140
|
+
*/
|
|
141
|
+
/** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */
|
|
142
|
+
export declare const SHORT_TO_LONG: Readonly<Record<string, string>>;
|
|
143
|
+
/** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */
|
|
144
|
+
export declare const LONG_TO_SHORT: Readonly<Record<string, string>>;
|
|
145
|
+
/** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */
|
|
146
|
+
export declare const WRAPPER_HEADS: Set<string>;
|
|
136
147
|
/**
|
|
137
148
|
* Extract every simple command as a {@link NormalizedLeaf} — the operation-level
|
|
138
149
|
* twin of {@link leafCommands}. Same AST-backed structural coverage (a leaf nested
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* See `research/bash-effect-classification.md` for the full design rationale.
|
|
25
25
|
*/
|
|
26
26
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
exports.WRAPPER_HEADS = exports.LONG_TO_SHORT = exports.SHORT_TO_LONG = void 0;
|
|
27
28
|
exports.classifyBashCommand = classifyBashCommand;
|
|
28
29
|
exports.isReadOnlyBash = isReadOnlyBash;
|
|
29
30
|
exports.leafCommands = leafCommands;
|
|
@@ -462,13 +463,15 @@ function leafCommands(command) {
|
|
|
462
463
|
* a caller always gates on the head (e.g. only treats `index-url` as supply-chain
|
|
463
464
|
* when the head is `pip`), so recording both forms unconditionally is safe.
|
|
464
465
|
*/
|
|
465
|
-
|
|
466
|
+
/** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */
|
|
467
|
+
exports.SHORT_TO_LONG = {
|
|
466
468
|
f: "force",
|
|
467
469
|
n: "no-verify",
|
|
468
470
|
r: "recursive",
|
|
469
471
|
i: "index-url",
|
|
470
472
|
};
|
|
471
|
-
|
|
473
|
+
/** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */
|
|
474
|
+
exports.LONG_TO_SHORT = {
|
|
472
475
|
force: "f",
|
|
473
476
|
"no-verify": "n",
|
|
474
477
|
recursive: "r",
|
|
@@ -480,17 +483,22 @@ const LONG_TO_SHORT = {
|
|
|
480
483
|
* truly dynamic segment (command substitution, arithmetic, a non-HOME parameter)
|
|
481
484
|
* — such a word can't be soundly reduced to a literal operation token.
|
|
482
485
|
*/
|
|
483
|
-
function normalizeParts(parts) {
|
|
486
|
+
function normalizeParts(parts, inDoubleQuotes = false, unescape = false) {
|
|
484
487
|
if (!parts)
|
|
485
488
|
return null;
|
|
486
489
|
let out = "";
|
|
487
490
|
for (const p of parts) {
|
|
488
491
|
const t = sh.syntax.NodeType(p);
|
|
489
|
-
if (t === "Lit"
|
|
492
|
+
if (t === "Lit") {
|
|
493
|
+
out += unescape
|
|
494
|
+
? unescapeLit(p.Value ?? "", inDoubleQuotes)
|
|
495
|
+
: (p.Value ?? "");
|
|
496
|
+
}
|
|
497
|
+
else if (t === "SglQuoted") {
|
|
490
498
|
out += p.Value ?? "";
|
|
491
499
|
}
|
|
492
500
|
else if (t === "DblQuoted") {
|
|
493
|
-
const inner = normalizeParts(p.Parts);
|
|
501
|
+
const inner = normalizeParts(p.Parts, true, unescape);
|
|
494
502
|
if (inner === null)
|
|
495
503
|
return null;
|
|
496
504
|
out += inner;
|
|
@@ -508,6 +516,25 @@ function normalizeParts(parts) {
|
|
|
508
516
|
}
|
|
509
517
|
return out;
|
|
510
518
|
}
|
|
519
|
+
/**
|
|
520
|
+
* Resolve the backslashes a shell removes before it runs a word. mvdan-sh keeps
|
|
521
|
+
* them as written (`g\it` parses as `Lit("g\\it")`), yet `sh -c 'g\it --version'`
|
|
522
|
+
* runs git: outside quotes a backslash makes the next character literal and is
|
|
523
|
+
* itself dropped; inside double quotes it does so only before `$`, `` ` ``, `"`, `\\`
|
|
524
|
+
* and a newline. Without this, `g\it push --force` normalized to head `g\it` — a
|
|
525
|
+
* spelling the shell reads as the dangerous command and a `runs("git push")` guard
|
|
526
|
+
* did not (found 2026-09-02 by a reader, not by the battery, which shares this
|
|
527
|
+
* normalizer and so could not). Deliberately NOT expansion: `$VAR`, `eval`, `$(…)`
|
|
528
|
+
* and friends still return null one level up.
|
|
529
|
+
*/
|
|
530
|
+
function unescapeLit(raw, inDoubleQuotes) {
|
|
531
|
+
if (!raw.includes("\\"))
|
|
532
|
+
return raw;
|
|
533
|
+
const joined = raw.replace(/\\\n/g, ""); // `\<newline>` is a continuation: both go
|
|
534
|
+
return inDoubleQuotes
|
|
535
|
+
? joined.replace(/\\([$`"\\])/g, "$1")
|
|
536
|
+
: joined.replace(/\\(.)/g, "$1");
|
|
537
|
+
}
|
|
511
538
|
/** Normalize a command head to its basename, stripping one leading backslash. */
|
|
512
539
|
function normalizeHead(raw) {
|
|
513
540
|
const unescaped = raw.startsWith("\\") ? raw.slice(1) : raw;
|
|
@@ -521,9 +548,9 @@ function buildFlags(args) {
|
|
|
521
548
|
if (!name)
|
|
522
549
|
return;
|
|
523
550
|
flags.add(name);
|
|
524
|
-
if (name.length === 1 && SHORT_TO_LONG[name])
|
|
525
|
-
flags.add(SHORT_TO_LONG[name]);
|
|
526
|
-
const short = LONG_TO_SHORT[name];
|
|
551
|
+
if (name.length === 1 && exports.SHORT_TO_LONG[name])
|
|
552
|
+
flags.add(exports.SHORT_TO_LONG[name]);
|
|
553
|
+
const short = exports.LONG_TO_SHORT[name];
|
|
527
554
|
if (short)
|
|
528
555
|
flags.add(short);
|
|
529
556
|
};
|
|
@@ -554,7 +581,8 @@ function buildFlags(args) {
|
|
|
554
581
|
// `sudo timeout 5 rm -rf /` unwraps fully). A wrapper with no following command
|
|
555
582
|
// (bare `env`, `env -i`) is preserved as-is, so the env-dump predicate still fires.
|
|
556
583
|
// ---------------------------------------------------------------------------
|
|
557
|
-
|
|
584
|
+
/** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */
|
|
585
|
+
exports.WRAPPER_HEADS = new Set([
|
|
558
586
|
"env",
|
|
559
587
|
"command",
|
|
560
588
|
"nice",
|
|
@@ -677,7 +705,7 @@ function stripWrappers(argv) {
|
|
|
677
705
|
let cur = argv;
|
|
678
706
|
for (let guard = 0; guard < 8; guard++) {
|
|
679
707
|
const head = cur[0];
|
|
680
|
-
if (head === undefined || !WRAPPER_HEADS.has(head))
|
|
708
|
+
if (head === undefined || !exports.WRAPPER_HEADS.has(head))
|
|
681
709
|
break;
|
|
682
710
|
const valueOpts = WRAPPER_VALUE_OPTS[head] ?? new Set();
|
|
683
711
|
const chdirOpts = WRAPPER_CHDIR_OPTS[head] ?? new Set();
|
|
@@ -1069,12 +1097,24 @@ function collectAssigns(node) {
|
|
|
1069
1097
|
function normalizeCallExpr(node, redirs) {
|
|
1070
1098
|
if (sh.syntax.NodeType(node) !== "CallExpr" || !node.Args?.length)
|
|
1071
1099
|
return null;
|
|
1072
|
-
const headRaw = normalizeParts(node.Args[0]?.Parts);
|
|
1100
|
+
const headRaw = normalizeParts(node.Args[0]?.Parts, false, true);
|
|
1073
1101
|
if (headRaw === null)
|
|
1074
1102
|
return null; // dynamic head → not normalizable
|
|
1075
1103
|
const rawHead = normalizeHead(headRaw);
|
|
1104
|
+
// Backslash resolution answers "what OPERATION is this" — the head and its
|
|
1105
|
+
// FLAGS — and must not touch a PATH operand. The two need opposite answers for
|
|
1106
|
+
// the SAME bytes: `sh` reads `--fo\rce` as `--force` (so a `{force:true}` guard
|
|
1107
|
+
// that missed it was open), while `\\SERVER\SHARE\repo\SECRETS\x` is a real
|
|
1108
|
+
// Windows UNC path whose backslashes a denylist must keep, or the write it
|
|
1109
|
+
// names stops matching `secrets` and the guard allows it. A word starting `-`
|
|
1110
|
+
// is never a path, so the split is decidable per word rather than guessed.
|
|
1076
1111
|
const rawArgs = node.Args.slice(1)
|
|
1077
|
-
.map((w) =>
|
|
1112
|
+
.map((w) => {
|
|
1113
|
+
const raw = normalizeParts(w.Parts);
|
|
1114
|
+
if (raw === null || !raw.startsWith("-"))
|
|
1115
|
+
return raw;
|
|
1116
|
+
return normalizeParts(w.Parts, false, true) ?? raw;
|
|
1117
|
+
})
|
|
1078
1118
|
.filter((w) => w !== null);
|
|
1079
1119
|
// Resolve through any command-wrapper (`env`/`command`/`sudo`/`timeout`/…) so
|
|
1080
1120
|
// the leaf reflects the REAL operation, not the wrapper head.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does `variant` perform every operation `seed` performs?
|
|
3
|
+
*
|
|
4
|
+
* Not string equality and not set equality: a wrapper adds a leaf (`sudo` itself),
|
|
5
|
+
* so the test is CONTAINMENT — every dangerous leaf of the seed still appears.
|
|
6
|
+
*/
|
|
7
|
+
export declare function sameOperation(seed: string, variant: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Every shell-equivalent rewrite of `seed` the families can produce.
|
|
10
|
+
*
|
|
11
|
+
* 🔴 A candidate that does NOT satisfy {@link sameOperation} THROWS rather than
|
|
12
|
+
* being dropped. A silent drop would hide a generator bug behind a smaller
|
|
13
|
+
* corpus — the battery would quietly shrink and still look like it ran.
|
|
14
|
+
* A family that does not APPLY (no flag to quote) yields nothing, which is
|
|
15
|
+
* different from producing something wrong.
|
|
16
|
+
*/
|
|
17
|
+
export declare function equivalentCommands(seed: string): readonly string[];
|
|
18
|
+
//# sourceMappingURL=bash-equivalents.d.ts.map
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sameOperation = sameOperation;
|
|
4
|
+
exports.equivalentCommands = equivalentCommands;
|
|
5
|
+
/**
|
|
6
|
+
* Shell-EQUIVALENT rewrites of a dangerous command — the generator behind the
|
|
7
|
+
* metamorphic disaster battery.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS EXISTS. `DISASTER_CATALOG` is seven commands a human labelled
|
|
10
|
+
* dangerous, written one way each. Measured 2026-09-02: the compiled guard behind
|
|
11
|
+
* the published "2/7 → 7/7" headline blocked all seven seeds and only 8 of 30
|
|
12
|
+
* shell-equivalent rewrites of them — `git push "--force" origin main` (a quoted
|
|
13
|
+
* flag), `sudo git push --force`, `/usr/bin/git push --force`. The number was
|
|
14
|
+
* true and it was measured on the only forms anyone had written down.
|
|
15
|
+
*
|
|
16
|
+
* WHY A GENERATOR AND NOT MORE HAND-WRITTEN ROWS. Adding rows by hand closes the
|
|
17
|
+
* forms you thought of, which is the same bounded set that produced the gap. The
|
|
18
|
+
* escape space is not enumerable by memory.
|
|
19
|
+
*
|
|
20
|
+
* WHY THIS NEEDS NO ORACLE — the part that makes it sound rather than clever.
|
|
21
|
+
* Generating a NEW dangerous command would need a human to label it. This
|
|
22
|
+
* generates only REWRITES of an already-labelled one, so:
|
|
23
|
+
*
|
|
24
|
+
* dangerous ← inherited from the seed a human labelled
|
|
25
|
+
* same thing ← decided by `leafCommandsNormalized`, our own normalizer
|
|
26
|
+
*
|
|
27
|
+
* Neither half is a new judgement. This is metamorphic testing (Chen et al.,
|
|
28
|
+
* 1998): with no oracle for a fresh input, assert instead that a
|
|
29
|
+
* semantics-preserving transform does not change the verdict.
|
|
30
|
+
*
|
|
31
|
+
* WHERE IT STOPS, and why the boundary is not a policy choice. The transform
|
|
32
|
+
* families are exactly the ones the normalizer collapses. `eval`, `sh -c`, a
|
|
33
|
+
* `$VAR` head, base64 — the normalizer returns null for those, so a variant
|
|
34
|
+
* built from them CANNOT pass the self-check and is never emitted. That is the
|
|
35
|
+
* correct boundary: a guard built on `runs()` genuinely cannot see through
|
|
36
|
+
* `eval "$(echo … | base64 -d)"`, so emitting it would call a correct guard
|
|
37
|
+
* broken — the crying-wolf failure that gets a check switched off.
|
|
38
|
+
*/
|
|
39
|
+
const bash_effects_js_1 = require("./bash-effects.js");
|
|
40
|
+
/** The operation a leaf performs, ignoring how it was spelled. */
|
|
41
|
+
function operationKey(leaf) {
|
|
42
|
+
return JSON.stringify([
|
|
43
|
+
leaf.head,
|
|
44
|
+
leaf.args.filter((a) => a !== "" && !a.startsWith("-")),
|
|
45
|
+
[...leaf.flags].sort(),
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Does `variant` perform every operation `seed` performs?
|
|
50
|
+
*
|
|
51
|
+
* Not string equality and not set equality: a wrapper adds a leaf (`sudo` itself),
|
|
52
|
+
* so the test is CONTAINMENT — every dangerous leaf of the seed still appears.
|
|
53
|
+
*/
|
|
54
|
+
function sameOperation(seed, variant) {
|
|
55
|
+
const a = (0, bash_effects_js_1.leafCommandsNormalized)(seed);
|
|
56
|
+
const b = (0, bash_effects_js_1.leafCommandsNormalized)(variant);
|
|
57
|
+
if (a.length === 0 || b.length === 0)
|
|
58
|
+
return false;
|
|
59
|
+
const keys = new Set(b.map(operationKey));
|
|
60
|
+
return a.every((leaf) => keys.has(operationKey(leaf)));
|
|
61
|
+
}
|
|
62
|
+
/** Wrappers that pass a command through unchanged, one representative form each. */
|
|
63
|
+
const WRAPPER_PREFIXES = [...bash_effects_js_1.WRAPPER_HEADS]
|
|
64
|
+
.filter((w) => w !== "xargs" && w !== "nohup")
|
|
65
|
+
.map((w) => w === "timeout" ? "timeout 30" : w === "nice" ? "nice -n 5" : w);
|
|
66
|
+
const FAMILIES = [
|
|
67
|
+
{
|
|
68
|
+
name: "quoted flag",
|
|
69
|
+
// `getLiteral` returns null for a quoted word and `leafCommands` filters
|
|
70
|
+
// nulls, so this is the family that made a flag VANISH from argv.
|
|
71
|
+
rewrite: (c) => [...c.matchAll(/(?<=\s)(--?[A-Za-z][\w-]*)(?=\s|$)/g)].flatMap((m) => [
|
|
72
|
+
c.replace(m[1], `"${m[1]}"`),
|
|
73
|
+
c.replace(m[1], `'${m[1]}'`),
|
|
74
|
+
]),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "flag alias",
|
|
78
|
+
rewrite: (c) => {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const [long, short] of Object.entries(bash_effects_js_1.LONG_TO_SHORT))
|
|
81
|
+
if (c.includes(`--${long}`))
|
|
82
|
+
out.push(c.replace(`--${long}`, `-${short}`));
|
|
83
|
+
for (const [short, long] of Object.entries(bash_effects_js_1.SHORT_TO_LONG)) {
|
|
84
|
+
const re = new RegExp(`(?<=\\s)-${short}(?=\\s|$)`);
|
|
85
|
+
if (re.test(c))
|
|
86
|
+
out.push(c.replace(re, `--${long}`));
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "absolute or escaped head",
|
|
93
|
+
rewrite: (c) => {
|
|
94
|
+
const head = c.trimStart().split(/\s+/)[0];
|
|
95
|
+
if (!head || head.includes("/") || head.startsWith("\\"))
|
|
96
|
+
return [];
|
|
97
|
+
const rest = c.trimStart().slice(head.length);
|
|
98
|
+
return [
|
|
99
|
+
`/usr/bin/${head}${rest}`,
|
|
100
|
+
`/bin/${head}${rest}`,
|
|
101
|
+
`\\${head}${rest}`,
|
|
102
|
+
];
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "wrapper prefix",
|
|
107
|
+
rewrite: (c) => WRAPPER_PREFIXES.map((w) => `${w} ${c.trimStart()}`),
|
|
108
|
+
},
|
|
109
|
+
// The four families below are the shell's OWN obfuscations — what promptfoo's
|
|
110
|
+
// base64/leetspeak strategies are for a model, these are for `sh`: the shell
|
|
111
|
+
// itself decodes them, so they pass the equivalence check, and a guard that
|
|
112
|
+
// matches the source string (a grep, a substring) does not see them.
|
|
113
|
+
{
|
|
114
|
+
// `g""it`, `"git"`, `gi"t"` — a quote pair inside or around a word is removed
|
|
115
|
+
// by the shell before the word runs; a substring guard sees the quotes.
|
|
116
|
+
name: "quoted head",
|
|
117
|
+
rewrite: (c) => {
|
|
118
|
+
const { head, rest } = splitHead(c);
|
|
119
|
+
if (!head || !/^[A-Za-z][\w.-]*$/.test(head))
|
|
120
|
+
return [];
|
|
121
|
+
return [
|
|
122
|
+
`${head.slice(0, 1)}""${head.slice(1)}${rest}`,
|
|
123
|
+
`"${head}"${rest}`,
|
|
124
|
+
`${head.slice(0, -1)}"${head.slice(-1)}"${rest}`,
|
|
125
|
+
];
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
// `$'git'` — ANSI-C quoting; with no escape inside it is the plain word.
|
|
130
|
+
name: "ansi-c quoted head",
|
|
131
|
+
rewrite: (c) => {
|
|
132
|
+
const { head, rest } = splitHead(c);
|
|
133
|
+
if (!head || !/^[A-Za-z][\w.-]*$/.test(head))
|
|
134
|
+
return [];
|
|
135
|
+
return [`$'${head}'${rest}`];
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
// `g\it` — a backslash before an ordinary character is that character.
|
|
140
|
+
// Distinct from the leading `\git` in "absolute or escaped head": that one
|
|
141
|
+
// is the alias-bypass idiom people actually type; this one nobody types,
|
|
142
|
+
// which is exactly why a hand-written matcher never lists it.
|
|
143
|
+
name: "escaped character in head",
|
|
144
|
+
rewrite: (c) => {
|
|
145
|
+
const { head, rest } = splitHead(c);
|
|
146
|
+
if (!head || !/^[A-Za-z]{2,}[\w.-]*$/.test(head))
|
|
147
|
+
return [];
|
|
148
|
+
return [`${head.slice(0, 1)}\\${head.slice(1)}${rest}`];
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
// `git push --force` / `git<TAB>push<TAB>--force` — any run of blanks
|
|
153
|
+
// between words is one separator to the shell; blanks inside quotes are kept.
|
|
154
|
+
name: "whitespace between words",
|
|
155
|
+
rewrite: (c) => [joinWords(c, " "), joinWords(c, "\t")],
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
// A leading `NAME=value` is an ENVIRONMENT ASSIGNMENT scoped to this one
|
|
159
|
+
// command, not an argument to it: the shell strips the assignments and runs
|
|
160
|
+
// what follows, so `FOO=1 git push --force` IS `git push --force`. Named by
|
|
161
|
+
// an adopter (2026-08-28) as a form their own guard's tests did not cover.
|
|
162
|
+
//
|
|
163
|
+
// Our compiled guard already blocked it — `runs()` reads the parsed leaf, so
|
|
164
|
+
// the assignments were never in its way. A guard that greps the command
|
|
165
|
+
// STRING has no such luck, and that is who the battery exists for.
|
|
166
|
+
name: "env assignment prefix",
|
|
167
|
+
rewrite: (c) => {
|
|
168
|
+
const cmd = c.trimStart();
|
|
169
|
+
return [`FOO=1 ${cmd}`, `GIT_TERMINAL_PROMPT=0 LC_ALL=C ${cmd}`];
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
/** The first word of a command and everything after it, leading blanks dropped. */
|
|
174
|
+
function splitHead(cmd) {
|
|
175
|
+
const trimmed = cmd.trimStart();
|
|
176
|
+
const head = trimmed.split(/\s+/)[0] ?? "";
|
|
177
|
+
return { head, rest: trimmed.slice(head.length) };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Replace every run of blanks OUTSIDE quotes with `sep`. Quote-aware by hand
|
|
181
|
+
* because the point is to re-spell the SOURCE; a run inside `'skip hooks'` is
|
|
182
|
+
* data and must survive as written.
|
|
183
|
+
*/
|
|
184
|
+
function joinWords(cmd, sep) {
|
|
185
|
+
let out = "";
|
|
186
|
+
let quote = null;
|
|
187
|
+
let i = 0;
|
|
188
|
+
const src = cmd.trim();
|
|
189
|
+
while (i < src.length) {
|
|
190
|
+
const ch = src[i] ?? "";
|
|
191
|
+
if (quote) {
|
|
192
|
+
out += ch;
|
|
193
|
+
if (ch === quote)
|
|
194
|
+
quote = null;
|
|
195
|
+
i++;
|
|
196
|
+
}
|
|
197
|
+
else if (ch === "'" || ch === '"') {
|
|
198
|
+
quote = ch;
|
|
199
|
+
out += ch;
|
|
200
|
+
i++;
|
|
201
|
+
}
|
|
202
|
+
else if (ch === " " || ch === "\t") {
|
|
203
|
+
while (src[i] === " " || src[i] === "\t")
|
|
204
|
+
i++;
|
|
205
|
+
out += sep;
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
out += ch;
|
|
209
|
+
i++;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Every shell-equivalent rewrite of `seed` the families can produce.
|
|
216
|
+
*
|
|
217
|
+
* 🔴 A candidate that does NOT satisfy {@link sameOperation} THROWS rather than
|
|
218
|
+
* being dropped. A silent drop would hide a generator bug behind a smaller
|
|
219
|
+
* corpus — the battery would quietly shrink and still look like it ran.
|
|
220
|
+
* A family that does not APPLY (no flag to quote) yields nothing, which is
|
|
221
|
+
* different from producing something wrong.
|
|
222
|
+
*/
|
|
223
|
+
function equivalentCommands(seed) {
|
|
224
|
+
const out = new Set();
|
|
225
|
+
for (const family of FAMILIES) {
|
|
226
|
+
for (const candidate of family.rewrite(seed)) {
|
|
227
|
+
if (candidate === seed)
|
|
228
|
+
continue;
|
|
229
|
+
if (!sameOperation(seed, candidate)) {
|
|
230
|
+
throw new Error(`bash-equivalents: family "${family.name}" produced a NON-equivalent ` +
|
|
231
|
+
`rewrite of ${JSON.stringify(seed)}: ${JSON.stringify(candidate)}. ` +
|
|
232
|
+
`A variant that changes the operation would blame a correct guard.`);
|
|
233
|
+
}
|
|
234
|
+
out.add(candidate);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return [...out];
|
|
238
|
+
}
|
|
239
|
+
//# sourceMappingURL=bash-equivalents.js.map
|
package/dist/core/coverage.d.ts
CHANGED
|
@@ -35,11 +35,11 @@ export declare function readNpmScripts(basePath: string): string[];
|
|
|
35
35
|
* Collect commands documented in specs by loading spec source files directly.
|
|
36
36
|
* Reads the structured `commands` field — no markdown parsing.
|
|
37
37
|
*/
|
|
38
|
-
export declare function collectDocumentedCommands(basePath: string, specs
|
|
38
|
+
export declare function collectDocumentedCommands(basePath: string, specs: ClaudeSpec[] | undefined, ignore: readonly string[]): Set<string>;
|
|
39
39
|
/**
|
|
40
40
|
* Compute script coverage: what % of npm scripts are documented in specs.
|
|
41
41
|
*/
|
|
42
|
-
export declare function computeScriptCoverage(basePath: string, threshold
|
|
42
|
+
export declare function computeScriptCoverage(basePath: string, threshold: number | undefined, specs: ClaudeSpec[] | undefined, ignore: readonly string[]): CoverageMetric;
|
|
43
43
|
/**
|
|
44
44
|
* Compute linter rule coverage from pre-computed totals.
|
|
45
45
|
* The actual linter scanning is done by the existing discover() in cli.ts.
|
|
@@ -48,7 +48,7 @@ export declare function computeLinterRuleCoverage(enabled: number, documented: n
|
|
|
48
48
|
/**
|
|
49
49
|
* Check all coverage metrics against thresholds.
|
|
50
50
|
*/
|
|
51
|
-
export declare function checkCoverage(basePath: string, thresholds: CoverageThresholds, linterEnabled: number, linterDocumented: number, specs
|
|
51
|
+
export declare function checkCoverage(basePath: string, thresholds: CoverageThresholds, linterEnabled: number, linterDocumented: number, specs: ClaudeSpec[] | undefined, ignore: readonly string[]): CoverageReport;
|
|
52
52
|
/**
|
|
53
53
|
* Format coverage report as human-readable text.
|
|
54
54
|
*/
|