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 +7 -4
- package/demo-app/.sparda/immunity.json +1 -1
- package/package.json +1 -1
- package/src/commands/apocalypse.js +35 -4
- package/src/commands/badge.js +15 -4
- package/src/commands/blindspots.js +2 -2
- package/src/commands/dossier.js +20 -6
- package/src/commands/enforce.js +344 -0
- package/src/commands/falsify.js +56 -0
- package/src/commands/prove.js +77 -7
- package/src/commands/remove.js +7 -0
- package/src/commands/review.js +39 -11
- package/src/detect.js +29 -4
- package/src/index.js +17 -0
- package/src/server/stdio.js +118 -4
- package/src/ubg/apocalypse.js +88 -46
- package/src/ubg/blindspots.js +37 -2
- package/src/ubg/compile.js +27 -3
- package/src/ubg/express.js +687 -94
- package/src/ubg/extract.js +227 -14
- package/src/ubg/falsify.js +118 -0
- package/src/ubg/fastapi.js +2 -0
- package/src/ubg/fastapi_extract.py +32 -3
- package/src/ubg/medusa.js +29 -2
- package/src/ubg/nestjs.js +317 -23
- package/src/ubg/nextjs.js +84 -4
- package/src/ubg/openapi.js +42 -1
- package/src/ubg/oracle-static.js +388 -0
- package/src/ubg/passes/type-propagation.js +12 -24
- package/src/ubg/premise.js +224 -0
- package/src/ubg/reach.js +93 -9
- package/src/ubg/resolve.js +8 -0
- package/src/ubg/strapi.js +29 -1
- package/src/ubg/translate.js +46 -6
- package/src/ubg/witness.js +170 -0
package/README.md
CHANGED
|
@@ -21,14 +21,14 @@
|
|
|
21
21
|

|
|
22
22
|
[](./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
|
+
{"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.
|
|
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
|
|
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
|
package/src/commands/badge.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 · ${
|
|
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(` `);
|
|
@@ -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
|
|
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(
|
package/src/commands/dossier.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
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 <b>${b.coverage ? (b.coverage.ratio
|
|
258
|
+
<span class="cov" title="share of the app's behavior SPARDA resolved and reasoned about">coverage <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
|
|
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
|
|
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
|
+
}
|