vigiles 9.0.0 → 10.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 +144 -222
- package/dist/audit-html.d.ts +15 -4
- package/dist/audit-html.js +15 -6
- package/dist/audit-report.d.ts +94 -1
- package/dist/audit-report.js +50 -0
- package/dist/audit-report.template.html +47 -22
- package/dist/audit-score.d.ts +27 -7
- package/dist/audit-score.js +63 -50
- package/dist/audit-serve.d.ts +109 -0
- package/dist/audit-serve.js +257 -0
- package/dist/cli.js +322 -25
- package/dist/core/adopt.d.ts +28 -0
- package/dist/core/adopt.js +203 -0
- package/dist/core/compile.d.ts +5 -1
- package/dist/core/compile.js +19 -10
- package/dist/leaderboard.d.ts +32 -0
- package/dist/leaderboard.js +109 -50
- package/dist/scan-behavioral.d.ts +85 -0
- package/dist/scan-behavioral.js +225 -0
- package/dist/scan.js +23 -1
- package/package.json +1 -1
package/dist/audit-score.d.ts
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
* A single structural-health number (the leaderboard's `scoreReport`) ranks
|
|
5
5
|
* plugins, but it hides WHERE a harness is weak. This buckets the SAME
|
|
6
6
|
* deterministic findings into four categories — Truthfulness, Triggering,
|
|
7
|
-
* Structure, Tested — each a 0–100 ring,
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Structure, Tested — each a 0–100 ring, as a DIAGNOSTIC breakdown beneath one
|
|
8
|
+
* headline `overall` = `100 − Σ(all graded penalties)` (the SAME summed model as
|
|
9
|
+
* the leaderboard's single health number, via the shared `computeIntegrityScore`
|
|
10
|
+
* — so the two surfaces never disagree). Same detectors, no re-detection
|
|
11
|
+
* (one-detector-no-drift); all deterministic, no execution. (Safety — "do your
|
|
12
|
+
* hooks actually block?" — is NOT an `audit` ring:
|
|
10
13
|
* it requires executing your hooks, which needs cross-platform confinement
|
|
11
14
|
* that isn't shipped yet, so it lives in the `vigiles/testing` API via
|
|
12
15
|
* `guardrail-check`/`assertBlocksDisasters`, where you opt in explicitly.)
|
|
@@ -23,11 +26,25 @@ export interface CategoryScore {
|
|
|
23
26
|
readonly score: number | null;
|
|
24
27
|
/** Relative weight in the overall (equal by default — tune later). */
|
|
25
28
|
readonly weight: number;
|
|
29
|
+
/**
|
|
30
|
+
* Advisory categories are shown but EXCLUDED from the overall grade. An untested
|
|
31
|
+
* surface (or any best-practice gap) is a HARDENING signal, not a broken harness
|
|
32
|
+
* — it must never drag the grade down, so `audit` doesn't read as F on a clean
|
|
33
|
+
* repo that simply hasn't written tests yet. The grade reflects what's BROKEN.
|
|
34
|
+
*/
|
|
35
|
+
readonly advisory?: boolean;
|
|
26
36
|
/** Human-readable deductions / notes, worst first; empty when clean. */
|
|
27
37
|
readonly findings: readonly string[];
|
|
28
38
|
}
|
|
29
39
|
export interface AuditScore {
|
|
30
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* The headline score — `100 − Σ(all graded penalties)`, clamped to [0,100]
|
|
42
|
+
* (the SAME summed model as the leaderboard's single health number, computed by
|
|
43
|
+
* the shared {@link computeIntegrityScore}, so the two surfaces never disagree).
|
|
44
|
+
* The per-category rings below are a DIAGNOSTIC breakdown, not the headline: a
|
|
45
|
+
* plugin whose only issue is Structure −30 shows Structure 70 in the breakdown
|
|
46
|
+
* AND overall 70 (averaging the rings would dilute that to ~90). 0 when empty.
|
|
47
|
+
*/
|
|
31
48
|
readonly overall: number;
|
|
32
49
|
readonly grade: PluginScore["grade"];
|
|
33
50
|
readonly categories: readonly CategoryScore[];
|
|
@@ -35,10 +52,13 @@ export interface AuditScore {
|
|
|
35
52
|
readonly empty: boolean;
|
|
36
53
|
}
|
|
37
54
|
/**
|
|
38
|
-
* Bucket a scan report into the four deterministic Lighthouse categories
|
|
39
|
-
*
|
|
55
|
+
* Bucket a scan report into the four deterministic Lighthouse categories as a
|
|
56
|
+
* DIAGNOSTIC breakdown, with the headline `overall` = `100 − Σ(all graded
|
|
57
|
+
* penalties)` (the shared summed model — NOT the average of the rings — so it
|
|
58
|
+
* equals the leaderboard's single health number). The advisory Tested ring and
|
|
59
|
+
* any n/a ring are shown but excluded from the headline.
|
|
40
60
|
*/
|
|
41
61
|
export declare function auditScore(report: ScanReport): AuditScore;
|
|
42
|
-
/** Render the category rings + the
|
|
62
|
+
/** Render the category rings (diagnostic) + the summed overall for the terminal. */
|
|
43
63
|
export declare function formatAuditScore(s: AuditScore): string;
|
|
44
64
|
//# sourceMappingURL=audit-score.d.ts.map
|
package/dist/audit-score.js
CHANGED
|
@@ -8,9 +8,12 @@ exports.formatAuditScore = formatAuditScore;
|
|
|
8
8
|
* A single structural-health number (the leaderboard's `scoreReport`) ranks
|
|
9
9
|
* plugins, but it hides WHERE a harness is weak. This buckets the SAME
|
|
10
10
|
* deterministic findings into four categories — Truthfulness, Triggering,
|
|
11
|
-
* Structure, Tested — each a 0–100 ring,
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Structure, Tested — each a 0–100 ring, as a DIAGNOSTIC breakdown beneath one
|
|
12
|
+
* headline `overall` = `100 − Σ(all graded penalties)` (the SAME summed model as
|
|
13
|
+
* the leaderboard's single health number, via the shared `computeIntegrityScore`
|
|
14
|
+
* — so the two surfaces never disagree). Same detectors, no re-detection
|
|
15
|
+
* (one-detector-no-drift); all deterministic, no execution. (Safety — "do your
|
|
16
|
+
* hooks actually block?" — is NOT an `audit` ring:
|
|
14
17
|
* it requires executing your hooks, which needs cross-platform confinement
|
|
15
18
|
* that isn't shipped yet, so it lives in the `vigiles/testing` API via
|
|
16
19
|
* `guardrail-check`/`assertBlocksDisasters`, where you opt in explicitly.)
|
|
@@ -19,13 +22,9 @@ exports.formatAuditScore = formatAuditScore;
|
|
|
19
22
|
* overall — never a false 0. Pure over the `ScanReport`, so it's fully testable.
|
|
20
23
|
*/
|
|
21
24
|
const leaderboard_js_1 = require("./leaderboard.js");
|
|
22
|
-
// Per-item penalties
|
|
23
|
-
// the single health number
|
|
24
|
-
|
|
25
|
-
const W_NO_DESCRIPTION = 10;
|
|
26
|
-
const W_DANGLING_REF = 8;
|
|
27
|
-
const W_OVERLAP = 8; // a description collision → the wrong skill fires
|
|
28
|
-
const W_NO_CONTRACT = 5;
|
|
25
|
+
// Per-item penalties are the SHARED leaderboard weights (imported above) so the
|
|
26
|
+
// category rings and the single health number can never drift. W_UNTESTED is
|
|
27
|
+
// audit-only — untested surfaces are advisory (shown, never scored into overall).
|
|
29
28
|
const W_UNTESTED = 3;
|
|
30
29
|
/** Apply deductions to a 100 base, clamped to [0,100], collecting non-zero labels. */
|
|
31
30
|
function scoreFrom(deductions) {
|
|
@@ -48,12 +47,12 @@ function truthfulness(r) {
|
|
|
48
47
|
const { score, findings } = scoreFrom([
|
|
49
48
|
{
|
|
50
49
|
n: r.danglingRefs.length,
|
|
51
|
-
weight: W_DANGLING_REF,
|
|
50
|
+
weight: leaderboard_js_1.W_DANGLING_REF,
|
|
52
51
|
label: "broken intra-plugin reference(s)",
|
|
53
52
|
},
|
|
54
53
|
{
|
|
55
54
|
n: missingHooks,
|
|
56
|
-
weight: W_MISSING_HOOK,
|
|
55
|
+
weight: leaderboard_js_1.W_MISSING_HOOK,
|
|
57
56
|
label: "hook script(s) missing (never run)",
|
|
58
57
|
},
|
|
59
58
|
]);
|
|
@@ -64,12 +63,12 @@ function triggering(r) {
|
|
|
64
63
|
const { score, findings } = scoreFrom([
|
|
65
64
|
{
|
|
66
65
|
n: noDesc,
|
|
67
|
-
weight: W_NO_DESCRIPTION,
|
|
66
|
+
weight: leaderboard_js_1.W_NO_DESCRIPTION,
|
|
68
67
|
label: "skill(s) with no usable description (can't trigger)",
|
|
69
68
|
},
|
|
70
69
|
{
|
|
71
70
|
n: r.descriptionOverlaps.length,
|
|
72
|
-
weight: W_OVERLAP,
|
|
71
|
+
weight: leaderboard_js_1.W_OVERLAP,
|
|
73
72
|
label: "near-identical skill description(s) (wrong one fires)",
|
|
74
73
|
},
|
|
75
74
|
]);
|
|
@@ -83,76 +82,89 @@ function structure(r) {
|
|
|
83
82
|
const { score, findings } = scoreFrom([
|
|
84
83
|
{
|
|
85
84
|
n: deadTools,
|
|
86
|
-
weight: W_DANGLING_REF,
|
|
85
|
+
weight: leaderboard_js_1.W_DANGLING_REF,
|
|
87
86
|
label: "agent tool(s) that don't exist (typo / never-available)",
|
|
88
87
|
},
|
|
89
88
|
{
|
|
90
89
|
n: deadMcpTools,
|
|
91
|
-
weight: W_DANGLING_REF,
|
|
90
|
+
weight: leaderboard_js_1.W_DANGLING_REF,
|
|
92
91
|
label: "agent MCP tool(s) whose server isn't declared",
|
|
93
92
|
},
|
|
94
93
|
{
|
|
95
94
|
n: r.hookEventIssues.length,
|
|
96
|
-
weight: W_MISSING_HOOK,
|
|
95
|
+
weight: leaderboard_js_1.W_MISSING_HOOK,
|
|
97
96
|
label: "hook(s) on an unknown event (never fire)",
|
|
98
97
|
},
|
|
99
98
|
{
|
|
100
99
|
n: r.mcpIssues.length,
|
|
101
|
-
weight: W_DANGLING_REF,
|
|
100
|
+
weight: leaderboard_js_1.W_DANGLING_REF,
|
|
102
101
|
label: "MCP server(s) that can't start (no command/url)",
|
|
103
102
|
},
|
|
104
103
|
{
|
|
105
104
|
n: r.mcpHookIssues.length,
|
|
106
|
-
weight: W_DANGLING_REF,
|
|
105
|
+
weight: leaderboard_js_1.W_DANGLING_REF,
|
|
107
106
|
label: "mcp_tool hook(s) incomplete / undeclared server",
|
|
108
107
|
},
|
|
109
108
|
{
|
|
110
109
|
n: r.frontmatterIssues.length,
|
|
111
|
-
weight: W_NO_DESCRIPTION,
|
|
110
|
+
weight: leaderboard_js_1.W_NO_DESCRIPTION,
|
|
112
111
|
label: "surface(s) missing required frontmatter",
|
|
113
112
|
},
|
|
114
113
|
{
|
|
115
114
|
n: r.frontmatterValueIssues.length,
|
|
116
|
-
weight: W_NO_CONTRACT,
|
|
115
|
+
weight: leaderboard_js_1.W_NO_CONTRACT,
|
|
117
116
|
label: "agent(s) with an invalid model/color (silent fallback)",
|
|
118
117
|
},
|
|
119
118
|
{
|
|
120
119
|
n: deadDisallowed,
|
|
121
|
-
weight: W_NO_CONTRACT,
|
|
120
|
+
weight: leaderboard_js_1.W_NO_CONTRACT,
|
|
122
121
|
label: "disallowedTools typo(s) that block nothing",
|
|
123
122
|
},
|
|
124
|
-
{
|
|
125
|
-
n: noContract,
|
|
126
|
-
weight: W_NO_CONTRACT,
|
|
127
|
-
label: "agent(s) inherit all tools (no contract)",
|
|
128
|
-
},
|
|
129
123
|
]);
|
|
130
|
-
|
|
124
|
+
// inherit-all (no `tools:` line) is ADVISORY, not graded: it's surfaced as a
|
|
125
|
+
// least-privilege NUDGE but never lowers the Structure ring. WHY: omitting the
|
|
126
|
+
// tool contract is a near-universal, legitimate authoring style (a measured OSS
|
|
127
|
+
// sweep of 122 real plugins found 109 whose only finding was this), so grading
|
|
128
|
+
// it would make `audit` cry wolf on idiomatic subagents. See reportDeductions.
|
|
129
|
+
const advisory = noContract > 0
|
|
130
|
+
? [
|
|
131
|
+
`${String(noContract)} agent(s) inherit all tools (no contract) (advisory)`,
|
|
132
|
+
]
|
|
133
|
+
: [];
|
|
134
|
+
return {
|
|
135
|
+
key: "Structure",
|
|
136
|
+
score,
|
|
137
|
+
weight: 1,
|
|
138
|
+
findings: [...findings, ...advisory],
|
|
139
|
+
};
|
|
131
140
|
}
|
|
132
141
|
function tested(r) {
|
|
133
142
|
const { score, findings } = scoreFrom([
|
|
134
143
|
{ n: r.untested, weight: W_UNTESTED, label: "untested surface(s)" },
|
|
135
144
|
]);
|
|
136
|
-
|
|
145
|
+
// ADVISORY: untested surfaces are a hardening gap, not breakage — shown, but
|
|
146
|
+
// excluded from the overall grade (so a clean-but-untested repo isn't graded F).
|
|
147
|
+
return { key: "Tested", score, weight: 1, advisory: true, findings };
|
|
137
148
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
// surface is the empty machine.
|
|
148
|
-
return surfaces === 0 && !r.mcp && !r.instructions;
|
|
149
|
+
/**
|
|
150
|
+
* An instruction-only repo (just a CLAUDE.md/AGENTS.md, no plugin surface) is NOT
|
|
151
|
+
* empty — the scan records `instructions` precisely so it isn't graded F/0 "no
|
|
152
|
+
* loadable surface". Only a dir with NO instruction file AND no surface is empty.
|
|
153
|
+
* (The shared `isEmptyMachine` ignores `instructions`; audit additionally treats
|
|
154
|
+
* an instruction file as a surface.)
|
|
155
|
+
*/
|
|
156
|
+
function isEmptyAudit(r) {
|
|
157
|
+
return (0, leaderboard_js_1.isEmptyMachine)(r) && !r.instructions;
|
|
149
158
|
}
|
|
150
159
|
/**
|
|
151
|
-
* Bucket a scan report into the four deterministic Lighthouse categories
|
|
152
|
-
*
|
|
160
|
+
* Bucket a scan report into the four deterministic Lighthouse categories as a
|
|
161
|
+
* DIAGNOSTIC breakdown, with the headline `overall` = `100 − Σ(all graded
|
|
162
|
+
* penalties)` (the shared summed model — NOT the average of the rings — so it
|
|
163
|
+
* equals the leaderboard's single health number). The advisory Tested ring and
|
|
164
|
+
* any n/a ring are shown but excluded from the headline.
|
|
153
165
|
*/
|
|
154
166
|
function auditScore(report) {
|
|
155
|
-
if (
|
|
167
|
+
if (isEmptyAudit(report)) {
|
|
156
168
|
const categories = [
|
|
157
169
|
"Truthfulness",
|
|
158
170
|
"Triggering",
|
|
@@ -177,11 +189,11 @@ function auditScore(report) {
|
|
|
177
189
|
structure(report),
|
|
178
190
|
tested(report),
|
|
179
191
|
];
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
192
|
+
// The headline is the SUMMED model (the shared integrity score), NOT the average
|
|
193
|
+
// of the rings — averaging would let a real problem in one category be diluted
|
|
194
|
+
// by clean siblings. The rings above stay a diagnostic breakdown; Tested
|
|
195
|
+
// (advisory) is never summed in (untested surfaces don't drag the grade).
|
|
196
|
+
const { score: overall } = (0, leaderboard_js_1.computeIntegrityScore)((0, leaderboard_js_1.reportDeductions)(report));
|
|
185
197
|
return { overall, grade: (0, leaderboard_js_1.gradeFor)(overall), categories, empty: false };
|
|
186
198
|
}
|
|
187
199
|
// A 22-cell bar gauge ("ring" in the terminal; the real rings are the HTML).
|
|
@@ -202,14 +214,15 @@ function bar(score) {
|
|
|
202
214
|
const filled = Math.round((score / 100) * BAR_CELLS);
|
|
203
215
|
return "█".repeat(filled) + "░".repeat(BAR_CELLS - filled);
|
|
204
216
|
}
|
|
205
|
-
/** Render the category rings + the
|
|
217
|
+
/** Render the category rings (diagnostic) + the summed overall for the terminal. */
|
|
206
218
|
function formatAuditScore(s) {
|
|
207
219
|
const lines = ["Harness audit", ""];
|
|
208
220
|
for (const c of s.categories) {
|
|
209
221
|
const glyph = bandGlyph(c.score);
|
|
210
222
|
const label = c.key.padEnd(13);
|
|
211
223
|
const num = (c.score === null ? "n/a" : String(c.score)).padStart(4);
|
|
212
|
-
|
|
224
|
+
const tag = c.advisory ? " · advisory (not graded)" : "";
|
|
225
|
+
lines.push(` ${glyph} ${label} ${num} ${bar(c.score)}${tag}`);
|
|
213
226
|
if (c.findings.length > 0) {
|
|
214
227
|
lines.push(` └ ${c.findings.join("; ")}`);
|
|
215
228
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** A per-run server session: the secret token + the adopt allowlist. */
|
|
2
|
+
export interface ServeSession {
|
|
3
|
+
/** Crypto-random hex; embedded in the HTML, required on every mutating POST. */
|
|
4
|
+
readonly token: string;
|
|
5
|
+
/** The loopback port the server is bound to (for the Origin check). */
|
|
6
|
+
readonly port: number;
|
|
7
|
+
/**
|
|
8
|
+
* The adoptable surfaces, keyed by their repo-relative path. A POST names a
|
|
9
|
+
* path; we resolve it HERE against this set, so an off-list path is refused.
|
|
10
|
+
*/
|
|
11
|
+
readonly surfaces: ReadonlySet<string>;
|
|
12
|
+
}
|
|
13
|
+
/** The salient, transport-agnostic fields of an incoming request. */
|
|
14
|
+
export interface RequestView {
|
|
15
|
+
readonly method: string;
|
|
16
|
+
/** The URL path (no query string). */
|
|
17
|
+
readonly path: string;
|
|
18
|
+
/** The `X-Vigiles-Token` header, if any. */
|
|
19
|
+
readonly token: string | null;
|
|
20
|
+
/** The `Origin` header, if any. */
|
|
21
|
+
readonly origin: string | null;
|
|
22
|
+
/** `body.target` for an adopt POST, if any. */
|
|
23
|
+
readonly target: string | null;
|
|
24
|
+
}
|
|
25
|
+
/** What the server should do with a request — a pure, testable verdict. */
|
|
26
|
+
export type ServeDecision = {
|
|
27
|
+
readonly kind: "report";
|
|
28
|
+
} | {
|
|
29
|
+
readonly kind: "adopt";
|
|
30
|
+
readonly target: string;
|
|
31
|
+
} | {
|
|
32
|
+
readonly kind: "adopt-all";
|
|
33
|
+
} | {
|
|
34
|
+
readonly kind: "shutdown";
|
|
35
|
+
} | {
|
|
36
|
+
readonly kind: "reject";
|
|
37
|
+
readonly status: number;
|
|
38
|
+
readonly reason: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Constant-time token comparison (avoids a timing side-channel). Returns false
|
|
42
|
+
* for a missing/short token rather than throwing.
|
|
43
|
+
*/
|
|
44
|
+
export declare function tokenOk(provided: string | null, expected: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* A mutating POST's Origin must be the loopback server itself (or absent — some
|
|
47
|
+
* same-origin fetches omit it, and the token already guards those). A foreign
|
|
48
|
+
* site's Origin never matches, so a cross-site POST is refused even before the
|
|
49
|
+
* token check.
|
|
50
|
+
*/
|
|
51
|
+
export declare function originOk(origin: string | null, port: number): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Resolve a client-supplied surface path against the allowlist. Returns the path
|
|
54
|
+
* only if it's a known adoptable surface — never trusts a raw path (no traversal).
|
|
55
|
+
*/
|
|
56
|
+
export declare function resolveSurface(target: string | null, surfaces: ReadonlySet<string>): string | null;
|
|
57
|
+
/**
|
|
58
|
+
* The pure router: given a request and the session, decide what to do. Every
|
|
59
|
+
* MUTATING route (adopt / adopt-all / shutdown) requires POST + a valid Origin +
|
|
60
|
+
* a valid token; GET / serves the report page (its body is CORS-protected, so a
|
|
61
|
+
* foreign site can't read it even if it requests it).
|
|
62
|
+
*/
|
|
63
|
+
export declare function decideServe(req: RequestView, session: ServeSession): ServeDecision;
|
|
64
|
+
/** A fresh crypto-random session token (32 hex chars = 16 bytes). */
|
|
65
|
+
export declare function newToken(): string;
|
|
66
|
+
/** Whether `audit` should start the live adoption server. */
|
|
67
|
+
export type ServeGate = "serve" | "skip" | "ask";
|
|
68
|
+
/**
|
|
69
|
+
* The pure serve-gate decision (option B). A plain `audit` stays a terminating,
|
|
70
|
+
* headless-safe read; the live server is only ever offered/started INTERACTIVELY
|
|
71
|
+
* and OWN-REPO (it writes specs — never into a stranger's dir):
|
|
72
|
+
* - `--no-serve`, a foreign repo, or `--json`/headless → skip (never serve).
|
|
73
|
+
* - `--serve` → serve (force, skip the prompt).
|
|
74
|
+
* - a TTY with adoptable surfaces → ask once ("open the live report?").
|
|
75
|
+
* - a TTY with nothing to adopt → skip (no point).
|
|
76
|
+
*/
|
|
77
|
+
export declare function decideServeGate(o: {
|
|
78
|
+
serveFlag: boolean;
|
|
79
|
+
noServeFlag: boolean;
|
|
80
|
+
json: boolean;
|
|
81
|
+
isTTY: boolean;
|
|
82
|
+
ownRepo: boolean;
|
|
83
|
+
adoptableCount: number;
|
|
84
|
+
}): ServeGate;
|
|
85
|
+
/** The outcome of running an adopt action, reported back to the report UI. */
|
|
86
|
+
export interface AdoptOutcome {
|
|
87
|
+
readonly ok: boolean;
|
|
88
|
+
readonly message: string;
|
|
89
|
+
}
|
|
90
|
+
export interface ServeOptions {
|
|
91
|
+
/** The per-run secret token (already injected into `html`). */
|
|
92
|
+
readonly token: string;
|
|
93
|
+
/** The adopt allowlist (repo-relative surface paths). */
|
|
94
|
+
readonly surfaces: ReadonlySet<string>;
|
|
95
|
+
/** The rendered report HTML (with the token already injected). */
|
|
96
|
+
readonly html: string;
|
|
97
|
+
/** Adopt ONE surface (the CLI passes a closure over `init --target=`). */
|
|
98
|
+
readonly runAdopt: (target: string) => Promise<AdoptOutcome>;
|
|
99
|
+
/** Adopt every surface (bare `init`). */
|
|
100
|
+
readonly runAdoptAll: () => Promise<AdoptOutcome>;
|
|
101
|
+
/** Called once the server is listening, with the URL to open. */
|
|
102
|
+
readonly onListening?: (url: string) => void;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Start the loopback adoption server. Resolves when the server shuts down (via
|
|
106
|
+
* the /shutdown route or SIGINT). Bound to 127.0.0.1 only.
|
|
107
|
+
*/
|
|
108
|
+
export declare function serveAudit(opts: ServeOptions): Promise<void>;
|
|
109
|
+
//# sourceMappingURL=audit-serve.d.ts.map
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.tokenOk = tokenOk;
|
|
4
|
+
exports.originOk = originOk;
|
|
5
|
+
exports.resolveSurface = resolveSurface;
|
|
6
|
+
exports.decideServe = decideServe;
|
|
7
|
+
exports.newToken = newToken;
|
|
8
|
+
exports.decideServeGate = decideServeGate;
|
|
9
|
+
exports.serveAudit = serveAudit;
|
|
10
|
+
/**
|
|
11
|
+
* `audit --serve` — the optional one-click-local adoption server.
|
|
12
|
+
*
|
|
13
|
+
* The HTML audit report is a STATIC file: a browser can't write your repo, so by
|
|
14
|
+
* default its "Create spec" buttons just COPY the `npx vigiles init …` command.
|
|
15
|
+
* `--serve` (or the TTY prompt) instead starts a tiny LOCAL server the report
|
|
16
|
+
* POSTs to, so a button click actually runs `init` for you — without ever leaving
|
|
17
|
+
* your machine.
|
|
18
|
+
*
|
|
19
|
+
* This is the only path where `audit` WRITES via a button, so it carries the
|
|
20
|
+
* standard localhost-server hardening (the Jupyter recipe — see
|
|
21
|
+
* research/audit-serve-design.md):
|
|
22
|
+
*
|
|
23
|
+
* 1. BIND 127.0.0.1 only (never 0.0.0.0) — unreachable off the machine.
|
|
24
|
+
* 2. A per-run SECRET TOKEN (crypto-random), embedded in the served HTML and
|
|
25
|
+
* REQUIRED on every mutating POST. A foreign website can't read the token
|
|
26
|
+
* (CORS blocks reading a cross-origin GET body), so it can't forge a POST —
|
|
27
|
+
* this is the primary CSRF defense.
|
|
28
|
+
* 3. ORIGIN/Host check as belt-and-suspenders: a POST's Origin must be the
|
|
29
|
+
* loopback server itself.
|
|
30
|
+
* 4. The adopt endpoint takes a surface ID from the pre-computed ALLOWLIST (the
|
|
31
|
+
* surfaces audit already discovered), never a client-supplied path — so a
|
|
32
|
+
* forged request can't traverse outside the repo.
|
|
33
|
+
* 5. The action calls `init` IN-PROCESS (an injected runner), never a shell, so
|
|
34
|
+
* there's no command injection.
|
|
35
|
+
* 6. Worst-case blast radius is tiny: `init` writes a reversible local
|
|
36
|
+
* `.spec.ts` (no exec, no network, no model); `eject` undoes it.
|
|
37
|
+
*
|
|
38
|
+
* The pure decision logic (`decideServe`, `tokenOk`, `originOk`,
|
|
39
|
+
* `resolveSurface`) is unit-tested; the http/IO shell (`serveAudit`) is the thin
|
|
40
|
+
* v8-ignored wrapper.
|
|
41
|
+
*/
|
|
42
|
+
const node_http_1 = require("node:http");
|
|
43
|
+
const node_crypto_1 = require("node:crypto");
|
|
44
|
+
/**
|
|
45
|
+
* Constant-time token comparison (avoids a timing side-channel). Returns false
|
|
46
|
+
* for a missing/short token rather than throwing.
|
|
47
|
+
*/
|
|
48
|
+
function tokenOk(provided, expected) {
|
|
49
|
+
if (!provided || provided.length !== expected.length)
|
|
50
|
+
return false;
|
|
51
|
+
const a = Buffer.from(provided);
|
|
52
|
+
const b = Buffer.from(expected);
|
|
53
|
+
// Lengths are equal here, so timingSafeEqual is safe to call.
|
|
54
|
+
return (0, node_crypto_1.timingSafeEqual)(a, b);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A mutating POST's Origin must be the loopback server itself (or absent — some
|
|
58
|
+
* same-origin fetches omit it, and the token already guards those). A foreign
|
|
59
|
+
* site's Origin never matches, so a cross-site POST is refused even before the
|
|
60
|
+
* token check.
|
|
61
|
+
*/
|
|
62
|
+
function originOk(origin, port) {
|
|
63
|
+
if (origin === null)
|
|
64
|
+
return true; // rely on the token (a foreign site can't have it)
|
|
65
|
+
return (origin === `http://127.0.0.1:${String(port)}` ||
|
|
66
|
+
origin === `http://localhost:${String(port)}`);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Resolve a client-supplied surface path against the allowlist. Returns the path
|
|
70
|
+
* only if it's a known adoptable surface — never trusts a raw path (no traversal).
|
|
71
|
+
*/
|
|
72
|
+
function resolveSurface(target, surfaces) {
|
|
73
|
+
if (!target)
|
|
74
|
+
return null;
|
|
75
|
+
return surfaces.has(target) ? target : null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The pure router: given a request and the session, decide what to do. Every
|
|
79
|
+
* MUTATING route (adopt / adopt-all / shutdown) requires POST + a valid Origin +
|
|
80
|
+
* a valid token; GET / serves the report page (its body is CORS-protected, so a
|
|
81
|
+
* foreign site can't read it even if it requests it).
|
|
82
|
+
*/
|
|
83
|
+
function decideServe(req, session) {
|
|
84
|
+
if (req.method === "GET" &&
|
|
85
|
+
(req.path === "/" || req.path === "/index.html")) {
|
|
86
|
+
return { kind: "report" };
|
|
87
|
+
}
|
|
88
|
+
const mutating = req.path === "/adopt" ||
|
|
89
|
+
req.path === "/adopt-all" ||
|
|
90
|
+
req.path === "/shutdown";
|
|
91
|
+
if (!mutating) {
|
|
92
|
+
return { kind: "reject", status: 404, reason: "not found" };
|
|
93
|
+
}
|
|
94
|
+
if (req.method !== "POST") {
|
|
95
|
+
return { kind: "reject", status: 405, reason: "method not allowed" };
|
|
96
|
+
}
|
|
97
|
+
if (!originOk(req.origin, session.port)) {
|
|
98
|
+
return { kind: "reject", status: 403, reason: "bad origin" };
|
|
99
|
+
}
|
|
100
|
+
if (!tokenOk(req.token, session.token)) {
|
|
101
|
+
return { kind: "reject", status: 403, reason: "bad or missing token" };
|
|
102
|
+
}
|
|
103
|
+
if (req.path === "/shutdown")
|
|
104
|
+
return { kind: "shutdown" };
|
|
105
|
+
if (req.path === "/adopt-all")
|
|
106
|
+
return { kind: "adopt-all" };
|
|
107
|
+
const target = resolveSurface(req.target, session.surfaces);
|
|
108
|
+
if (!target) {
|
|
109
|
+
return { kind: "reject", status: 400, reason: "unknown surface" };
|
|
110
|
+
}
|
|
111
|
+
return { kind: "adopt", target };
|
|
112
|
+
}
|
|
113
|
+
/** A fresh crypto-random session token (32 hex chars = 16 bytes). */
|
|
114
|
+
function newToken() {
|
|
115
|
+
return (0, node_crypto_1.randomBytes)(16).toString("hex");
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The pure serve-gate decision (option B). A plain `audit` stays a terminating,
|
|
119
|
+
* headless-safe read; the live server is only ever offered/started INTERACTIVELY
|
|
120
|
+
* and OWN-REPO (it writes specs — never into a stranger's dir):
|
|
121
|
+
* - `--no-serve`, a foreign repo, or `--json`/headless → skip (never serve).
|
|
122
|
+
* - `--serve` → serve (force, skip the prompt).
|
|
123
|
+
* - a TTY with adoptable surfaces → ask once ("open the live report?").
|
|
124
|
+
* - a TTY with nothing to adopt → skip (no point).
|
|
125
|
+
*/
|
|
126
|
+
function decideServeGate(o) {
|
|
127
|
+
if (o.noServeFlag)
|
|
128
|
+
return "skip";
|
|
129
|
+
if (!o.ownRepo)
|
|
130
|
+
return "skip"; // serve writes specs → own repo only
|
|
131
|
+
if (o.serveFlag)
|
|
132
|
+
return "serve";
|
|
133
|
+
if (o.json || !o.isTTY)
|
|
134
|
+
return "skip"; // headless never serves
|
|
135
|
+
if (o.adoptableCount === 0)
|
|
136
|
+
return "skip"; // nothing to adopt
|
|
137
|
+
return "ask";
|
|
138
|
+
}
|
|
139
|
+
/* v8 ignore start — the http/IO shell; the decision logic above is unit-tested. */
|
|
140
|
+
/** Read a request body to a string, capped to avoid an unbounded read. */
|
|
141
|
+
async function readBody(req) {
|
|
142
|
+
const chunks = [];
|
|
143
|
+
let size = 0;
|
|
144
|
+
for await (const chunk of req) {
|
|
145
|
+
size += chunk.length;
|
|
146
|
+
if (size > 64 * 1024)
|
|
147
|
+
break; // an adopt POST is tiny; cap defensively
|
|
148
|
+
chunks.push(chunk);
|
|
149
|
+
}
|
|
150
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
151
|
+
}
|
|
152
|
+
function viewOf(req, body) {
|
|
153
|
+
const path = (req.url ?? "/").split("?")[0];
|
|
154
|
+
let target = null;
|
|
155
|
+
try {
|
|
156
|
+
if (body)
|
|
157
|
+
target = JSON.parse(body).target ?? null;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
target = null;
|
|
161
|
+
}
|
|
162
|
+
const header = (n) => {
|
|
163
|
+
const v = req.headers[n];
|
|
164
|
+
return typeof v === "string" ? v : null;
|
|
165
|
+
};
|
|
166
|
+
return {
|
|
167
|
+
method: req.method ?? "GET",
|
|
168
|
+
path,
|
|
169
|
+
token: header("x-vigiles-token"),
|
|
170
|
+
origin: header("origin"),
|
|
171
|
+
target,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function sendJson(res, status, body) {
|
|
175
|
+
const payload = JSON.stringify(body);
|
|
176
|
+
res.writeHead(status, {
|
|
177
|
+
"content-type": "application/json",
|
|
178
|
+
// No CORS headers: same-origin only. A cross-origin site can fire a request
|
|
179
|
+
// but cannot read this response — and can't forge the token anyway.
|
|
180
|
+
"x-content-type-options": "nosniff",
|
|
181
|
+
});
|
|
182
|
+
res.end(payload);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Start the loopback adoption server. Resolves when the server shuts down (via
|
|
186
|
+
* the /shutdown route or SIGINT). Bound to 127.0.0.1 only.
|
|
187
|
+
*/
|
|
188
|
+
async function serveAudit(opts) {
|
|
189
|
+
const { token, surfaces, html, runAdopt, runAdoptAll, onListening } = opts;
|
|
190
|
+
// The bound port is known only after listen(); the request handler reads it via
|
|
191
|
+
// this closure. No request can arrive before the server is listening, so the
|
|
192
|
+
// late assignment is race-free.
|
|
193
|
+
let session = { token, port: 0, surfaces };
|
|
194
|
+
await new Promise((resolveServer) => {
|
|
195
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
196
|
+
void (async () => {
|
|
197
|
+
const body = req.method === "POST" ? await readBody(req) : "";
|
|
198
|
+
const decision = decideServe(viewOf(req, body), session);
|
|
199
|
+
switch (decision.kind) {
|
|
200
|
+
case "report":
|
|
201
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
202
|
+
res.end(html);
|
|
203
|
+
return;
|
|
204
|
+
case "adopt": {
|
|
205
|
+
const out = await runAdopt(decision.target);
|
|
206
|
+
sendJson(res, out.ok ? 200 : 500, out);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
case "adopt-all": {
|
|
210
|
+
const out = await runAdoptAll();
|
|
211
|
+
sendJson(res, out.ok ? 200 : 500, out);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
case "shutdown":
|
|
215
|
+
sendJson(res, 200, { ok: true, message: "shutting down" });
|
|
216
|
+
server.close(() => {
|
|
217
|
+
resolveServer();
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
case "reject":
|
|
221
|
+
sendJson(res, decision.status, {
|
|
222
|
+
ok: false,
|
|
223
|
+
message: decision.reason,
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
})().catch(() => {
|
|
228
|
+
try {
|
|
229
|
+
sendJson(res, 500, { ok: false, message: "internal error" });
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
/* response already sent */
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
server.on("error", () => {
|
|
237
|
+
resolveServer();
|
|
238
|
+
});
|
|
239
|
+
// 127.0.0.1 ONLY — never 0.0.0.0; the server is unreachable off the machine.
|
|
240
|
+
// Port 0 → the OS assigns an ephemeral port; we learn it after binding.
|
|
241
|
+
server.listen(0, "127.0.0.1", () => {
|
|
242
|
+
const addr = server.address();
|
|
243
|
+
const port = addr && typeof addr === "object" ? addr.port : 0;
|
|
244
|
+
session = { token, port, surfaces };
|
|
245
|
+
onListening?.(`http://127.0.0.1:${String(port)}/?token=${token}`);
|
|
246
|
+
});
|
|
247
|
+
const stop = () => {
|
|
248
|
+
server.close(() => {
|
|
249
|
+
resolveServer();
|
|
250
|
+
});
|
|
251
|
+
};
|
|
252
|
+
process.once("SIGINT", stop);
|
|
253
|
+
process.once("SIGTERM", stop);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/* v8 ignore stop */
|
|
257
|
+
//# sourceMappingURL=audit-serve.js.map
|