sparda-mcp 0.17.1 → 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.
- package/demo-app/.sparda/immunity.json +1 -1
- package/package.json +1 -1
- package/src/commands/apocalypse.js +7 -0
- package/src/commands/dossier.js +209 -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 +12 -0
- package/src/ubg/apocalypse.js +37 -1
- package/src/ubg/compile.js +2 -0
- package/src/ubg/express.js +91 -2
- package/src/ubg/extract.js +300 -6
- 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 +184 -54
- package/src/ubg/nextjs.js +61 -2
- package/src/ubg/translate.js +20 -6
|
@@ -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
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 · the obligation doesn’t apply <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
|
+
}
|
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';
|
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)
|
package/src/ubg/apocalypse.js
CHANGED
|
@@ -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
|
|
package/src/ubg/compile.js
CHANGED
|
@@ -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),
|