sparda-mcp 0.68.0 → 0.69.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 CHANGED
@@ -21,14 +21,14 @@
21
21
  ![runtime deps](https://img.shields.io/badge/runtime%20deps-4%20pinned-4c1)
22
22
  [![license](https://img.shields.io/badge/license-BUSL--1.1-blue)](./LICENSE)
23
23
 
24
- 100% local · deterministic · zero API key · no cloud account. It fails loudly on a real risk, and when it can only see part of your app it says **PROVEN (PARTIAL)** — never a false green.
24
+ 100% local · deterministic · zero API key · no cloud account. It fails loudly on a real risk, and when it can only see part of your app it says **PROVEN (PARTIAL)** — never a false green. And when it can prove it was not even looking at your whole app, it says **PREMISE NOT VERIFIED** and claims nothing at all.
25
25
 
26
26
  ## 60-second proof
27
27
 
28
28
  From your Express, FastAPI, Flask, Next.js, NestJS or Medusa app — nothing to configure:
29
29
 
30
30
  ```bash
31
- npx sparda-mcp apocalypse # prove the tree is safe to deploy — exit 1 on any real risk
31
+ npx sparda-mcp apocalypse # prove the tree is safe to deploy — exit 1 on any real risk, or on an unverified premise
32
32
  npx sparda-mcp prove # the whole verdict: proof + coverage + shareable seal
33
33
  npx sparda-mcp badge # a README badge: proven · coverage% · routes
34
34
  ```
@@ -68,7 +68,7 @@ npm run wedge # (from a clone) — or drive it on your own app with `sparda
68
68
 
69
69
  | Command | What it does |
70
70
  | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
71
- | **`prove`** | _The whole trust verdict in one gesture_ — proof + coverage + a shareable seal (`--json` / `--markdown`) |
71
+ | **`prove`** | _The whole trust verdict in one gesture_ — proof + coverage + premise check + a shareable seal (`--json` / `--markdown`) |
72
72
  | **`apocalypse`** | _Prove the deploy_ — no guard, invariant, transaction or aggregate boundary can be broken (SARIF + CI gate) |
73
73
  | **`heal`** | _Self-heal, **proven**_ — the gate Copilot Autofix doesn't have: a fix ships **only if** replay matches, `verify` still passes, and `apocalypse` finds no new risk / no dropped guard. Whoever wrote the fix, the machine judges it. |
74
74
  | **`badge`** | _The shareable artifact_ — a self-contained SVG badge + README snippet (verdict · coverage · routes) |
@@ -163,6 +163,8 @@ This command reads the compiled `.sparda/ubg.json` (with zero source code parsin
163
163
  - **Non-Atomic Aggregate Write (High)**: Flags when an API writes to multiple tables of the same Consistency Domain (Aggregate) outside a single transaction scope.
164
164
  - **Unvalidated Constrained Write (Medium)**: Flags writes into columns with declared invariants (CHECK, NOT NULL, UNIQUE — parsed from your `.sql` DDL **or `schema.prisma`**, Prisma enums included) without prior validation (Zod/Pydantic).
165
165
  - **Irreversible Observable Effect (High)**: Flags out-of-process actions (like Stripe charges) that happen alongside state writes without a structural compensation path (like a catch-refund).
166
+ - **Taint Flow Analysis (High)**: Tracks untrusted input variables through the AST to ensure they do not corrupt critical sinks.
167
+ - **Guard Dominance (Medium)**: Proves that top-level security guards cannot be bypassed by nested or overlapping sibling routes.
166
168
  - **Aggregate Member Bypass (Info)**: Flags mutating a member table directly without routing through the aggregate root.
167
169
 
168
170
  To save your current graph as a safe baseline:
@@ -361,7 +363,8 @@ runtime, so the guidance never goes stale.
361
363
  ## Supported frameworks
362
364
 
363
365
  - **Next.js App Router (13/14/15)** — file-based injection. SPARDA creates a catch-all route handler. It natively resolves wrapped handlers (`export const POST = withAuth(h)`) and deep effect chains.
364
- - **NestJS** — AST-based router injection. Deeply resolves Multi-hop Dependency Injection (Controller → Service → Repository), inherited DI, and `baseUrl`/`paths` imports. Resolves ORM writes: Prisma, Kysely, and TypeORM injected repositories (`@InjectRepository(Entity)` → `this.repo.save()`).
366
+ - **NestJS** — AST-based router injection. Deeply resolves Multi-hop Dependency Injection (Controller → Service → Repository), inherited DI, and `baseUrl`/`paths` imports. Fully supports composite decorators (`applyDecorators`). Resolves ORM writes: Prisma, Kysely, and TypeORM injected repositories (`@InjectRepository(Entity)` → `this.repo.save()`).
367
+ - **Strapi** — Native AST ingestion of Strapi content-types, core controllers, and custom routes.
365
368
  - **Express 4/5** (JS/TS, ESM/CJS) — AST-based router injection. Deeply resolves external controllers, Mongoose schemas, barrel re-exports, and inline handlers. Uses dynamic tree-scanning to find non-standard entry points (`bootstrap.ts`, etc).
366
369
  - **MedusaJS** — Native AST ingestion of complex e-commerce routing.
367
370
  - **Any Backend On Earth (Go, Java, Rails, Laravel)** — Compiles flawlessly from OpenAPI 3.x specs.
@@ -1 +1 @@
1
- {"v":"imm1","proven":false,"surfaceOnly":true,"coverage":1,"blindHigh":0,"routes":[{"behaviorHash":"bh1_13969839d8a18d54aa0342618e56db6a","pol":121,"exposed":[]},{"behaviorHash":"bh1_2daf8a2b1b0ed393d443991a2b0700f6","pol":121,"exposed":[]},{"behaviorHash":"bh1_58fbf5ac38b6d71abc7204921de6b957","pol":121,"exposed":[]},{"behaviorHash":"bh1_58fbf5ac38b6d71abc7204921de6b957","pol":121,"exposed":[]},{"behaviorHash":"bh1_cc6788289c612af85b8d215b5bdf9b28","pol":121,"exposed":[]}],"posture":{"auth":{"protected":0,"exposed":0,"na":5},"atomicity":{"protected":0,"exposed":0,"na":5},"reversibility":{"protected":0,"exposed":0,"na":5},"validation":{"protected":0,"exposed":0,"na":5},"aggregate":{"protected":0,"exposed":0,"na":5}},"bytes":5}
1
+ {"v":"imm1","proven":false,"surfaceOnly":true,"coverage":null,"blindHigh":0,"routes":[{"behaviorHash":"bh1_13969839d8a18d54aa0342618e56db6a","pol":121,"exposed":[]},{"behaviorHash":"bh1_2daf8a2b1b0ed393d443991a2b0700f6","pol":121,"exposed":[]},{"behaviorHash":"bh1_58fbf5ac38b6d71abc7204921de6b957","pol":121,"exposed":[]},{"behaviorHash":"bh1_58fbf5ac38b6d71abc7204921de6b957","pol":121,"exposed":[]},{"behaviorHash":"bh1_cc6788289c612af85b8d215b5bdf9b28","pol":121,"exposed":[]}],"posture":{"auth":{"protected":0,"exposed":0,"na":5},"atomicity":{"protected":0,"exposed":0,"na":5},"reversibility":{"protected":0,"exposed":0,"na":5},"validation":{"protected":0,"exposed":0,"na":5},"aggregate":{"protected":0,"exposed":0,"na":5}},"bytes":5}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.68.0",
3
+ "version": "0.69.0",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "AI writes. SPARDA proves. A deterministic, offline gate that catches when an AI edit removes a guard, exposes a route, or breaks an invariant — no API key, right in the agent edit loop.",
6
6
  "type": "module",
@@ -18,7 +18,8 @@ import {
18
18
  verdictState,
19
19
  buildProofObjects,
20
20
  } from '../ubg/apocalypse.js';
21
- import { surveyBlindspots } from '../ubg/blindspots.js';
21
+ import { surveyBlindspots, coveragePct } from '../ubg/blindspots.js';
22
+ import { premiseFor, withPremiseGaps } from '../ubg/premise.js';
22
23
  import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
23
24
 
24
25
  // version travels with the proof so an audit knows which prover produced it
@@ -69,13 +70,22 @@ export async function runApocalypse(opts) {
69
70
  }
70
71
 
71
72
  const findings = [...staticFindings, ...diffFindings];
73
+ // THE PREMISE, before the proof. This is the CI gate — the command whose exit code
74
+ // decides whether a tree ships — so it is the last place that may certify an app whose
75
+ // route table nobody checked. `premiseFor` keeps the opt-in boundary: the runtime
76
+ // oracle needs `--probe`, the boot-free convention oracle always runs.
77
+ const premise = await premiseFor(canonical, report, {
78
+ cwd: opts.cwd,
79
+ probe: opts.probe,
80
+ });
72
81
  // the honesty companion: where does the proof stop? (see `sparda blindspots`)
73
- const blind = surveyBlindspots(canonical, report);
82
+ const blind = surveyBlindspots(canonical, withPremiseGaps(report, premise));
74
83
  // coverage feeds the verdict: a clean app that resolved almost nothing is SURFACE, not PROVEN;
75
84
  // and any high-risk blind spot pulls a bare PROVEN down to PARTIAL (E-047, the giant-test rung)
76
85
  const verdict = verdictOf(findings, canonical, {
77
86
  coverage: blind.coverage.ratio,
78
87
  blindHigh: blind.byRisk.critical + blind.byRisk.high,
88
+ premiseGaps: premise.available ? premise.gaps.length : 0,
79
89
  });
80
90
 
81
91
  if (opts.sarif) {
@@ -136,6 +146,21 @@ export async function runApocalypse(opts) {
136
146
  if (opts.verbose && report.skipped?.length)
137
147
  for (const s of report.skipped)
138
148
  console.log(` skipped: ${s.reason}${s.file ? ` (${s.file})` : ''}`);
149
+ } else if (verdict.premiseUnverified) {
150
+ // The one negative backed by a source outside SPARDA. It is stated BEFORE the
151
+ // clean/risky branches on purpose: findings about a subject we did not have are
152
+ // not the headline, the missing subject is.
153
+ const witness =
154
+ premise.oracle === 'convention'
155
+ ? "the framework's own file conventions"
156
+ : 'the running app';
157
+ console.log(
158
+ `✗ PREMISE NOT VERIFIED — ${premise.gaps.length} route(s) ${witness} serve were NEVER seen by the compiler. Nothing below is a claim about this tree: the proof's subject is incomplete, so no verdict is issued.`,
159
+ );
160
+ for (const g of premise.gaps.slice(0, 8))
161
+ console.log(` ✗ ${g.method} ${g.path}`);
162
+ if (premise.gaps.length > 8)
163
+ console.log(` … and ${premise.gaps.length - 8} more`);
139
164
  } else if (verdict.clean) {
140
165
  console.log(
141
166
  `✓ PROVEN — ${obligations} obligation(s) discharged, zero violations. No declared guard, invariant, transaction or aggregate boundary can be broken by this tree.`,
@@ -148,16 +173,22 @@ export async function runApocalypse(opts) {
148
173
  }
149
174
  // Honesty companion: where the proof stops. A green verdict over a graph riddled
150
175
  // with blind spots is not omniscience — say so, on the same screen as the verdict.
176
+ if (premise.available)
177
+ console.log(
178
+ premise.oracle === 'convention'
179
+ ? ` ⚖ premise checked against the framework's file conventions: ${premise.probed} route(s) implied, ${premise.gaps.length} gap(s)`
180
+ : ` ⚖ premise checked against the running app: ${premise.probed} route(s) probed, ${premise.gaps.length} gap(s)`,
181
+ );
151
182
  if (blind.surface > 0) {
152
183
  const hi = blind.byRisk.critical + blind.byRisk.high;
153
184
  console.log(
154
- ` ◐ blind spots: ${blind.surface} (${hi} high+, ${blind.byRisk.medium} medium, ${blind.byRisk.low} low) · coverage ${(blind.coverage.ratio * 100).toFixed(0)}% — run \`sparda blindspots\` for the map`,
185
+ ` ◐ blind spots: ${blind.surface} (${hi} high+, ${blind.byRisk.medium} medium, ${blind.byRisk.low} low) · coverage ${coveragePct(blind.coverage.ratio)} — run \`sparda blindspots\` for the map`,
155
186
  );
156
187
  }
157
188
  }
158
189
 
159
190
  if (!verdict.safe) process.exitCode = 1; // CI gates on this
160
- return { verdict, findings, obligations, blindspots: blind };
191
+ return { verdict, findings, obligations, blindspots: blind, premise };
161
192
  }
162
193
 
163
194
  // SARIF 2.1.0 — GitHub code scanning eats this directly
@@ -9,18 +9,29 @@ import path from 'node:path';
9
9
  import { compileUBG } from '../ubg/compile.js';
10
10
  import { canonicalizeGraph } from '../ubg/schema.js';
11
11
  import { checkGraph, verdictOf, badgeFor } from '../ubg/apocalypse.js';
12
- import { surveyBlindspots } from '../ubg/blindspots.js';
12
+ import { surveyBlindspots, coveragePct } from '../ubg/blindspots.js';
13
+ import { premiseFor, withPremiseGaps } from '../ubg/premise.js';
13
14
 
14
15
  export async function runBadge(opts) {
15
16
  const { graph, report } = compileUBG(opts.cwd, { write: false });
16
17
  const canonical = canonicalizeGraph(graph);
17
18
  const { findings } = checkGraph(canonical);
18
- const blind = surveyBlindspots(canonical, report);
19
+ // A badge is the artifact that leaves the repo. It may not read green over an app
20
+ // whose route table was never checked — that is the one place a false claim travels
21
+ // furthest and is hardest to retract.
22
+ const premise = await premiseFor(canonical, report, {
23
+ cwd: opts.cwd,
24
+ probe: opts.probe,
25
+ });
26
+ const blind = surveyBlindspots(canonical, withPremiseGaps(report, premise));
19
27
  const verdict = verdictOf(findings, canonical, {
20
28
  coverage: blind.coverage.ratio,
21
29
  blindHigh: blind.byRisk.critical + blind.byRisk.high,
30
+ premiseGaps: premise.available ? premise.gaps.length : 0,
22
31
  });
23
- const cov = Math.round(blind.coverage.ratio * 100);
32
+ // null = measured-but-unknown (0/0): the JSON keeps the null, the console says the word
33
+ const cov =
34
+ blind.coverage.ratio == null ? null : Math.round(blind.coverage.ratio * 100);
24
35
  const { state, message, color } = badgeFor(verdict, { coverage: blind.coverage.ratio });
25
36
 
26
37
  const svg = renderBadge('SPARDA', message, color);
@@ -52,7 +63,7 @@ export async function runBadge(opts) {
52
63
  const shields = `https://img.shields.io/badge/SPARDA-${encodeURIComponent(message)}-${color.slice(1)}`;
53
64
 
54
65
  console.log(
55
- `\n✓ Badge written: ${relSvg} (${state} · ${report.routes} routes · ${cov}% coverage)`,
66
+ `\n✓ Badge written: ${relSvg} (${state} · ${report.routes} routes · ${coveragePct(blind.coverage.ratio)} coverage)`,
56
67
  );
57
68
  console.log(`\nPaste into your README:\n`);
58
69
  console.log(` ![SPARDA](./${relSvg})`);
@@ -10,7 +10,7 @@
10
10
  // sparda blindspots --json the raw survey for tooling
11
11
  import { compileUBG } from '../ubg/compile.js';
12
12
  import { canonicalizeGraph } from '../ubg/schema.js';
13
- import { surveyBlindspots } from '../ubg/blindspots.js';
13
+ import { surveyBlindspots, coveragePct } from '../ubg/blindspots.js';
14
14
 
15
15
  const ICON = { critical: '✗', high: '✗', medium: '⚠', low: '·' };
16
16
 
@@ -30,7 +30,7 @@ export async function runBlindspots(opts) {
30
30
  ` ${surface} blind spot(s): ${byRisk.high + byRisk.critical} high+, ${byRisk.medium} medium, ${byRisk.low} low`,
31
31
  );
32
32
  console.log(
33
- ` coverage ${(coverage.ratio * 100).toFixed(1)}% — ${coverage.resolved} behaviors resolved, ${coverage.blind} left unseen`,
33
+ ` coverage ${coveragePct(coverage.ratio, 1)} — ${coverage.resolved} behaviors resolved, ${coverage.blind} left unseen`,
34
34
  );
35
35
  if (surface === 0) {
36
36
  console.log(
@@ -10,7 +10,8 @@ import path from 'node:path';
10
10
  import { compileUBG } from '../ubg/compile.js';
11
11
  import { canonicalizeGraph } from '../ubg/schema.js';
12
12
  import { checkGraph, verdictOf, verdictState } from '../ubg/apocalypse.js';
13
- import { surveyBlindspots } from '../ubg/blindspots.js';
13
+ import { premiseFor, withPremiseGaps } from '../ubg/premise.js';
14
+ import { surveyBlindspots, coveragePct } from '../ubg/blindspots.js';
14
15
  import { buildCapsule } from '../ubg/immunity.js';
15
16
  import { AXES, POLARITY_SYMBOL, exposedAxes } from '../ubg/polarity.js';
16
17
  import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
@@ -20,10 +21,20 @@ export async function runDossier(opts) {
20
21
  const canonical = canonicalizeGraph(compiled.graph);
21
22
  const { findings, polarity } = checkGraph(canonical);
22
23
  const capsule = buildCapsule(canonical);
23
- const blindspots = surveyBlindspots(canonical, compiled.report);
24
+ // the public report — same rule as the badge: it must not describe an app SPARDA
25
+ // never fully had
26
+ const premise = await premiseFor(canonical, compiled.report, {
27
+ cwd: opts.cwd,
28
+ probe: opts.probe,
29
+ });
30
+ const blindspots = surveyBlindspots(
31
+ canonical,
32
+ withPremiseGaps(compiled.report, premise),
33
+ );
24
34
  const verdict = verdictOf(findings, canonical, {
25
35
  coverage: blindspots.coverage.ratio,
26
36
  blindHigh: blindspots.byRisk.critical + blindspots.byRisk.high,
37
+ premiseGaps: premise.available ? premise.gaps.length : 0,
27
38
  });
28
39
 
29
40
  const data = {
@@ -36,7 +47,10 @@ export async function runDossier(opts) {
36
47
  provable: verdict.provable,
37
48
  proven: verdict.provable && verdict.clean,
38
49
  state: verdictState(verdict),
39
- coverage: Math.round(blindspots.coverage.ratio * 100),
50
+ coverage:
51
+ blindspots.coverage.ratio == null
52
+ ? null // 0/0 — a measurement that does not exist, never "100"
53
+ : Math.round(blindspots.coverage.ratio * 100),
40
54
  surfaceOnly: verdict.surfaceOnly,
41
55
  guards: verdict.guards,
42
56
  guardsVerified: verdict.guardsVerified,
@@ -241,12 +255,12 @@ export function renderDossierHTML(d) {
241
255
  <section class="hero">
242
256
  <div class="verdict-row">
243
257
  <span class="stamp ${verdictClass}"><span class="dot"></span>${esc(verdictText)}</span>
244
- <span class="cov" title="share of the app's behavior SPARDA resolved and reasoned about">coverage&nbsp;<b>${b.coverage ? (b.coverage.ratio * 100).toFixed(0) : (d.coverage ?? 0)}%</b></span>
258
+ <span class="cov" title="share of the app's behavior SPARDA resolved and reasoned about">coverage&nbsp;<b>${b.coverage ? coveragePct(b.coverage.ratio) : `${d.coverage ?? 0}%`}</b></span>
245
259
  </div>
246
260
  <p>${esc(verdictLede)}</p>
247
261
  <div class="stats">
248
262
  ${statCard(d.routes, 'routes')}
249
- ${statCard((b.coverage.ratio * 100).toFixed(0) + '%', 'behavior resolved')}
263
+ ${statCard(coveragePct(b.coverage.ratio), 'behavior resolved')}
250
264
  ${statCard(d.nodes, 'graph nodes')}
251
265
  ${statCard(d.tables, 'data tables')}
252
266
  ${statCard(esc(d.framework), 'framework')}
@@ -266,7 +280,7 @@ export function renderDossierHTML(d) {
266
280
  <h2>What SPARDA found</h2>
267
281
  ${findingCards}
268
282
 
269
- <h2>Where the proof stops <small>SPARDA's own blind spots · coverage ${(b.coverage.ratio * 100).toFixed(0)}% · ${b.surface} unseen, ranked by what each could hide</small></h2>
283
+ <h2>Where the proof stops <small>SPARDA's own blind spots · coverage ${coveragePct(b.coverage.ratio)} · ${b.surface} unseen, ranked by what each could hide</small></h2>
270
284
  ${blindRows}
271
285
 
272
286
  <footer>
@@ -0,0 +1,344 @@
1
+ // commands/enforce.js — the third leg (ADR-076): SAST observes, RASP imposes, SPARDA does
2
+ // both AND proves what it imposed. `sparda enforce` closes the type-lock gap (ADR-070) by
3
+ // SYNTHESIS: a clean mutation route whose only protection is an ASSERTED guard (an opaque
4
+ // middleware SPARDA cannot read) gets a minimal boundary check SPARDA CAN read — inserted
5
+ // into the route's middleware chain, where domination is syntactically guaranteed (every
6
+ // chain step dominates the handler; no dominator-tree computation needed, the chain IS the
7
+ // dominance spine the guard scanner already trusts). PARTIAL → PROVEN, by construction.
8
+ //
9
+ // The three rails that keep this honest:
10
+ // 1. THE COURT — after writing, the app is recompiled and must prove PROVEN with zero new
11
+ // findings; otherwise every edit is rolled back and the command fails. Enforcement that
12
+ // cannot prove itself does not persist. (A non-denying check can never buy green: the
13
+ // gate re-runs the same verifier that demanded the proof in the first place.)
14
+ // 2. REVERSIBILITY (hard rule #4) — the injected block is marker-fenced, the chain
15
+ // insertion is a single unique identifier, and the pre-enforce content hash is recorded:
16
+ // `sparda enforce --revert` restores the file byte-for-byte (verified by hash).
17
+ // 3. DISCLOSURE — the manifest (.sparda/enforce.json) makes an enforced proof auditable
18
+ // and distinguishable from a native static proof; `prove` reads it and reports
19
+ // PROVEN (ENFORCED), never a silent bare PROVEN. Soundness never depends on the
20
+ // manifest: delete the injected check and the verdict falls back to PARTIAL on its own.
21
+ //
22
+ // V1 scope (honest): Express route registrations `<app|router>.<verb>('/path', mw…, handler)`
23
+ // with ≥1 middleware already in the chain (the asserted guard), JS/TS, same-file. The check
24
+ // denies when the authenticated principal is ABSENT (`req.user` by convention, configurable
25
+ // via --principal): legitimate traffic through a working auth middleware is untouched; a
26
+ // removed/broken auth middleware now fails closed instead of open.
27
+ import crypto from 'node:crypto';
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ import { parse } from '@babel/parser';
31
+ import { compileUBG } from '../ubg/compile.js';
32
+ import { canonicalizeGraph } from '../ubg/schema.js';
33
+ import {
34
+ checkGraph,
35
+ verdictOf,
36
+ verdictState,
37
+ assertedOnlyMutationRoutes,
38
+ } from '../ubg/apocalypse.js';
39
+
40
+ export const ENFORCE_IDENT = 'spardaProvenAuth';
41
+ const MARK_START = '// >>> sparda-enforce (do not edit this block) >>>';
42
+ const MARK_END = '// <<< sparda-enforce <<<';
43
+ const HTTP_VERBS = new Set(['get', 'post', 'put', 'patch', 'delete', 'all']);
44
+ const MANIFEST_REL = path.join('.sparda', 'enforce.json');
45
+
46
+ const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
47
+
48
+ // The synthesized boundary check. Deny-first, provable by the same scanner that verifies any
49
+ // guard (a 401 status response = deniesWithStatus): nothing about this is trusted by name.
50
+ // `body` is overridable ONLY so the adversarial tests can prove the court below rejects a
51
+ // non-denying counterfeit — production callers never pass it.
52
+ function shimBlock(principal, body) {
53
+ return [
54
+ '',
55
+ MARK_START,
56
+ '// SPARDA-synthesized boundary proof (ADR-076): denies when no authenticated principal is',
57
+ "// present. Inserted because this file's guarded mutation route(s) rest on a guard SPARDA",
58
+ '// could not verify. Behavior for authenticated traffic is unchanged. Revert with',
59
+ '// `sparda enforce --revert`.',
60
+ `const ${ENFORCE_IDENT} = (req, res, next) => {`,
61
+ body ??
62
+ ` if (!${principal}) return res.status(401).json({ error: 'unauthorized' });`,
63
+ ' return next();',
64
+ '};',
65
+ MARK_END,
66
+ '',
67
+ ].join('\n');
68
+ }
69
+
70
+ // Find the route-registration CallExpression at (or spanning from) the entrypoint's recorded
71
+ // line: `<obj>.<verb>('/path', …)`. Returns the babel node or null.
72
+ function routeCallAt(ast, line) {
73
+ let hit = null;
74
+ const walk = (node) => {
75
+ if (!node || typeof node !== 'object' || hit) return;
76
+ if (Array.isArray(node)) {
77
+ for (const n of node) walk(n);
78
+ return;
79
+ }
80
+ if (
81
+ node.type === 'CallExpression' &&
82
+ node.loc?.start.line === line &&
83
+ node.callee?.type === 'MemberExpression' &&
84
+ node.callee.property?.type === 'Identifier' &&
85
+ HTTP_VERBS.has(node.callee.property.name) &&
86
+ node.arguments?.length >= 2
87
+ ) {
88
+ hit = node;
89
+ return;
90
+ }
91
+ for (const k of Object.keys(node)) {
92
+ if (k === 'loc' || k === 'range') continue;
93
+ const v = node[k];
94
+ if (v && typeof v === 'object') walk(v);
95
+ }
96
+ };
97
+ walk(ast.program);
98
+ return hit;
99
+ }
100
+
101
+ // Compute the per-file edit plan for the given target routes. Pure: returns
102
+ // { insertions: [offset], needsBlock, blockOffset } or an error string.
103
+ function planFile(src, lines) {
104
+ const ast = parse(src, {
105
+ sourceType: 'unambiguous',
106
+ plugins: ['typescript', 'jsx', 'decorators-legacy'],
107
+ });
108
+ const insertions = [];
109
+ for (const line of lines) {
110
+ const call = routeCallAt(ast, line);
111
+ if (!call) return { error: `no route registration found at line ${line}` };
112
+ const args = call.arguments;
113
+ if (args.some((a) => a?.type === 'Identifier' && a.name === ENFORCE_IDENT)) continue; // idempotent
114
+ if (args.length < 3)
115
+ return {
116
+ error: `route at line ${line} has no middleware chain to anchor to (path + handler only)`,
117
+ };
118
+ // insertion point: before the LAST argument (the handler) — after every existing
119
+ // middleware, so the asserted guard still runs first and the proof shim dominates
120
+ // only the handler, never the app's own middleware.
121
+ insertions.push(args[args.length - 1].start);
122
+ }
123
+ const needsBlock = !src.includes(MARK_START);
124
+ // the block goes after the last top-level import (or at the top for CJS)
125
+ let blockOffset = 0;
126
+ for (const stmt of ast.program.body)
127
+ if (stmt.type === 'ImportDeclaration') blockOffset = stmt.end;
128
+ return { insertions, needsBlock, blockOffset };
129
+ }
130
+
131
+ function applyEdits(src, plan, principal, shimBody) {
132
+ const edits = plan.insertions
133
+ .map((offset) => ({ offset, text: `${ENFORCE_IDENT}, ` }))
134
+ .sort((a, b) => b.offset - a.offset);
135
+ let out = src;
136
+ for (const e of edits) out = out.slice(0, e.offset) + e.text + out.slice(e.offset);
137
+ if (plan.needsBlock) {
138
+ const at = plan.blockOffset;
139
+ out = out.slice(0, at) + shimBlock(principal, shimBody) + out.slice(at);
140
+ }
141
+ return out;
142
+ }
143
+
144
+ function compileVerdict(cwd) {
145
+ const canonical = canonicalizeGraph(compileUBG(cwd, { write: false }).graph);
146
+ const { findings } = checkGraph(canonical);
147
+ const verdict = verdictOf(findings, canonical, {});
148
+ return { canonical, findings, verdict, state: verdictState(verdict) };
149
+ }
150
+
151
+ export function readEnforceManifest(cwd) {
152
+ try {
153
+ const m = JSON.parse(fs.readFileSync(path.join(cwd, MANIFEST_REL), 'utf8'));
154
+ // the manifest is a claim like any other — it counts only while the injected checks are
155
+ // still in place (a hand-stripped shim must not read as ENFORCED)
156
+ for (const [rel, rec] of Object.entries(m.files ?? {})) {
157
+ const cur = fs.readFileSync(path.join(cwd, rel), 'utf8');
158
+ if (sha(cur) !== rec.enforcedSha256 || !cur.includes(MARK_START)) return null;
159
+ }
160
+ return m;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ export async function runEnforce(opts) {
167
+ const cwd = opts.cwd;
168
+ const principal = opts.principal ?? 'req.user';
169
+ const log = (s) => console.error(s);
170
+
171
+ if (opts.revert) return revertEnforce(cwd, { json: opts.json });
172
+
173
+ // `req.user` / `req.auth` shapes only — the principal lands in a `!(...)` test inside the
174
+ // generated code, so it must be a plain member path (defense against option injection).
175
+ if (!/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)+$/.test(principal))
176
+ throw Object.assign(new Error(`invalid --principal: ${principal}`), {
177
+ code: 'USER',
178
+ hint: 'Use a plain member path like req.user or req.auth.',
179
+ });
180
+
181
+ const before = compileVerdict(cwd);
182
+ const targets = assertedOnlyMutationRoutes(before.canonical).filter((t) => t.loc?.file);
183
+ if (targets.length === 0) {
184
+ const msg =
185
+ before.state === 'PROVEN'
186
+ ? 'Nothing to enforce — the app is already PROVEN.'
187
+ : `Nothing to enforce — no clean mutation route rests on an asserted-only guard (verdict: ${before.state}). Enforce closes exactly that gap; other findings need real fixes, not synthesis.`;
188
+ if (opts.json) console.log(JSON.stringify({ enforced: [], note: msg }, null, 2));
189
+ else log(msg);
190
+ return;
191
+ }
192
+
193
+ // group targets per file, plan all edits
194
+ const byFile = new Map();
195
+ for (const t of targets) {
196
+ if (!byFile.has(t.loc.file)) byFile.set(t.loc.file, []);
197
+ byFile.get(t.loc.file).push(t);
198
+ }
199
+ const plans = new Map(); // rel -> { src, out }
200
+ for (const [rel, list] of byFile) {
201
+ const abs = path.join(cwd, rel);
202
+ const src = fs.readFileSync(abs, 'utf8');
203
+ const plan = planFile(
204
+ src,
205
+ list.map((t) => t.loc.line),
206
+ );
207
+ if (plan.error)
208
+ throw Object.assign(new Error(`${rel}: ${plan.error}`), {
209
+ code: 'USER',
210
+ hint: 'This route shape is outside enforce V1 (Express chain form). File an issue with the route.',
211
+ });
212
+ plans.set(rel, { src, out: applyEdits(src, plan, principal, opts._shimBody) });
213
+ }
214
+
215
+ if (!opts.apply) {
216
+ // dry-run is the default — enforcement writes code, so the plan is shown first
217
+ log(`sparda enforce — plan (dry run; pass --apply to write):\n`);
218
+ for (const t of targets)
219
+ log(` ⚙ ${t.label} — insert ${ENFORCE_IDENT} before the handler`);
220
+ for (const rel of plans.keys())
221
+ log(
222
+ ` ⚙ ${rel} — add the marker-fenced ${ENFORCE_IDENT} block (denies without ${principal})`,
223
+ );
224
+ log(
225
+ `\n The check denies only when ${principal} is ABSENT. Verify your auth middleware sets it.\n After --apply, SPARDA recompiles and keeps the edit ONLY if the app proves PROVEN.`,
226
+ );
227
+ if (opts.json)
228
+ console.log(
229
+ JSON.stringify(
230
+ { plan: targets.map((t) => t.label), files: [...plans.keys()], applied: false },
231
+ null,
232
+ 2,
233
+ ),
234
+ );
235
+ return;
236
+ }
237
+
238
+ // write, then THE COURT: recompile — the edit persists only if it proves itself
239
+ for (const [rel, p] of plans) fs.writeFileSync(path.join(cwd, rel), p.out);
240
+ const after = compileVerdict(cwd);
241
+ const stillAsserted = assertedOnlyMutationRoutes(after.canonical).length;
242
+ const grewFindings = after.findings.length > before.findings.length;
243
+ if (after.state !== 'PROVEN' || stillAsserted > 0 || grewFindings) {
244
+ for (const [rel, p] of plans) fs.writeFileSync(path.join(cwd, rel), p.src); // roll back, byte-for-byte
245
+ throw Object.assign(
246
+ new Error(
247
+ `enforcement did not prove itself (verdict ${after.state}, asserted-only left ${stillAsserted}${grewFindings ? ', new findings appeared' : ''}) — every edit rolled back`,
248
+ ),
249
+ {
250
+ code: 'USER',
251
+ hint: 'The synthesized check could not be verified on the recompiled graph. Nothing was changed.',
252
+ },
253
+ );
254
+ }
255
+
256
+ // record the manifest — the auditable provenance of the ENFORCED tier
257
+ const manifest = {
258
+ version: 1,
259
+ at: new Date().toISOString(),
260
+ principal,
261
+ routes: targets.map((t) => t.label),
262
+ files: Object.fromEntries(
263
+ [...plans].map(([rel, p]) => [
264
+ rel,
265
+ { originalSha256: sha(p.src), enforcedSha256: sha(p.out) },
266
+ ]),
267
+ ),
268
+ };
269
+ fs.mkdirSync(path.join(cwd, '.sparda'), { recursive: true });
270
+ fs.writeFileSync(
271
+ path.join(cwd, MANIFEST_REL),
272
+ JSON.stringify(manifest, null, 2) + '\n',
273
+ );
274
+
275
+ if (opts.json)
276
+ console.log(
277
+ JSON.stringify(
278
+ {
279
+ enforced: manifest.routes,
280
+ files: Object.keys(manifest.files),
281
+ verdict: 'PROVEN (ENFORCED)',
282
+ },
283
+ null,
284
+ 2,
285
+ ),
286
+ );
287
+ else {
288
+ for (const t of targets) log(` ✓ enforced ${t.label}`);
289
+ log(
290
+ `\n✓ PROVEN (ENFORCED) — ${targets.length} route(s) now carry a boundary check SPARDA verified on the recompiled graph.\n Revert any time: sparda enforce --revert (byte-for-byte).`,
291
+ );
292
+ }
293
+ }
294
+
295
+ export function revertEnforce(cwd, { json } = {}) {
296
+ const log = (s) => console.error(s);
297
+ const manifestPath = path.join(cwd, MANIFEST_REL);
298
+ let manifest;
299
+ try {
300
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
301
+ } catch {
302
+ log('Nothing to revert — no enforcement manifest found.');
303
+ return;
304
+ }
305
+ const results = [];
306
+ for (const [rel, rec] of Object.entries(manifest.files ?? {})) {
307
+ const abs = path.join(cwd, rel);
308
+ let cur;
309
+ try {
310
+ cur = fs.readFileSync(abs, 'utf8');
311
+ } catch {
312
+ results.push({ file: rel, restored: false, reason: 'missing' });
313
+ continue;
314
+ }
315
+ // strip the fenced block (with the exact newlines shimBlock added around it), then the
316
+ // chain identifiers — the two edits enforce made, undone in reverse
317
+ let out = cur.replace(
318
+ new RegExp(
319
+ `\\n${MARK_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${MARK_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\n`,
320
+ 'g',
321
+ ),
322
+ '',
323
+ );
324
+ out = out.split(`${ENFORCE_IDENT}, `).join('');
325
+ fs.writeFileSync(abs, out);
326
+ results.push({ file: rel, restored: sha(out) === rec.originalSha256 });
327
+ }
328
+ fs.rmSync(manifestPath, { force: true });
329
+ const clean = results.every((r) => r.restored);
330
+ if (json) console.log(JSON.stringify({ reverted: results, byteClean: clean }, null, 2));
331
+ else {
332
+ for (const r of results)
333
+ log(
334
+ r.restored
335
+ ? ` ✓ ${r.file} restored byte-for-byte`
336
+ : ` ⚠ ${r.file} reverted, but differs from the pre-enforce bytes (edited since?)`,
337
+ );
338
+ log(
339
+ clean
340
+ ? '✓ enforcement fully reverted.'
341
+ : '⚠ reverted with differences — review the files above.',
342
+ );
343
+ }
344
+ }