sparda-mcp 0.17.0 → 0.26.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.
@@ -1 +1 @@
1
- {"v":"imm1","proven":true,"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,"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.17.0",
3
+ "version": "0.26.0",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
5
  "description": "Compile backends to behavior graphs. Statically prove security, simulate APIs, and replay bugs.",
6
6
  "type": "module",
@@ -78,6 +78,13 @@ export async function runApocalypse(opts) {
78
78
  ` no route call sites reached — routes are likely registered indirectly (a loader / DI pattern the static walk can't follow).`,
79
79
  );
80
80
  }
81
+ } else if (verdict.surfaceOnly) {
82
+ console.log(
83
+ `● 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.`,
84
+ );
85
+ if (opts.verbose && report.skipped?.length)
86
+ for (const s of report.skipped)
87
+ console.log(` skipped: ${s.reason}${s.file ? ` (${s.file})` : ''}`);
81
88
  } else if (verdict.clean) {
82
89
  console.log(
83
90
  `✓ PROVEN — ${obligations} obligation(s) discharged, zero violations. No declared guard, invariant, transaction or aggregate boundary can be broken by this tree.`,
@@ -0,0 +1,209 @@
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 { buildCapsule } from '../ubg/immunity.js';
14
+ import { AXES, POLARITY_SYMBOL, exposedAxes } from '../ubg/polarity.js';
15
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
16
+
17
+ export async function runDossier(opts) {
18
+ const compiled = compileUBG(opts.cwd, { write: false, openapi: opts.openapi });
19
+ const canonical = canonicalizeGraph(compiled.graph);
20
+ const { findings, polarity } = checkGraph(canonical);
21
+ const verdict = verdictOf(findings, canonical);
22
+ const capsule = buildCapsule(canonical);
23
+
24
+ const data = {
25
+ app: path.basename(path.resolve(opts.cwd)) || 'app',
26
+ framework: compiled.report.framework,
27
+ routes: compiled.report.routes,
28
+ tables: compiled.report.tables,
29
+ nodes: canonical.nodes.length,
30
+ edges: canonical.edges.length,
31
+ provable: verdict.provable,
32
+ proven: verdict.provable && verdict.clean,
33
+ surfaceOnly: verdict.surfaceOnly,
34
+ guards: verdict.guards,
35
+ guardsVerified: verdict.guardsVerified,
36
+ counts: verdict.counts,
37
+ findings,
38
+ polarity: polarity.map((p) => ({ entrypoint: p.entrypoint, vector: p.vector })),
39
+ posture: capsule.posture,
40
+ capsuleBytes: capsule.bytes,
41
+ sourceHash: (canonical.meta?.sourceHash ?? '').slice(0, 12),
42
+ };
43
+
44
+ const html = renderDossierHTML(data);
45
+
46
+ if (opts.json) {
47
+ console.log(JSON.stringify(data, null, 2));
48
+ return { data };
49
+ }
50
+
51
+ const outPath = path.join(opts.cwd, '.sparda', 'dossier.html');
52
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
53
+ atomicWrite(outPath, html);
54
+ console.log(
55
+ `DOSSIER — ${data.routes} route(s), verdict ${data.proven ? '✓ PROVEN' : data.provable ? '✗ NOT PROVEN' : '✗ NO PROOF'}`,
56
+ );
57
+ console.log(
58
+ ` written: .sparda/dossier.html (${html.length} bytes, self-contained) — open it in any browser.`,
59
+ );
60
+ console.log(
61
+ ` ephemeral: .sparda/ is gitignored, so it vanishes on \`sparda remove\` — save it to keep it.`,
62
+ );
63
+ return { data, outPath };
64
+ }
65
+
66
+ // ── pure renderer: proof data → one self-contained HTML document ────────────
67
+
68
+ const esc = (s) =>
69
+ String(s ?? '').replace(
70
+ /[&<>"']/g,
71
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c],
72
+ );
73
+ const short = (id) => esc(String(id).replace(/^entrypoint:/, ''));
74
+
75
+ const SEV_COLOR = {
76
+ critical: '#e5484d',
77
+ high: '#e5484d',
78
+ medium: '#e5a23d',
79
+ info: '#8b8f9a',
80
+ };
81
+
82
+ export function renderDossierHTML(d) {
83
+ const verdictClass = d.proven ? 'ok' : d.surfaceOnly ? 'warn' : 'bad';
84
+ const verdictText = d.proven
85
+ ? 'PROVEN'
86
+ : d.surfaceOnly
87
+ ? 'SURFACE ONLY — routes seen, no behavior resolved'
88
+ : d.provable
89
+ ? 'NOT PROVEN'
90
+ : 'NO PROOF — the app’s routes could not be read';
91
+ const verdictLede = d.proven
92
+ ? 'No declared guard, invariant, transaction or aggregate boundary can be broken by this code.'
93
+ : d.surfaceOnly
94
+ ? '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).'
95
+ : d.provable
96
+ ? `SPARDA found ${d.counts.critical} critical and ${d.counts.high} high risk(s) that must be fixed before this ships.`
97
+ : 'SPARDA could not see this app’s route surface — this is not a clean bill of health.';
98
+
99
+ const cell = (v) => {
100
+ const cls = v === -1 ? 'neg' : v === 1 ? 'pos' : 'na';
101
+ return `<td class="${cls}" title="${v === -1 ? 'exposed' : v === 1 ? 'protected' : 'n/a'}">${POLARITY_SYMBOL[v]}</td>`;
102
+ };
103
+ const matrixRows = d.polarity
104
+ .map((p) => {
105
+ const flagged = exposedAxes(p.vector).length ? ' class="row-bad"' : '';
106
+ return `<tr${flagged}><th>${short(p.entrypoint)}</th>${AXES.map((a) => cell(p.vector[a])).join('')}</tr>`;
107
+ })
108
+ .join('');
109
+
110
+ const findingCards = d.findings.length
111
+ ? d.findings
112
+ .map(
113
+ (f) => `
114
+ <div class="finding" style="--sev:${SEV_COLOR[f.severity] ?? '#8b8f9a'}">
115
+ <div class="sev">${esc(f.severity)}</div>
116
+ <div class="fbody">
117
+ <div class="frule">${esc(f.rule)} · <span class="fep">${short(f.entrypoint)}</span></div>
118
+ <p>${esc(f.message)}</p>
119
+ </div>
120
+ </div>`,
121
+ )
122
+ .join('')
123
+ : '<p class="clean">No risks found — every proof obligation was discharged.</p>';
124
+
125
+ const statCard = (n, label) =>
126
+ `<div class="stat"><b>${esc(n)}</b><span>${esc(label)}</span></div>`;
127
+
128
+ return `<!doctype html>
129
+ <html lang="en"><head><meta charset="utf-8">
130
+ <meta name="viewport" content="width=device-width, initial-scale=1">
131
+ <title>SPARDA dossier — ${esc(d.app)}</title>
132
+ <style>
133
+ :root{--bg:oklch(0.16 0.01 255);--panel:oklch(0.22 0.01 255);--line:oklch(0.32 0.01 255);
134
+ --ink:oklch(0.95 0.01 255);--soft:oklch(0.75 0.01 255);--faint:oklch(0.55 0.01 255);
135
+ --red:oklch(0.65 0.20 20);--green:oklch(0.75 0.15 150);--amber:oklch(0.75 0.18 70);
136
+ --mono:"Geist Mono",ui-monospace,Menlo,Consolas,monospace;
137
+ --sans:Inter,Geist,-apple-system,system-ui,"Segoe UI",Roboto,sans-serif;}
138
+ @media(prefers-color-scheme:light){:root{--bg:oklch(0.98 0.005 255);--panel:oklch(1 0 0);
139
+ --line:oklch(0.90 0.01 255);--ink:oklch(0.20 0.01 255);--soft:oklch(0.40 0.01 255);
140
+ --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);}}
141
+ *{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}
142
+ .wrap{max-width:960px;margin:0 auto;padding:48px 24px 80px}
143
+ .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)}
144
+ .top b{color:var(--ink);letter-spacing:.12em}
145
+ .hero{padding:48px 0 32px}
146
+ .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)}
147
+ .stamp .dot{width:8px;height:8px;border-radius:50%;background:currentColor;box-shadow:0 0 0 4px color-mix(in oklch,currentColor 22%,transparent)}
148
+ .ok{color:var(--green);border-color:var(--green);background:color-mix(in oklch,var(--green) 10%,transparent)}
149
+ .bad{color:var(--red);border-color:var(--red);background:color-mix(in oklch,var(--red) 10%,transparent)}
150
+ .warn{color:var(--amber);border-color:var(--amber);background:color-mix(in oklch,var(--amber) 10%,transparent)}
151
+ .hero p{font-size:18px;color:var(--soft);max-width:65ch;margin:24px 0 0;text-wrap:balance}
152
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:16px;margin:32px 0}
153
+ .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}
154
+ .stat:hover{transform:translateY(-2px);border-color:var(--soft)}
155
+ .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}
156
+ h2{font-size:16px;letter-spacing:-0.01em;margin:48px 0 16px;color:var(--ink)}
157
+ h2 small{color:var(--faint);font-weight:400;font-family:var(--mono)}
158
+ table{border-collapse:collapse;font-family:var(--mono);font-size:13px;width:100%;overflow-x:auto;display:block}
159
+ table thead th{color:var(--faint);font-weight:500;padding:8px 12px;text-align:center;font-size:11px;border-bottom:1px solid var(--line)}
160
+ table tbody th{text-align:left;padding:12px 16px 12px 8px;color:var(--ink);font-weight:500;white-space:nowrap}
161
+ table tbody tr{border-bottom:1px solid var(--line);transition:background-color 150ms}
162
+ table tbody tr:hover{background-color:color-mix(in oklch,var(--panel) 60%,transparent)}
163
+ table td{text-align:center;padding:12px 8px;font-weight:700}
164
+ td.neg{color:var(--red)}td.pos{color:var(--green)}td.na{color:var(--faint)}
165
+ tr.row-bad th{color:var(--red)}
166
+ .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}
167
+ .finding:hover{transform:translateY(-2px);box-shadow:0 8px 24px -8px color-mix(in oklch,var(--bg) 80%,transparent)}
168
+ .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}
169
+ .finding .fbody{padding:16px 16px 16px 0}
170
+ .frule{font-family:var(--mono);font-size:13px;color:var(--ink)}.fep{color:var(--faint)}
171
+ .finding p{margin:8px 0 0;font-size:14px;color:var(--soft);line-height:1.6}
172
+ .clean{color:var(--green);font-family:var(--mono)}
173
+ .legend{font-family:var(--mono);font-size:12px;color:var(--faint);margin-top:12px}
174
+ .legend b.pos{color:var(--green)}.legend b.neg{color:var(--red)}
175
+ 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}
176
+ </style></head><body><div class="wrap">
177
+ <div class="top"><b>SPARDA</b><span>AI writes. SPARDA proves.</span></div>
178
+
179
+ <section class="hero">
180
+ <span class="stamp ${verdictClass}"><span class="dot"></span>${esc(verdictText)}</span>
181
+ <p>${esc(verdictLede)}</p>
182
+ <div class="stats">
183
+ ${statCard(d.routes, 'routes')}
184
+ ${statCard(d.nodes, 'graph nodes')}
185
+ ${statCard(d.tables, 'data tables')}
186
+ ${statCard(d.capsuleBytes + ' B', 'safety capsule')}
187
+ ${statCard(esc(d.framework), 'framework')}
188
+ </div>
189
+ ${
190
+ d.guards
191
+ ? `<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>`
192
+ : ''
193
+ }
194
+ </section>
195
+
196
+ <h2>The safety matrix <small>each route × five obligations · − exposed · · n/a · + protected</small></h2>
197
+ <table><thead><tr><th style="text-align:left">route</th>${AXES.map((a) => `<th>${esc(a)}</th>`).join('')}</tr></thead>
198
+ <tbody>${matrixRows || '<tr><td>—</td></tr>'}</tbody></table>
199
+ <p class="legend"><b class="neg">−</b> a protection is missing &nbsp; · &nbsp; the obligation doesn’t apply &nbsp; <b class="pos">+</b> the protection is present</p>
200
+
201
+ <h2>What SPARDA found</h2>
202
+ ${findingCards}
203
+
204
+ <footer>
205
+ <span>graph ${esc(d.sourceHash)} · ${esc(d.nodes)} nodes / ${esc(d.edges)} edges</span>
206
+ <span>generated by SPARDA · residual-labs.fr</span>
207
+ </footer>
208
+ </div></body></html>`;
209
+ }
@@ -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
+ }
@@ -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
- console.log(
48
- ` verdict: ${capsule.proven ? ' PROVEN' : '✗ NOT PROVEN'}` +
49
- (exposed ? ` — exposed: ${exposed}` : ''),
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
- if (!capsule.proven) process.exitCode = 1;
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
- // NestJS (and Medusa/DI-framework) apps: routes live in @Controller classes,
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';
package/src/index.js CHANGED
@@ -137,6 +137,16 @@ try {
137
137
  await runSpeculate(opts);
138
138
  break;
139
139
  }
140
+ case 'dossier': {
141
+ const { runDossier } = await import('./commands/dossier.js');
142
+ await runDossier(opts);
143
+ break;
144
+ }
145
+ case 'genome': {
146
+ const { runGenome } = await import('./commands/genome.js');
147
+ await runGenome(opts);
148
+ break;
149
+ }
140
150
  case 'timeless': {
141
151
  const { runTimeless } = await import('./commands/timeless.js');
142
152
  await runTimeless(opts, rest);
@@ -185,6 +195,8 @@ Usage:
185
195
  npx sparda-mcp polarity Ternary safety matrix per route — proof as arithmetic (--json)
186
196
  npx sparda-mcp immunize Freeze proven safety into a tiny capsule (1 byte/route) — .sparda/immunity.json
187
197
  npx sparda-mcp speculate Re-verify vs the frozen capsule by lookup — full proof only on novel shapes (--json)
198
+ npx sparda-mcp dossier Render the whole proof as one self-contained HTML page anyone can read (.sparda/dossier.html)
199
+ npx sparda-mcp genome Sign this app's proofs into the shared world memory — self-verifying antibodies, zero infra (--json)
188
200
  npx sparda-mcp timeless Replay production requests (list | replay <id> | export <id> → vitest)
189
201
  npx sparda-mcp mirror Serve the compiled graph over HTTP — no framework, no source (--port)
190
202
  npx sparda-mcp openapi Emit an OpenAPI 3.1 spec FROM the graph (--out / --json)
@@ -269,6 +269,24 @@ export function diffGraphs(baseline, candidate) {
269
269
 
270
270
  // ---------------------------------------------------------------------------
271
271
 
272
+ // State-touching behavior SPARDA could actually fault: state nodes + db/http/fs
273
+ // effects. `entropy` (a bare `new Date()`) is not safety-relevant, so it doesn't
274
+ // count — an app of only time-reads has no behavior to prove. This is the effect-level
275
+ // analogue of the provability guard, shared by the verdict and the immunity capsule so
276
+ // the two artifacts never disagree about whether SPARDA saw anything to prove.
277
+ const OBSERVABLE_EFFECT = new Set(['db_write', 'db_read', 'http_call', 'fs_write']);
278
+ export function countObserved(graph) {
279
+ let n = 0;
280
+ for (const node of graph.nodes.values ? graph.nodes.values() : graph.nodes) {
281
+ if (
282
+ node.kind === 'state' ||
283
+ (node.kind === 'effect' && OBSERVABLE_EFFECT.has(node.meta?.effectType))
284
+ )
285
+ n++;
286
+ }
287
+ return n;
288
+ }
289
+
272
290
  export function verdictOf(findings, graph) {
273
291
  const counts = { critical: 0, high: 0, medium: 0, info: 0 };
274
292
  for (const f of findings) counts[f.severity]++;
@@ -283,12 +301,30 @@ export function verdictOf(findings, graph) {
283
301
  ? graph.nodes.filter((n) => n.kind === 'entrypoint').length
284
302
  : null;
285
303
  const provable = entrypoints === null || entrypoints > 0;
304
+ const observed = graph ? countObserved(graph) : null;
305
+ // only assert surface-only for a WHOLE-app proof (graph given with entrypoints);
306
+ // a partial graph (heal's delta, entrypoints===null) keeps the old semantics.
307
+ const surfaceOnly = provable && entrypoints > 0 && observed === 0;
308
+ // guard provenance: how many guards did SPARDA actually VERIFY (see a deny path in
309
+ // the body) vs only assert by name (opaque middleware/decorators it couldn't read).
310
+ // Honest signal — it does not change the verdict, but it tells you how much of the
311
+ // auth posture rests on trust vs proof.
312
+ const guards = graph ? graph.nodes.filter((n) => n.kind === 'guard') : [];
313
+ const guardsVerified = guards.filter((g) => g.meta?.verified).length;
314
+ // `safe` is the CI gate (block a risky deploy): a surface-only app has no
315
+ // critical/high findings and is NOT risky, so it does not fail the gate — it just
316
+ // isn't a positive proof. `clean` is the strong claim (PROVEN) and DOES require
317
+ // observed behavior, so a hollow "everything's fine" can never read as PROVEN.
286
318
  return {
287
319
  counts,
288
320
  entrypoints,
321
+ observed,
289
322
  provable,
323
+ surfaceOnly,
324
+ guards: guards.length,
325
+ guardsVerified,
290
326
  safe: provable && counts.critical === 0 && counts.high === 0,
291
- clean: provable && findings.length === 0,
327
+ clean: provable && !surfaceOnly && findings.length === 0,
292
328
  };
293
329
  }
294
330
 
@@ -7,6 +7,7 @@ import { detectStack } from '../detect.js';
7
7
  import { clearModuleCache } from './extract.js';
8
8
  import { extractExpress } from './express.js';
9
9
  import { extractNest } from './nestjs.js';
10
+ import { extractMedusa } from './medusa.js';
10
11
  import { extractNext } from './nextjs.js';
11
12
  import { extractFastAPI } from './fastapi.js';
12
13
  import { extractOpenAPI } from './openapi.js';
@@ -30,6 +31,7 @@ export function compileUBG(
30
31
  const extractors = {
31
32
  express: () => extractExpress(cwd, stack.entryFile),
32
33
  nestjs: () => extractNest(cwd, stack.entryFile),
34
+ medusa: () => extractMedusa(cwd, stack.entryFile),
33
35
  nextjs: () => extractNext(cwd, stack.entryFile),
34
36
  fastapi: () => extractFastAPI(cwd, stack.entryFile, stack.pythonCmd),
35
37
  openapi: () => extractOpenAPI(cwd, stack.entryFile),