vigiles 9.1.0 → 11.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.
Files changed (47) hide show
  1. package/README.md +126 -112
  2. package/dist/adapters/claude-code/dialect.js +15 -0
  3. package/dist/audit-html.d.ts +15 -4
  4. package/dist/audit-html.js +15 -6
  5. package/dist/audit-report.d.ts +58 -2
  6. package/dist/audit-report.js +29 -0
  7. package/dist/audit-report.template.html +34 -24
  8. package/dist/audit-score.d.ts +19 -12
  9. package/dist/audit-score.js +79 -15
  10. package/dist/audit-serve.d.ts +109 -0
  11. package/dist/audit-serve.js +257 -0
  12. package/dist/cli.js +435 -20
  13. package/dist/core/CLAUDE.md.spec.d.ts +3 -0
  14. package/dist/core/CLAUDE.md.spec.js +26 -0
  15. package/dist/core/compile.d.ts +5 -1
  16. package/dist/core/compile.js +19 -10
  17. package/dist/core/delegation-trifecta.d.ts +64 -0
  18. package/dist/core/delegation-trifecta.js +124 -0
  19. package/dist/core/dialect.d.ts +18 -0
  20. package/dist/core/hook-block-ineffective.d.ts +62 -0
  21. package/dist/core/hook-block-ineffective.js +153 -0
  22. package/dist/core/hook-matcher.d.ts +66 -0
  23. package/dist/core/hook-matcher.js +182 -0
  24. package/dist/core/hook-normalize.d.ts +43 -0
  25. package/dist/core/hook-normalize.js +78 -0
  26. package/dist/core/lethal-trifecta.d.ts +100 -0
  27. package/dist/core/lethal-trifecta.js +197 -0
  28. package/dist/core/plugin-dir-layout.d.ts +30 -0
  29. package/dist/core/plugin-dir-layout.js +73 -0
  30. package/dist/core/rule-meta.d.ts +82 -0
  31. package/dist/core/rule-meta.js +266 -0
  32. package/dist/core/skill-missing-fence.d.ts +47 -0
  33. package/dist/core/skill-missing-fence.js +119 -0
  34. package/dist/core/skill-resources.d.ts +27 -0
  35. package/dist/core/skill-resources.js +167 -0
  36. package/dist/core/types.d.ts +71 -0
  37. package/dist/core/validate.d.ts +1 -0
  38. package/dist/core/validate.js +26 -4
  39. package/dist/leaderboard.d.ts +1 -0
  40. package/dist/leaderboard.js +64 -15
  41. package/dist/scan-behavioral.d.ts +85 -0
  42. package/dist/scan-behavioral.js +225 -0
  43. package/dist/scan.d.ts +106 -0
  44. package/dist/scan.js +269 -53
  45. package/dist/setup-plan.d.ts +6 -3
  46. package/dist/setup-plan.js +12 -2
  47. package/package.json +1 -1
@@ -3,23 +3,30 @@
3
3
  *
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
- * deterministic findings into four categories — Truthfulness, Triggering,
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:
13
- * it requires executing your hooks, which needs cross-platform confinement
14
- * that isn't shipped yet, so it lives in the `vigiles/testing` API via
15
- * `guardrail-check`/`assertBlocksDisasters`, where you opt in explicitly.)
6
+ * deterministic findings into five categories — Truthfulness, Triggering,
7
+ * Structure, Safety, Tested — each a 0–100 ring, as a DIAGNOSTIC breakdown
8
+ * beneath one headline `overall` = `100 − Σ(all graded penalties)` (the SAME
9
+ * summed model as the leaderboard's single health number, via the shared
10
+ * `computeIntegrityScore` — so the two surfaces never disagree). Same detectors,
11
+ * no re-detection (one-detector-no-drift); all deterministic, no execution.
12
+ *
13
+ * SAFETY is fed by the STATIC lethal-trifecta capability check
14
+ * (`lethalTrifectaIssues` → `report.trifectaFindings`): a unit holding all three
15
+ * capability legs is a prompt-injection exfil path detectable from the tool-SET
16
+ * alone — nothing executes, so it sidesteps the confinement blocker. A `"hard"`
17
+ * (explicit all-three) finding is GRADED into the overall; a `"advisory"`
18
+ * (inherits-all) finding is SHOWN in the ring but not graded. NB the EXECUTING
19
+ * "do your hooks actually block?" disaster-battery is STILL not an `audit` ring:
20
+ * running arbitrary hooks safely needs cross-platform confinement that isn't
21
+ * shipped yet, so the battery lives in the `vigiles/testing` API via
22
+ * `guardrail-check`/`assertBlocksDisasters`, where you opt in explicitly.
16
23
  *
17
24
  * A category that can't be assessed scores `null` (n/a) and is EXCLUDED from the
18
25
  * overall — never a false 0. Pure over the `ScanReport`, so it's fully testable.
19
26
  */
20
27
  import { type PluginScore } from "./leaderboard.js";
21
28
  import type { ScanReport } from "./scan.js";
22
- export type CategoryKey = "Truthfulness" | "Triggering" | "Structure" | "Tested";
29
+ export type CategoryKey = "Truthfulness" | "Triggering" | "Structure" | "Safety" | "Tested";
23
30
  export interface CategoryScore {
24
31
  readonly key: CategoryKey;
25
32
  /** 0–100, or `null` when the category isn't assessable (n/a — excluded from overall). */
@@ -52,7 +59,7 @@ export interface AuditScore {
52
59
  readonly empty: boolean;
53
60
  }
54
61
  /**
55
- * Bucket a scan report into the four deterministic Lighthouse categories as a
62
+ * Bucket a scan report into the five deterministic Lighthouse categories as a
56
63
  * DIAGNOSTIC breakdown, with the headline `overall` = `100 − Σ(all graded
57
64
  * penalties)` (the shared summed model — NOT the average of the rings — so it
58
65
  * equals the leaderboard's single health number). The advisory Tested ring and
@@ -7,16 +7,23 @@ exports.formatAuditScore = formatAuditScore;
7
7
  *
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
- * deterministic findings into four categories — Truthfulness, Triggering,
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:
17
- * it requires executing your hooks, which needs cross-platform confinement
18
- * that isn't shipped yet, so it lives in the `vigiles/testing` API via
19
- * `guardrail-check`/`assertBlocksDisasters`, where you opt in explicitly.)
10
+ * deterministic findings into five categories — Truthfulness, Triggering,
11
+ * Structure, Safety, Tested — each a 0–100 ring, as a DIAGNOSTIC breakdown
12
+ * beneath one headline `overall` = `100 − Σ(all graded penalties)` (the SAME
13
+ * summed model as the leaderboard's single health number, via the shared
14
+ * `computeIntegrityScore` — so the two surfaces never disagree). Same detectors,
15
+ * no re-detection (one-detector-no-drift); all deterministic, no execution.
16
+ *
17
+ * SAFETY is fed by the STATIC lethal-trifecta capability check
18
+ * (`lethalTrifectaIssues` → `report.trifectaFindings`): a unit holding all three
19
+ * capability legs is a prompt-injection exfil path detectable from the tool-SET
20
+ * alone — nothing executes, so it sidesteps the confinement blocker. A `"hard"`
21
+ * (explicit all-three) finding is GRADED into the overall; a `"advisory"`
22
+ * (inherits-all) finding is SHOWN in the ring but not graded. NB the EXECUTING
23
+ * "do your hooks actually block?" disaster-battery is STILL not an `audit` ring:
24
+ * running arbitrary hooks safely needs cross-platform confinement that isn't
25
+ * shipped yet, so the battery lives in the `vigiles/testing` API via
26
+ * `guardrail-check`/`assertBlocksDisasters`, where you opt in explicitly.
20
27
  *
21
28
  * A category that can't be assessed scores `null` (n/a) and is EXCLUDED from the
22
29
  * overall — never a false 0. Pure over the `ScanReport`, so it's fully testable.
@@ -120,13 +127,68 @@ function structure(r) {
120
127
  weight: leaderboard_js_1.W_NO_CONTRACT,
121
128
  label: "disallowedTools typo(s) that block nothing",
122
129
  },
130
+ ]);
131
+ // inherit-all (no `tools:` line) is ADVISORY, not graded: it's surfaced as a
132
+ // least-privilege NUDGE but never lowers the Structure ring. WHY: omitting the
133
+ // tool contract is a near-universal, legitimate authoring style (a measured OSS
134
+ // sweep of 122 real plugins found 109 whose only finding was this), so grading
135
+ // it would make `audit` cry wolf on idiomatic subagents. See reportDeductions.
136
+ const advisory = noContract > 0
137
+ ? [
138
+ `${String(noContract)} agent(s) inherit all tools (no contract) (advisory)`,
139
+ ]
140
+ : [];
141
+ return {
142
+ key: "Structure",
143
+ score,
144
+ weight: 1,
145
+ findings: [...findings, ...advisory],
146
+ };
147
+ }
148
+ /**
149
+ * SAFETY — fed by the STATIC lethal-trifecta check (`report.trifectaFindings`).
150
+ * A `"hard"` finding (an explicit contract naming all three capability legs) is a
151
+ * declared prompt-injection exfil path and is GRADED (`W_TRIFECTA` each, the same
152
+ * weight `reportDeductions` sums into the overall, so the ring and the headline
153
+ * agree). A `"advisory"` finding (inherits-all) is SHOWN in the ring's findings
154
+ * but NOT graded — aligned with the inherits-all-is-advisory stance.
155
+ *
156
+ * Scores `null` (n/a, excluded from the overall) when there's NO tool-bearing
157
+ * surface to assess at all — no subagents AND no model-invocable skills. A
158
+ * user-invoked skill carries no model-driven trifecta risk, so it doesn't count
159
+ * as an assessable surface. When there ARE assessable surfaces but no trifecta,
160
+ * the ring is a clean 100.
161
+ */
162
+ function safety(r) {
163
+ const modelInvocableSkills = r.skills.filter((s) => !s.userInvoked).length;
164
+ const assessable = r.agents.length + modelInvocableSkills;
165
+ if (assessable === 0) {
166
+ return {
167
+ key: "Safety",
168
+ score: null,
169
+ weight: 1,
170
+ findings: ["no tool-bearing surface to assess"],
171
+ };
172
+ }
173
+ const hard = r.trifectaFindings.filter((f) => f.finding.severity === "hard");
174
+ const { score, findings } = scoreFrom([
123
175
  {
124
- n: noContract,
125
- weight: leaderboard_js_1.W_NO_CONTRACT,
126
- label: "agent(s) inherit all tools (no contract)",
176
+ n: hard.length,
177
+ weight: leaderboard_js_1.W_TRIFECTA,
178
+ label: "unit(s) holding all three lethal-trifecta legs (prompt-injection exfil path)",
127
179
  },
128
180
  ]);
129
- return { key: "Structure", score, weight: 1, findings };
181
+ // inherits-all trifecta findings are ADVISORY: surfaced as a maximal-blast-radius
182
+ // note but never graded (mirrors the Structure inherits-all advisory).
183
+ const advisory = r.trifectaFindings
184
+ .filter((f) => f.finding.severity === "advisory")
185
+ .map((f) => `${f.name} inherits all tools — maximal trifecta blast radius (advisory)`);
186
+ return {
187
+ key: "Safety",
188
+ score,
189
+ weight: 1,
190
+ findings: [...findings, ...advisory],
191
+ };
130
192
  }
131
193
  function tested(r) {
132
194
  const { score, findings } = scoreFrom([
@@ -147,7 +209,7 @@ function isEmptyAudit(r) {
147
209
  return (0, leaderboard_js_1.isEmptyMachine)(r) && !r.instructions;
148
210
  }
149
211
  /**
150
- * Bucket a scan report into the four deterministic Lighthouse categories as a
212
+ * Bucket a scan report into the five deterministic Lighthouse categories as a
151
213
  * DIAGNOSTIC breakdown, with the headline `overall` = `100 − Σ(all graded
152
214
  * penalties)` (the shared summed model — NOT the average of the rings — so it
153
215
  * equals the leaderboard's single health number). The advisory Tested ring and
@@ -159,6 +221,7 @@ function auditScore(report) {
159
221
  "Truthfulness",
160
222
  "Triggering",
161
223
  "Structure",
224
+ "Safety",
162
225
  "Tested",
163
226
  ];
164
227
  return {
@@ -177,6 +240,7 @@ function auditScore(report) {
177
240
  truthfulness(report),
178
241
  triggering(report),
179
242
  structure(report),
243
+ safety(report),
180
244
  tested(report),
181
245
  ];
182
246
  // The headline is the SUMMED model (the shared integrity score), NOT the average
@@ -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