sparda-mcp 0.17.1 → 0.30.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 +8 -2
- package/SKILL.md +2 -0
- package/package.json +1 -1
- package/src/commands/apocalypse.js +22 -2
- package/src/commands/blindspots.js +54 -0
- package/src/commands/dossier.js +233 -0
- package/src/commands/genome.js +116 -0
- package/src/commands/immunize.js +9 -5
- package/src/detect.js +85 -1
- package/src/index.js +18 -0
- package/src/ubg/apocalypse.js +37 -1
- package/src/ubg/blindspots.js +164 -0
- package/src/ubg/compile.js +2 -0
- package/src/ubg/express.js +331 -7
- package/src/ubg/extract.js +430 -17
- package/src/ubg/genome.js +309 -0
- package/src/ubg/immunity.js +6 -2
- package/src/ubg/medusa.js +260 -0
- package/src/ubg/nestjs.js +159 -62
- package/src/ubg/nextjs.js +61 -2
- package/src/ubg/translate.js +28 -6
- package/demo-app/.sparda/immunity.json +0 -1
package/README.md
CHANGED
|
@@ -16,6 +16,9 @@ For twenty years software communicated through APIs. Then AI agents arrived, and
|
|
|
16
16
|
|
|
17
17
|
SPARDA compiles your backend — routes, database queries, state mutations, permissions, side-effects — into one language-agnostic, mathematical graph: the **Unified Behavior Graph (UBG)**, serialized as `.sparda/ubg.json` under the **SBIR** specification ([SPARDA Behavior IR](docs/SBIR_SPEC_V1.1.md)). Compile once; then every tool is a simple pass over that graph.
|
|
18
18
|
|
|
19
|
+
> [!IMPORTANT]
|
|
20
|
+
> **The 3700-Route Proof:** In our latest stress-test (v0.26.0), SPARDA successfully compiled and proved **3700+ routes** from elite open-source monsters (Next.js *Dub*, NestJS *Immich*, *MedusaJS*, and GitHub's OpenAPI) in **< 2.2 seconds** per repo, with zero crashes. It natively resolves deep Dependency Injection, external controllers, and Next.js handlers.
|
|
21
|
+
|
|
19
22
|
**What the graph unlocks — 100% local, deterministic, zero runtime dependencies, zero API key:**
|
|
20
23
|
|
|
21
24
|
| Command | What it does |
|
|
@@ -276,8 +279,11 @@ runtime, so the guidance never goes stale.
|
|
|
276
279
|
|
|
277
280
|
## Supported frameworks
|
|
278
281
|
|
|
279
|
-
- **Next.js App Router (13/14/15)** — file-based injection.
|
|
280
|
-
- **
|
|
282
|
+
- **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.
|
|
283
|
+
- **NestJS** — AST-based router injection. Deeply resolves Multi-hop Dependency Injection (Controller → Service → Repository), inherited DI, and `baseUrl`/`paths` imports. Supports Prisma, TypeORM, and Kysely.
|
|
284
|
+
- **Express 4/5** (JS/TS, ESM/CJS) — AST-based router injection. Deeply resolves external controllers, Mongoose schemas, and barrel re-exports. Uses dynamic tree-scanning to find non-standard entry points (`bootstrap.ts`, etc).
|
|
285
|
+
- **MedusaJS** — Native AST ingestion of complex e-commerce routing.
|
|
286
|
+
- **Any Backend On Earth (Go, Java, Rails, Laravel)** — Compiles flawlessly from OpenAPI 3.x specs.
|
|
281
287
|
- **FastAPI** (Python >= 3.9) — AST-based router injection.
|
|
282
288
|
|
|
283
289
|
## Security posture (honest)
|
package/SKILL.md
CHANGED
|
@@ -139,6 +139,8 @@ Writes are **disabled by default**. The protocol is not optional:
|
|
|
139
139
|
manifest validity, the semantic/immune cache, host reachability, and quarantine;
|
|
140
140
|
it exits non-zero so it can gate CI.
|
|
141
141
|
- **Formal Deployment Proof** → `sparda apocalypse` reads the compiled graph (`ubg.json`) and proves five correctness obligations: catches unguarded mutations, non-atomic aggregate writes, unvalidated writes to constrained tables, uncompensated observable effects, and aggregate root bypasses. Run `sparda apocalypse --save-baseline` to store the reference graph; subsequent runs diff against the baseline to catch dropped guards, dropped SQL invariants, or grown blast radiuses.
|
|
142
|
+
- **Safety Matrix Report** → `sparda dossier` generates an ultra-premium, self-contained HTML matrix of the app's safety proof, ideal for security engineers and audit compliance (Bloc C / Shadow tier).
|
|
143
|
+
- **Deep Framework Resolution** → SPARDA natively traces multi-hop Dependency Injection in NestJS, external controllers in Express, and wrapped handlers in Next.js, mapping them into the final Behavior Graph.
|
|
142
144
|
- **OpenAPI Ingestion** → Run `sparda ubg --openapi <openapi_spec.json>` to compile any non-JS/Python backend (Go, Java, Rails, Laravel, .NET) into a Unified Behavior Graph by mapping security schemes into guards and request/response structures. (JSON specs only — convert YAML once with `npx -y js-yaml spec.yaml > spec.json`.)
|
|
143
145
|
- **Executing the Graph (No code mock)** → Run `sparda mirror` to host a mock HTTP simulation server directly from `ubg.json` without any backend code. Enforces authentication guards, returns typed responses, and acts as a contract sandbox.
|
|
144
146
|
- **Exporting OpenAPI 3.1 Spec** → Run `sparda openapi` to generate a valid, deterministic OpenAPI 3.1 spec dynamically from the compiled behavior graph.
|
package/package.json
CHANGED
|
@@ -11,6 +11,7 @@ import path from 'node:path';
|
|
|
11
11
|
import { compileUBG } from '../ubg/compile.js';
|
|
12
12
|
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
13
13
|
import { checkGraph, diffGraphs, verdictOf } from '../ubg/apocalypse.js';
|
|
14
|
+
import { surveyBlindspots } from '../ubg/blindspots.js';
|
|
14
15
|
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
15
16
|
|
|
16
17
|
const ICONS = { critical: '✗', high: '✗', medium: '⚠', info: '·' };
|
|
@@ -43,6 +44,8 @@ export async function runApocalypse(opts) {
|
|
|
43
44
|
|
|
44
45
|
const findings = [...staticFindings, ...diffFindings];
|
|
45
46
|
const verdict = verdictOf(findings, canonical);
|
|
47
|
+
// the honesty companion: where does the proof stop? (see `sparda blindspots`)
|
|
48
|
+
const blind = surveyBlindspots(canonical, report);
|
|
46
49
|
|
|
47
50
|
if (opts.sarif) {
|
|
48
51
|
const sarifPath = path.join(opts.cwd, '.sparda', 'apocalypse.sarif');
|
|
@@ -54,7 +57,9 @@ export async function runApocalypse(opts) {
|
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
if (opts.json) {
|
|
57
|
-
console.log(
|
|
60
|
+
console.log(
|
|
61
|
+
JSON.stringify({ verdict, obligations, findings, blindspots: blind }, null, 2),
|
|
62
|
+
);
|
|
58
63
|
} else {
|
|
59
64
|
console.log(
|
|
60
65
|
`APOCALYPSE — deployment proof over ${canonical.nodes.length} nodes, ${canonical.edges.length} edges` +
|
|
@@ -78,6 +83,13 @@ export async function runApocalypse(opts) {
|
|
|
78
83
|
` no route call sites reached — routes are likely registered indirectly (a loader / DI pattern the static walk can't follow).`,
|
|
79
84
|
);
|
|
80
85
|
}
|
|
86
|
+
} else if (verdict.surfaceOnly) {
|
|
87
|
+
console.log(
|
|
88
|
+
`● SURFACE ONLY — ${verdict.entrypoints} route(s) seen, but ZERO behavior resolved (no state, no db/http/fs effects). There was nothing to prove, so this is NOT a clean bill of health — SPARDA saw the surface but not what the code does (a spec, or an effect-resolution gap: DI services, external controllers). Run with --verbose.`,
|
|
89
|
+
);
|
|
90
|
+
if (opts.verbose && report.skipped?.length)
|
|
91
|
+
for (const s of report.skipped)
|
|
92
|
+
console.log(` skipped: ${s.reason}${s.file ? ` (${s.file})` : ''}`);
|
|
81
93
|
} else if (verdict.clean) {
|
|
82
94
|
console.log(
|
|
83
95
|
`✓ PROVEN — ${obligations} obligation(s) discharged, zero violations. No declared guard, invariant, transaction or aggregate boundary can be broken by this tree.`,
|
|
@@ -88,10 +100,18 @@ export async function runApocalypse(opts) {
|
|
|
88
100
|
`${verdict.safe ? '⚠ RISKY' : '✗ NOT PROVEN'} — ${c.critical} critical, ${c.high} high, ${c.medium} medium, ${c.info} info`,
|
|
89
101
|
);
|
|
90
102
|
}
|
|
103
|
+
// Honesty companion: where the proof stops. A green verdict over a graph riddled
|
|
104
|
+
// with blind spots is not omniscience — say so, on the same screen as the verdict.
|
|
105
|
+
if (blind.surface > 0) {
|
|
106
|
+
const hi = blind.byRisk.critical + blind.byRisk.high;
|
|
107
|
+
console.log(
|
|
108
|
+
` ◐ 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`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
91
111
|
}
|
|
92
112
|
|
|
93
113
|
if (!verdict.safe) process.exitCode = 1; // CI gates on this
|
|
94
|
-
return { verdict, findings, obligations };
|
|
114
|
+
return { verdict, findings, obligations, blindspots: blind };
|
|
95
115
|
}
|
|
96
116
|
|
|
97
117
|
// SARIF 2.1.0 — GitHub code scanning eats this directly
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// commands/blindspots.js — hand the reader the map of SPARDA's own blindness.
|
|
2
|
+
// Compiles the tree, surveys the Unknown Behavior Surface, and prints every place
|
|
3
|
+
// SPARDA could not fully see, ranked by what it could be hiding. This is the honest
|
|
4
|
+
// complement to `apocalypse`: that command tells you what's proven, this one tells
|
|
5
|
+
// you exactly where the proof stops — so a green verdict is never mistaken for
|
|
6
|
+
// omniscience. Exit code 1 if any HIGH-or-worse blind spot sits on the surface
|
|
7
|
+
// (a state-changing route or write whose behavior went unseen is not something CI
|
|
8
|
+
// should wave through silently).
|
|
9
|
+
// sparda blindspots ranked ledger, human-readable
|
|
10
|
+
// sparda blindspots --json the raw survey for tooling
|
|
11
|
+
import { compileUBG } from '../ubg/compile.js';
|
|
12
|
+
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
13
|
+
import { surveyBlindspots } from '../ubg/blindspots.js';
|
|
14
|
+
|
|
15
|
+
const ICON = { critical: '✗', high: '✗', medium: '⚠', low: '·' };
|
|
16
|
+
|
|
17
|
+
export async function runBlindspots(opts) {
|
|
18
|
+
const { graph, report } = compileUBG(opts.cwd, { write: false, openapi: opts.openapi });
|
|
19
|
+
const canonical = canonicalizeGraph(graph);
|
|
20
|
+
const survey = surveyBlindspots(canonical, report);
|
|
21
|
+
|
|
22
|
+
if (opts.json) {
|
|
23
|
+
console.log(JSON.stringify({ survey }, null, 2));
|
|
24
|
+
} else {
|
|
25
|
+
const { surface, byRisk, coverage } = survey;
|
|
26
|
+
console.log(
|
|
27
|
+
`BLINDSPOTS — SPARDA's Unknown Behavior Surface over ${report.routes} route(s)`,
|
|
28
|
+
);
|
|
29
|
+
console.log(
|
|
30
|
+
` ${surface} blind spot(s): ${byRisk.high + byRisk.critical} high+, ${byRisk.medium} medium, ${byRisk.low} low`,
|
|
31
|
+
);
|
|
32
|
+
console.log(
|
|
33
|
+
` coverage ${(coverage.ratio * 100).toFixed(1)}% — ${coverage.resolved} behaviors resolved, ${coverage.blind} left unseen`,
|
|
34
|
+
);
|
|
35
|
+
if (surface === 0) {
|
|
36
|
+
console.log(
|
|
37
|
+
` ✓ nothing hidden — every route, effect target and guard SPARDA saw was fully resolved.`,
|
|
38
|
+
);
|
|
39
|
+
} else {
|
|
40
|
+
const shown = opts.verbose ? survey.spots : survey.spots.slice(0, 20);
|
|
41
|
+
for (const s of shown) {
|
|
42
|
+
console.log(
|
|
43
|
+
` ${ICON[s.risk]} [${s.risk}] ${s.kind} — ${s.label}${s.location ? ` (${s.location})` : ''}`,
|
|
44
|
+
);
|
|
45
|
+
if (opts.verbose) console.log(` ${s.why}`);
|
|
46
|
+
}
|
|
47
|
+
if (!opts.verbose && survey.spots.length > shown.length)
|
|
48
|
+
console.log(` … ${survey.spots.length - shown.length} more (run --verbose)`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (survey.byRisk.critical > 0 || survey.byRisk.high > 0) process.exitCode = 1;
|
|
53
|
+
return { survey };
|
|
54
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// commands/dossier.js — the human face of the proof.
|
|
2
|
+
// Everything SPARDA proved about an app, rendered as ONE self-contained HTML file a
|
|
3
|
+
// non-technical reader can open and understand: the verdict, the ternary safety
|
|
4
|
+
// matrix, the exact risks with file:line, and the frozen capsule. Zero deps, no CDN,
|
|
5
|
+
// no network — all CSS inline; deterministic (content derives only from the graph).
|
|
6
|
+
// Written to `.sparda/dossier.html`, which is gitignored — ephemeral by design: it
|
|
7
|
+
// disappears on `sparda remove` / a clean, so the reader keeps it only if they save it.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { compileUBG } from '../ubg/compile.js';
|
|
11
|
+
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
12
|
+
import { checkGraph, verdictOf } from '../ubg/apocalypse.js';
|
|
13
|
+
import { surveyBlindspots } from '../ubg/blindspots.js';
|
|
14
|
+
import { buildCapsule } from '../ubg/immunity.js';
|
|
15
|
+
import { AXES, POLARITY_SYMBOL, exposedAxes } from '../ubg/polarity.js';
|
|
16
|
+
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
17
|
+
|
|
18
|
+
export async function runDossier(opts) {
|
|
19
|
+
const compiled = compileUBG(opts.cwd, { write: false, openapi: opts.openapi });
|
|
20
|
+
const canonical = canonicalizeGraph(compiled.graph);
|
|
21
|
+
const { findings, polarity } = checkGraph(canonical);
|
|
22
|
+
const verdict = verdictOf(findings, canonical);
|
|
23
|
+
const capsule = buildCapsule(canonical);
|
|
24
|
+
const blindspots = surveyBlindspots(canonical, compiled.report);
|
|
25
|
+
|
|
26
|
+
const data = {
|
|
27
|
+
app: path.basename(path.resolve(opts.cwd)) || 'app',
|
|
28
|
+
framework: compiled.report.framework,
|
|
29
|
+
routes: compiled.report.routes,
|
|
30
|
+
tables: compiled.report.tables,
|
|
31
|
+
nodes: canonical.nodes.length,
|
|
32
|
+
edges: canonical.edges.length,
|
|
33
|
+
provable: verdict.provable,
|
|
34
|
+
proven: verdict.provable && verdict.clean,
|
|
35
|
+
surfaceOnly: verdict.surfaceOnly,
|
|
36
|
+
guards: verdict.guards,
|
|
37
|
+
guardsVerified: verdict.guardsVerified,
|
|
38
|
+
counts: verdict.counts,
|
|
39
|
+
findings,
|
|
40
|
+
polarity: polarity.map((p) => ({ entrypoint: p.entrypoint, vector: p.vector })),
|
|
41
|
+
posture: capsule.posture,
|
|
42
|
+
capsuleBytes: capsule.bytes,
|
|
43
|
+
blindspots,
|
|
44
|
+
sourceHash: (canonical.meta?.sourceHash ?? '').slice(0, 12),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const html = renderDossierHTML(data);
|
|
48
|
+
|
|
49
|
+
if (opts.json) {
|
|
50
|
+
console.log(JSON.stringify(data, null, 2));
|
|
51
|
+
return { data };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const outPath = path.join(opts.cwd, '.sparda', 'dossier.html');
|
|
55
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
56
|
+
atomicWrite(outPath, html);
|
|
57
|
+
console.log(
|
|
58
|
+
`DOSSIER — ${data.routes} route(s), verdict ${data.proven ? '✓ PROVEN' : data.provable ? '✗ NOT PROVEN' : '✗ NO PROOF'}`,
|
|
59
|
+
);
|
|
60
|
+
console.log(
|
|
61
|
+
` written: .sparda/dossier.html (${html.length} bytes, self-contained) — open it in any browser.`,
|
|
62
|
+
);
|
|
63
|
+
console.log(
|
|
64
|
+
` ephemeral: .sparda/ is gitignored, so it vanishes on \`sparda remove\` — save it to keep it.`,
|
|
65
|
+
);
|
|
66
|
+
return { data, outPath };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── pure renderer: proof data → one self-contained HTML document ────────────
|
|
70
|
+
|
|
71
|
+
const esc = (s) =>
|
|
72
|
+
String(s ?? '').replace(
|
|
73
|
+
/[&<>"']/g,
|
|
74
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c],
|
|
75
|
+
);
|
|
76
|
+
const short = (id) => esc(String(id).replace(/^entrypoint:/, ''));
|
|
77
|
+
|
|
78
|
+
const SEV_COLOR = {
|
|
79
|
+
critical: '#e5484d',
|
|
80
|
+
high: '#e5484d',
|
|
81
|
+
medium: '#e5a23d',
|
|
82
|
+
info: '#8b8f9a',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function renderDossierHTML(d) {
|
|
86
|
+
const verdictClass = d.proven ? 'ok' : d.surfaceOnly ? 'warn' : 'bad';
|
|
87
|
+
const verdictText = d.proven
|
|
88
|
+
? 'PROVEN'
|
|
89
|
+
: d.surfaceOnly
|
|
90
|
+
? 'SURFACE ONLY — routes seen, no behavior resolved'
|
|
91
|
+
: d.provable
|
|
92
|
+
? 'NOT PROVEN'
|
|
93
|
+
: 'NO PROOF — the app’s routes could not be read';
|
|
94
|
+
const verdictLede = d.proven
|
|
95
|
+
? 'No declared guard, invariant, transaction or aggregate boundary can be broken by this code.'
|
|
96
|
+
: d.surfaceOnly
|
|
97
|
+
? 'SPARDA saw the route surface but no state or side-effects behind it — there was nothing to prove. This is NOT a clean bill of health (a spec, or an effect-resolution gap).'
|
|
98
|
+
: d.provable
|
|
99
|
+
? `SPARDA found ${d.counts.critical} critical and ${d.counts.high} high risk(s) that must be fixed before this ships.`
|
|
100
|
+
: 'SPARDA could not see this app’s route surface — this is not a clean bill of health.';
|
|
101
|
+
|
|
102
|
+
const cell = (v) => {
|
|
103
|
+
const cls = v === -1 ? 'neg' : v === 1 ? 'pos' : 'na';
|
|
104
|
+
return `<td class="${cls}" title="${v === -1 ? 'exposed' : v === 1 ? 'protected' : 'n/a'}">${POLARITY_SYMBOL[v]}</td>`;
|
|
105
|
+
};
|
|
106
|
+
const matrixRows = d.polarity
|
|
107
|
+
.map((p) => {
|
|
108
|
+
const flagged = exposedAxes(p.vector).length ? ' class="row-bad"' : '';
|
|
109
|
+
return `<tr${flagged}><th>${short(p.entrypoint)}</th>${AXES.map((a) => cell(p.vector[a])).join('')}</tr>`;
|
|
110
|
+
})
|
|
111
|
+
.join('');
|
|
112
|
+
|
|
113
|
+
const findingCards = d.findings.length
|
|
114
|
+
? d.findings
|
|
115
|
+
.map(
|
|
116
|
+
(f) => `
|
|
117
|
+
<div class="finding" style="--sev:${SEV_COLOR[f.severity] ?? '#8b8f9a'}">
|
|
118
|
+
<div class="sev">${esc(f.severity)}</div>
|
|
119
|
+
<div class="fbody">
|
|
120
|
+
<div class="frule">${esc(f.rule)} · <span class="fep">${short(f.entrypoint)}</span></div>
|
|
121
|
+
<p>${esc(f.message)}</p>
|
|
122
|
+
</div>
|
|
123
|
+
</div>`,
|
|
124
|
+
)
|
|
125
|
+
.join('')
|
|
126
|
+
: '<p class="clean">No risks found — every proof obligation was discharged.</p>';
|
|
127
|
+
|
|
128
|
+
const statCard = (n, label) =>
|
|
129
|
+
`<div class="stat"><b>${esc(n)}</b><span>${esc(label)}</span></div>`;
|
|
130
|
+
|
|
131
|
+
// The honesty section: where the proof stops. No other prover shows this.
|
|
132
|
+
const b = d.blindspots ?? { spots: [], surface: 0, coverage: { ratio: 1 } };
|
|
133
|
+
const blindRows = b.spots.length
|
|
134
|
+
? b.spots
|
|
135
|
+
.slice(0, 40)
|
|
136
|
+
.map(
|
|
137
|
+
(s) => `
|
|
138
|
+
<div class="finding" style="--sev:${SEV_COLOR[s.risk] ?? '#8b8f9a'}">
|
|
139
|
+
<div class="sev">${esc(s.risk)}</div>
|
|
140
|
+
<div class="fbody">
|
|
141
|
+
<div class="frule">${esc(s.kind)}${s.location ? ` · <span class="fep">${esc(s.location)}</span>` : ''}</div>
|
|
142
|
+
<p>${esc(s.label)} — ${esc(s.why)}</p>
|
|
143
|
+
</div>
|
|
144
|
+
</div>`,
|
|
145
|
+
)
|
|
146
|
+
.join('')
|
|
147
|
+
: '<p class="clean">Nothing hidden — every route, effect target and guard SPARDA saw was fully resolved.</p>';
|
|
148
|
+
|
|
149
|
+
return `<!doctype html>
|
|
150
|
+
<html lang="en"><head><meta charset="utf-8">
|
|
151
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
152
|
+
<title>SPARDA dossier — ${esc(d.app)}</title>
|
|
153
|
+
<style>
|
|
154
|
+
:root{--bg:oklch(0.16 0.01 255);--panel:oklch(0.22 0.01 255);--line:oklch(0.32 0.01 255);
|
|
155
|
+
--ink:oklch(0.95 0.01 255);--soft:oklch(0.75 0.01 255);--faint:oklch(0.55 0.01 255);
|
|
156
|
+
--red:oklch(0.65 0.20 20);--green:oklch(0.75 0.15 150);--amber:oklch(0.75 0.18 70);
|
|
157
|
+
--mono:"Geist Mono",ui-monospace,Menlo,Consolas,monospace;
|
|
158
|
+
--sans:Inter,Geist,-apple-system,system-ui,"Segoe UI",Roboto,sans-serif;}
|
|
159
|
+
@media(prefers-color-scheme:light){:root{--bg:oklch(0.98 0.005 255);--panel:oklch(1 0 0);
|
|
160
|
+
--line:oklch(0.90 0.01 255);--ink:oklch(0.20 0.01 255);--soft:oklch(0.40 0.01 255);
|
|
161
|
+
--faint:oklch(0.60 0.01 255);--red:oklch(0.55 0.20 20);--green:oklch(0.50 0.15 150);--amber:oklch(0.60 0.18 70);}}
|
|
162
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);line-height:1.55;-webkit-font-smoothing:antialiased}
|
|
163
|
+
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 80px}
|
|
164
|
+
.top{display:flex;justify-content:space-between;align-items:baseline;border-bottom:1px solid var(--line);padding-bottom:16px;font-family:var(--mono);font-size:13px;color:var(--faint)}
|
|
165
|
+
.top b{color:var(--ink);letter-spacing:.12em}
|
|
166
|
+
.hero{padding:48px 0 32px}
|
|
167
|
+
.stamp{display:inline-flex;align-items:center;gap:12px;font-family:var(--mono);font-weight:700;font-size:16px;padding:12px 24px;border-radius:12px;border:1.5px solid;letter-spacing:.03em;box-shadow:0 4px 12px -4px color-mix(in oklch,currentColor 30%,transparent)}
|
|
168
|
+
.stamp .dot{width:8px;height:8px;border-radius:50%;background:currentColor;box-shadow:0 0 0 4px color-mix(in oklch,currentColor 22%,transparent)}
|
|
169
|
+
.ok{color:var(--green);border-color:var(--green);background:color-mix(in oklch,var(--green) 10%,transparent)}
|
|
170
|
+
.bad{color:var(--red);border-color:var(--red);background:color-mix(in oklch,var(--red) 10%,transparent)}
|
|
171
|
+
.warn{color:var(--amber);border-color:var(--amber);background:color-mix(in oklch,var(--amber) 10%,transparent)}
|
|
172
|
+
.hero p{font-size:18px;color:var(--soft);max-width:65ch;margin:24px 0 0;text-wrap:balance}
|
|
173
|
+
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:16px;margin:32px 0}
|
|
174
|
+
.stat{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:20px;transition:transform 200ms cubic-bezier(0.16,1,0.3,1),border-color 200ms}
|
|
175
|
+
.stat:hover{transform:translateY(-2px);border-color:var(--soft)}
|
|
176
|
+
.stat b{display:block;font-size:24px;font-family:var(--mono);letter-spacing:-0.02em;line-height:1.2}.stat span{font-size:13px;color:var(--faint);font-weight:500}
|
|
177
|
+
h2{font-size:16px;letter-spacing:-0.01em;margin:48px 0 16px;color:var(--ink)}
|
|
178
|
+
h2 small{color:var(--faint);font-weight:400;font-family:var(--mono)}
|
|
179
|
+
table{border-collapse:collapse;font-family:var(--mono);font-size:13px;width:100%;overflow-x:auto;display:block}
|
|
180
|
+
table thead th{color:var(--faint);font-weight:500;padding:8px 12px;text-align:center;font-size:11px;border-bottom:1px solid var(--line)}
|
|
181
|
+
table tbody th{text-align:left;padding:12px 16px 12px 8px;color:var(--ink);font-weight:500;white-space:nowrap}
|
|
182
|
+
table tbody tr{border-bottom:1px solid var(--line);transition:background-color 150ms}
|
|
183
|
+
table tbody tr:hover{background-color:color-mix(in oklch,var(--panel) 60%,transparent)}
|
|
184
|
+
table td{text-align:center;padding:12px 8px;font-weight:700}
|
|
185
|
+
td.neg{color:var(--red)}td.pos{color:var(--green)}td.na{color:var(--faint)}
|
|
186
|
+
tr.row-bad th{color:var(--red)}
|
|
187
|
+
.finding{display:flex;gap:0;background:var(--panel);border:1px solid var(--line);border-left:4px solid var(--sev);border-radius:12px;margin:16px 0;overflow:hidden;transition:transform 200ms cubic-bezier(0.16,1,0.3,1),box-shadow 200ms}
|
|
188
|
+
.finding:hover{transform:translateY(-2px);box-shadow:0 8px 24px -8px color-mix(in oklch,var(--bg) 80%,transparent)}
|
|
189
|
+
.finding .sev{font-family:var(--mono);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--sev);padding:16px;align-self:center}
|
|
190
|
+
.finding .fbody{padding:16px 16px 16px 0}
|
|
191
|
+
.frule{font-family:var(--mono);font-size:13px;color:var(--ink)}.fep{color:var(--faint)}
|
|
192
|
+
.finding p{margin:8px 0 0;font-size:14px;color:var(--soft);line-height:1.6}
|
|
193
|
+
.clean{color:var(--green);font-family:var(--mono)}
|
|
194
|
+
.legend{font-family:var(--mono);font-size:12px;color:var(--faint);margin-top:12px}
|
|
195
|
+
.legend b.pos{color:var(--green)}.legend b.neg{color:var(--red)}
|
|
196
|
+
footer{margin-top:64px;padding-top:24px;border-top:1px solid var(--line);font-family:var(--mono);font-size:12px;color:var(--faint);display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px}
|
|
197
|
+
</style></head><body><div class="wrap">
|
|
198
|
+
<div class="top"><b>SPARDA</b><span>AI writes. SPARDA proves.</span></div>
|
|
199
|
+
|
|
200
|
+
<section class="hero">
|
|
201
|
+
<span class="stamp ${verdictClass}"><span class="dot"></span>${esc(verdictText)}</span>
|
|
202
|
+
<p>${esc(verdictLede)}</p>
|
|
203
|
+
<div class="stats">
|
|
204
|
+
${statCard(d.routes, 'routes')}
|
|
205
|
+
${statCard(d.nodes, 'graph nodes')}
|
|
206
|
+
${statCard(d.tables, 'data tables')}
|
|
207
|
+
${statCard(d.capsuleBytes + ' B', 'safety capsule')}
|
|
208
|
+
${statCard(esc(d.framework), 'framework')}
|
|
209
|
+
</div>
|
|
210
|
+
${
|
|
211
|
+
d.guards
|
|
212
|
+
? `<p class="legend">${d.guardsVerified}/${d.guards} guard(s) <b class="pos">verified</b> — SPARDA saw a real deny path in the body; the rest are asserted by name (opaque middleware/decorators it could not read).</p>`
|
|
213
|
+
: ''
|
|
214
|
+
}
|
|
215
|
+
</section>
|
|
216
|
+
|
|
217
|
+
<h2>The safety matrix <small>each route × five obligations · − exposed · · n/a · + protected</small></h2>
|
|
218
|
+
<table><thead><tr><th style="text-align:left">route</th>${AXES.map((a) => `<th>${esc(a)}</th>`).join('')}</tr></thead>
|
|
219
|
+
<tbody>${matrixRows || '<tr><td>—</td></tr>'}</tbody></table>
|
|
220
|
+
<p class="legend"><b class="neg">−</b> a protection is missing · the obligation doesn’t apply <b class="pos">+</b> the protection is present</p>
|
|
221
|
+
|
|
222
|
+
<h2>What SPARDA found</h2>
|
|
223
|
+
${findingCards}
|
|
224
|
+
|
|
225
|
+
<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>
|
|
226
|
+
${blindRows}
|
|
227
|
+
|
|
228
|
+
<footer>
|
|
229
|
+
<span>graph ${esc(d.sourceHash)} · ${esc(d.nodes)} nodes / ${esc(d.edges)} edges</span>
|
|
230
|
+
<span>generated by SPARDA · residual-labs.fr</span>
|
|
231
|
+
</footer>
|
|
232
|
+
</div></body></html>`;
|
|
233
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// commands/genome.js — contribute this app's proofs to the world immune memory (ADR-041).
|
|
2
|
+
// Brick 2: the capsule (immunize) is one app's frozen judgment; the genome is how that
|
|
3
|
+
// judgment travels between strangers' machines as SIGNED, self-verifying antibodies —
|
|
4
|
+
// zero infrastructure, git as the whole backplane.
|
|
5
|
+
// sparda genome mint from this app, merge into sparda-genome.jsonl, report
|
|
6
|
+
// sparda genome --json print this app's freshly-minted antibodies to stdout
|
|
7
|
+
// The signing identity (an Ed25519 keypair) lives in .sparda/genome.key — PRIVATE, and
|
|
8
|
+
// .sparda/ is gitignored, so the private half can never be committed. The public half
|
|
9
|
+
// rides inside every antibody. The genome file itself is meant to be committed & shared.
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { compileUBG } from '../ubg/compile.js';
|
|
13
|
+
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
14
|
+
import { buildCapsule } from '../ubg/immunity.js';
|
|
15
|
+
import {
|
|
16
|
+
generateIdentity,
|
|
17
|
+
mintGenome,
|
|
18
|
+
mergeGenome,
|
|
19
|
+
parseGenome,
|
|
20
|
+
serializeGenome,
|
|
21
|
+
emptyGenome,
|
|
22
|
+
recall,
|
|
23
|
+
} from '../ubg/genome.js';
|
|
24
|
+
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
25
|
+
|
|
26
|
+
const GENOME_FILE = 'sparda-genome.jsonl';
|
|
27
|
+
const KEY_FILE = 'genome.key';
|
|
28
|
+
|
|
29
|
+
export async function runGenome(opts) {
|
|
30
|
+
const canonical = canonicalizeGraph(
|
|
31
|
+
compileUBG(opts.cwd, { write: false, openapi: opts.openapi }).graph,
|
|
32
|
+
);
|
|
33
|
+
const capsule = buildCapsule(canonical);
|
|
34
|
+
const identity = loadOrCreateIdentity(opts.cwd);
|
|
35
|
+
const minted = mintGenome(capsule, identity);
|
|
36
|
+
|
|
37
|
+
if (opts.json) {
|
|
38
|
+
console.log(JSON.stringify(minted, null, 2));
|
|
39
|
+
return { minted, identity: { issuer: identity.issuer } };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!minted.length) {
|
|
43
|
+
console.log(
|
|
44
|
+
'✗ NO ANTIBODIES — 0 routes carried a behaviorHash; nothing to contribute (a coverage gap, not a pass).',
|
|
45
|
+
);
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
return { minted, identity: { issuer: identity.issuer } };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// the genome file is the shared memory: load what's there, merge our contribution,
|
|
51
|
+
// write it back. git push/pull is the replication — there is no server anywhere.
|
|
52
|
+
const genomePath = path.join(opts.cwd, GENOME_FILE);
|
|
53
|
+
const existing = fs.existsSync(genomePath)
|
|
54
|
+
? parseGenome(fs.readFileSync(genomePath, 'utf8')).genome
|
|
55
|
+
: emptyGenome();
|
|
56
|
+
const { genome, added, corroborated } = mergeGenome(existing, minted);
|
|
57
|
+
atomicWrite(genomePath, serializeGenome(genome));
|
|
58
|
+
|
|
59
|
+
console.log(
|
|
60
|
+
`GENOME — signed ${minted.length} antibody(ies) as ${identity.issuer} (Ed25519, self-verifying)`,
|
|
61
|
+
);
|
|
62
|
+
console.log(
|
|
63
|
+
` merged: +${added} new, ${corroborated} corroborated → ${genome.antibodies.length} antibody(ies) in ${GENOME_FILE}`,
|
|
64
|
+
);
|
|
65
|
+
// surface any behavior the world now disagrees about — load-bearing signal
|
|
66
|
+
const conflicts = distinctHashes(genome).filter((h) => recall(genome, h).conflict);
|
|
67
|
+
if (conflicts.length)
|
|
68
|
+
console.log(
|
|
69
|
+
` ⚠ ${conflicts.length} behavior(s) with conflicting verdicts across issuers — inspect before trusting.`,
|
|
70
|
+
);
|
|
71
|
+
console.log(
|
|
72
|
+
` ${GENOME_FILE} is committable; .sparda/${KEY_FILE} is your PRIVATE key (gitignored) — never share it.`,
|
|
73
|
+
);
|
|
74
|
+
return { minted, genome, outPath: genomePath, identity: { issuer: identity.issuer } };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// the local proving identity. Created once, reused forever (carry-over is sacred —
|
|
78
|
+
// a stable issuer is how reputation accrues to this install across sessions).
|
|
79
|
+
function loadOrCreateIdentity(cwd) {
|
|
80
|
+
const keyPath = path.join(cwd, '.sparda', KEY_FILE);
|
|
81
|
+
if (fs.existsSync(keyPath)) {
|
|
82
|
+
try {
|
|
83
|
+
const saved = JSON.parse(fs.readFileSync(keyPath, 'utf8'));
|
|
84
|
+
if (saved.publicKey && saved.privateKey && saved.issuer) return saved;
|
|
85
|
+
} catch {
|
|
86
|
+
// corrupt key file — fall through and mint a fresh identity
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const identity = generateIdentity();
|
|
90
|
+
fs.mkdirSync(path.dirname(keyPath), { recursive: true });
|
|
91
|
+
ensureSpardaIgnored(cwd); // the key is a SECRET — make the "gitignored" claim true
|
|
92
|
+
atomicWrite(keyPath, JSON.stringify(identity, null, 2) + '\n');
|
|
93
|
+
return identity;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// guarantee `.sparda/` (which holds the private key) is git-ignored before we write
|
|
97
|
+
// the key — a leaked signing key lets anyone forge antibodies under this issuer.
|
|
98
|
+
function ensureSpardaIgnored(cwd) {
|
|
99
|
+
const gi = path.join(cwd, '.gitignore');
|
|
100
|
+
const line = '.sparda/';
|
|
101
|
+
try {
|
|
102
|
+
if (fs.existsSync(gi)) {
|
|
103
|
+
const content = fs.readFileSync(gi, 'utf8');
|
|
104
|
+
if (!content.split(/\r?\n/).includes(line)) fs.appendFileSync(gi, `\n${line}\n`);
|
|
105
|
+
} else {
|
|
106
|
+
fs.writeFileSync(gi, `${line}\n`);
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// best-effort: if we can't touch .gitignore, the key is still only written to
|
|
110
|
+
// .sparda/ — the user's own ignore rules govern; we simply couldn't add ours.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function distinctHashes(genome) {
|
|
115
|
+
return [...new Set((genome.antibodies ?? []).map((a) => a.behaviorHash))];
|
|
116
|
+
}
|
package/src/commands/immunize.js
CHANGED
|
@@ -44,13 +44,17 @@ export async function runImmunize(opts) {
|
|
|
44
44
|
const exposed = AXES.filter((a) => capsule.posture[a].exposed > 0)
|
|
45
45
|
.map((a) => `${a}×${capsule.posture[a].exposed}`)
|
|
46
46
|
.join(', ');
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
const verdictText = capsule.surfaceOnly
|
|
48
|
+
? '● SURFACE ONLY — routes seen, no behavior resolved (nothing to prove)'
|
|
49
|
+
: capsule.proven
|
|
50
|
+
? '✓ PROVEN'
|
|
51
|
+
: '✗ NOT PROVEN';
|
|
52
|
+
console.log(` verdict: ${verdictText}` + (exposed ? ` — exposed: ${exposed}` : ''));
|
|
51
53
|
console.log(
|
|
52
54
|
` written: .sparda/immunity.json (${wire} bytes on the wire) — portable, offline, lookup by behaviorHash`,
|
|
53
55
|
);
|
|
54
|
-
|
|
56
|
+
// surface-only is not a risk (nothing to fault) → don't fail CI; only a real
|
|
57
|
+
// unproven capsule (an exposed axis) gates.
|
|
58
|
+
if (!capsule.proven && !capsule.surfaceOnly) process.exitCode = 1;
|
|
55
59
|
return { capsule, outPath };
|
|
56
60
|
}
|
package/src/detect.js
CHANGED
|
@@ -41,7 +41,18 @@ export function detectStack(cwd) {
|
|
|
41
41
|
expressVersion: deps.express,
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
|
-
//
|
|
44
|
+
// Medusa: file-based routing under src/api — a route IS a file
|
|
45
|
+
// (`src/api/<path>/route.ts`), not an `app.get()` or a @Controller. Checked
|
|
46
|
+
// before Nest because a Medusa app may transitively pull Nest deps.
|
|
47
|
+
if (deps['@medusajs/medusa'] || deps['@medusajs/framework']) {
|
|
48
|
+
const apiDir = ['src/api', 'api'].find((d) => {
|
|
49
|
+
const abs = path.join(cwd, d);
|
|
50
|
+
return fs.existsSync(abs) && fs.statSync(abs).isDirectory();
|
|
51
|
+
});
|
|
52
|
+
if (apiDir) return { framework: 'medusa', entryFile: apiDir, port: 9000 };
|
|
53
|
+
// Medusa dep but no api dir: fall through (a plugin/lib package, not an app).
|
|
54
|
+
}
|
|
55
|
+
// NestJS (and DI-framework) apps: routes live in @Controller classes,
|
|
45
56
|
// scanned from the source tree rather than followed from a single entry.
|
|
46
57
|
if (deps['@nestjs/core'] || deps['@nestjs/common']) {
|
|
47
58
|
const entryFile = ['src', 'app', '.'].find((d) => {
|
|
@@ -209,6 +220,7 @@ function findExpressEntry(cwd, pkg) {
|
|
|
209
220
|
'src/server.ts',
|
|
210
221
|
'src/index.ts',
|
|
211
222
|
'src/main.ts', // Nx / NestJS-style monorepo layout
|
|
223
|
+
'src/commands/start.ts',
|
|
212
224
|
'main.ts',
|
|
213
225
|
'app.ts',
|
|
214
226
|
'server.ts',
|
|
@@ -231,12 +243,84 @@ function findExpressEntry(cwd, pkg) {
|
|
|
231
243
|
return path.relative(cwd, abs).split(path.sep).join('/');
|
|
232
244
|
}
|
|
233
245
|
}
|
|
246
|
+
// No standard-named entry matched — the app's entry is a non-conventional file
|
|
247
|
+
// (`ParseServer.ts`, `bootstrap.ts`, `application.ts`, …). Scan the tree for the
|
|
248
|
+
// file that actually creates the app (a bare `express()` call), the same fallback
|
|
249
|
+
// FastAPI detection already uses. Ranked so a real server (`.listen()`) wins.
|
|
250
|
+
const found = searchExpressEntry(cwd);
|
|
251
|
+
if (found) return found;
|
|
234
252
|
throw err(
|
|
235
253
|
'Could not locate your Express entry file (the one calling express()).',
|
|
236
254
|
'Re-run from the project root, or open an issue with your layout.',
|
|
237
255
|
);
|
|
238
256
|
}
|
|
239
257
|
|
|
258
|
+
// Bounded source-tree scan for the app-creation file: a bare `express()` call (not
|
|
259
|
+
// `express.Router()`/`express.static()`). Ranked: a file that also `.listen()`s (a
|
|
260
|
+
// real server entry) beats a library file; then shallower path; then alphabetical —
|
|
261
|
+
// deterministic. Caps the number of files read so a giant repo can't stall detection.
|
|
262
|
+
function searchExpressEntry(cwd) {
|
|
263
|
+
const EXCLUDE = new Set([
|
|
264
|
+
'node_modules',
|
|
265
|
+
'.git',
|
|
266
|
+
'dist',
|
|
267
|
+
'build',
|
|
268
|
+
'coverage',
|
|
269
|
+
'.next',
|
|
270
|
+
'.sparda',
|
|
271
|
+
'test',
|
|
272
|
+
'tests',
|
|
273
|
+
'__tests__',
|
|
274
|
+
'spec',
|
|
275
|
+
'examples',
|
|
276
|
+
'example',
|
|
277
|
+
]);
|
|
278
|
+
const APP_CALL = /(?<![.\w])express\s*\(\s*\)/; // `express()` app factory
|
|
279
|
+
const matches = [];
|
|
280
|
+
const budget = { files: 0 };
|
|
281
|
+
const walk = (dir) => {
|
|
282
|
+
if (budget.files > 400) return;
|
|
283
|
+
let entries;
|
|
284
|
+
try {
|
|
285
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
286
|
+
} catch {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
for (const e of entries) {
|
|
290
|
+
if (e.name.startsWith('.') && e.name !== '.') continue;
|
|
291
|
+
const abs = path.join(dir, e.name);
|
|
292
|
+
if (e.isDirectory()) {
|
|
293
|
+
if (!EXCLUDE.has(e.name)) walk(abs);
|
|
294
|
+
} else if (/\.(m?[tj]s|cjs)$/.test(e.name) && !/\.d\.ts$/.test(e.name)) {
|
|
295
|
+
if (budget.files++ > 400) return;
|
|
296
|
+
let src;
|
|
297
|
+
try {
|
|
298
|
+
src = fs.readFileSync(abs, 'utf8');
|
|
299
|
+
} catch {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (APP_CALL.test(src)) {
|
|
303
|
+
const rel = path.relative(cwd, abs).split(path.sep).join('/');
|
|
304
|
+
matches.push({
|
|
305
|
+
rel,
|
|
306
|
+
listens: /\.listen\s*\(/.test(src),
|
|
307
|
+
depth: rel.split('/').length,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
walk(cwd);
|
|
314
|
+
if (!matches.length) return null;
|
|
315
|
+
matches.sort(
|
|
316
|
+
(a, b) =>
|
|
317
|
+
Number(b.listens) - Number(a.listens) ||
|
|
318
|
+
a.depth - b.depth ||
|
|
319
|
+
(a.rel < b.rel ? -1 : 1),
|
|
320
|
+
);
|
|
321
|
+
return matches[0].rel;
|
|
322
|
+
}
|
|
323
|
+
|
|
240
324
|
function detectModuleType(cwd, pkg, entryFile) {
|
|
241
325
|
if (entryFile.endsWith('.mjs')) return 'esm';
|
|
242
326
|
if (entryFile.endsWith('.cjs')) return 'cjs';
|