sparda-mcp 0.64.0 → 0.66.1
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 +5 -4
- package/package.json +14 -7
- package/src/commands/apocalypse.js +42 -1
- package/src/commands/gate.js +188 -0
- package/src/detect.js +37 -4
- package/src/index.js +11 -1
- package/src/ubg/apocalypse.js +187 -7
- package/src/ubg/compile.js +3 -0
- package/src/ubg/extract.js +260 -0
- package/src/ubg/fastapi_extract.py +252 -21
- package/src/ubg/nextjs.js +189 -0
- package/src/ubg/passes/state-minimization.js +6 -0
- package/src/ubg/resolve.js +24 -0
- package/src/ubg/translate.js +50 -2
package/README.md
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
<br/>
|
|
8
8
|
|
|
9
|
-
> 🇫🇷 **Français**
|
|
9
|
+
> 🇫🇷 **Français** — _L'IA écrit. SPARDA prouve._ Un gate déterministe et hors-ligne qui détecte quand une modif d'IA retire une garde, expose une route ou casse un invariant — sans clé API, directement dans la boucle d'édition de l'agent. Pour tout comprendre en 10 minutes (douleur, architecture, vision) : [SPARDA-EXPLIQUE.md](docs/SPARDA-EXPLIQUE.md).
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
<h1 align="center">AI writes. SPARDA proves.</h1>
|
|
14
|
+
<p align="center"><em>L'IA écrit. SPARDA prouve.</em></p>
|
|
14
15
|
|
|
15
16
|
**The trust layer for AI-written backends.** SPARDA compiles your backend — routes, database queries, state mutations, guards, side-effects — into one deterministic behavior graph, then **statically proves what can and can't break before you ship**: no unguarded mutation, no broken invariant, no non-atomic aggregate write.
|
|
16
17
|
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
|
|
25
26
|
## 60-second proof
|
|
26
27
|
|
|
27
|
-
From your Express, FastAPI, Next.js, NestJS or Medusa app — nothing to configure:
|
|
28
|
+
From your Express, FastAPI, Flask, Next.js, NestJS or Medusa app — nothing to configure:
|
|
28
29
|
|
|
29
30
|
```bash
|
|
30
31
|
npx sparda-mcp apocalypse # prove the tree is safe to deploy — exit 1 on any real risk
|
|
@@ -52,7 +53,7 @@ Under the hood it compiles your backend into one language-agnostic graph — the
|
|
|
52
53
|
| **`heal`** | _Self-heal, **proven**_ — the gate Copilot Autofix doesn't have: a fix ships **only if** replay matches, `verify` still passes, and `apocalypse` finds no new risk / no dropped guard. Whoever wrote the fix, the machine judges it. |
|
|
53
54
|
| **`badge`** | _The shareable artifact_ — a self-contained SVG badge + README snippet (verdict · coverage · routes) |
|
|
54
55
|
| **`dossier`** | _The public report_ — one self-contained HTML page: verdict, risks, and SPARDA's own blind spots |
|
|
55
|
-
| **`ubg`** | Compile the codebase to its behavior graph (Express · FastAPI · Next.js · NestJS · Medusa natively; **any** stack via OpenAPI)
|
|
56
|
+
| **`ubg`** | Compile the codebase to its behavior graph (Express · FastAPI · Flask · Next.js · NestJS · Medusa natively; **any** stack via OpenAPI) |
|
|
56
57
|
| **`timeless`** | _Time-travel_ — record a production request, replay it byte-identically, export the bug as a test |
|
|
57
58
|
| **`mirror`** | _Execute the graph_ — serve the compiled behavior over HTTP with no framework and no source |
|
|
58
59
|
| **`init` / `dev`** | _Runtime, optional_ — expose the graph to AI clients as a live MCP server (+ Twin, Immune, Evolution) |
|
|
@@ -212,7 +213,7 @@ The gate is honest in both directions: an unfixed bug, or a "fix" that silently
|
|
|
212
213
|
|
|
213
214
|
## Any Backend On Earth: OpenAPI Lowering
|
|
214
215
|
|
|
215
|
-
SPARDA parses Express, FastAPI and Next.js natively — and **every other stack through the format the industry already agreed on**. Go, Java, Rails, Laravel, .NET: if it has an OpenAPI spec, it compiles.
|
|
216
|
+
SPARDA parses Express, FastAPI, Flask and Next.js natively — and **every other stack through the format the industry already agreed on**. Go, Java, Rails, Laravel, .NET: if it has an OpenAPI spec, it compiles.
|
|
216
217
|
|
|
217
218
|
```bash
|
|
218
219
|
npx sparda-mcp ubg --openapi openapi.json
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparda-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.1",
|
|
4
4
|
"mcpName": "io.github.zyx77550/sparda-mcp",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "AI writes. SPARDA proves. A deterministic, offline gate that catches when an AI edit removes a guard, exposes a route, or breaks an invariant — no API key, right in the agent edit loop.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"url": "https://github.com/zyx77550/sparda/issues"
|
|
14
14
|
},
|
|
15
15
|
"bin": {
|
|
16
|
-
"sparda": "
|
|
16
|
+
"sparda": "src/index.js"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
19
|
"test": "vitest run",
|
|
@@ -44,12 +44,19 @@
|
|
|
44
44
|
},
|
|
45
45
|
"keywords": [
|
|
46
46
|
"mcp",
|
|
47
|
+
"mcp-server",
|
|
47
48
|
"model-context-protocol",
|
|
48
49
|
"claude",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
50
|
+
"claude-code",
|
|
51
|
+
"ai-code-review",
|
|
52
|
+
"code-review",
|
|
53
|
+
"guardrails",
|
|
54
|
+
"static-analysis",
|
|
55
|
+
"security",
|
|
56
|
+
"authorization",
|
|
57
|
+
"bola",
|
|
58
|
+
"ai-agents",
|
|
59
|
+
"trust-layer"
|
|
53
60
|
],
|
|
54
61
|
"author": "Residual Labs (residual-labs.fr)",
|
|
55
62
|
"license": "BUSL-1.1",
|
|
@@ -8,12 +8,38 @@
|
|
|
8
8
|
// sparda apocalypse --json raw findings for tooling
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
11
12
|
import { compileUBG } from '../ubg/compile.js';
|
|
12
13
|
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
13
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
checkGraph,
|
|
16
|
+
diffGraphs,
|
|
17
|
+
verdictOf,
|
|
18
|
+
verdictState,
|
|
19
|
+
buildProofObjects,
|
|
20
|
+
} from '../ubg/apocalypse.js';
|
|
14
21
|
import { surveyBlindspots } from '../ubg/blindspots.js';
|
|
15
22
|
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
16
23
|
|
|
24
|
+
// version travels with the proof so an audit knows which prover produced it
|
|
25
|
+
const SPARDA_VERSION = (() => {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(
|
|
28
|
+
fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
|
|
29
|
+
).version;
|
|
30
|
+
} catch {
|
|
31
|
+
return '0.0.0';
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
// a deterministic fingerprint of the canonical graph: same source → same graph → same hash.
|
|
36
|
+
// The proof object binds to it, so a third party proves it audited the SAME artifact.
|
|
37
|
+
function graphHash(canonical) {
|
|
38
|
+
const h = crypto.createHash('sha256');
|
|
39
|
+
h.update(JSON.stringify({ nodes: canonical.nodes, edges: canonical.edges }));
|
|
40
|
+
return 'bh1_' + h.digest('hex').slice(0, 16);
|
|
41
|
+
}
|
|
42
|
+
|
|
17
43
|
const ICONS = { critical: '✗', high: '✗', medium: '⚠', info: '·' };
|
|
18
44
|
|
|
19
45
|
export async function runApocalypse(opts) {
|
|
@@ -61,6 +87,21 @@ export async function runApocalypse(opts) {
|
|
|
61
87
|
);
|
|
62
88
|
}
|
|
63
89
|
|
|
90
|
+
if (opts.proof) {
|
|
91
|
+
const proofPath = path.join(opts.cwd, '.sparda', 'apocalypse.proof.json');
|
|
92
|
+
fs.mkdirSync(path.dirname(proofPath), { recursive: true });
|
|
93
|
+
const proof = {
|
|
94
|
+
sparda_version: SPARDA_VERSION,
|
|
95
|
+
graph_hash: graphHash(canonical),
|
|
96
|
+
verdict: verdictState(verdict),
|
|
97
|
+
discharged: buildProofObjects(canonical),
|
|
98
|
+
};
|
|
99
|
+
atomicWrite(proofPath, JSON.stringify(proof, null, 2) + '\n');
|
|
100
|
+
console.log(
|
|
101
|
+
`✓ Proof written: .sparda/apocalypse.proof.json (${proof.discharged.length} discharged obligation(s), re-verifiable against ${proof.graph_hash})`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
64
105
|
if (opts.json) {
|
|
65
106
|
console.log(
|
|
66
107
|
JSON.stringify({ verdict, obligations, findings, blindspots: blind }, null, 2),
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// commands/gate.js — the agent edit-loop gate: did THIS edit lose a protection
|
|
2
|
+
// or introduce a risk, relative to the armed baseline? Delta-only by design: an
|
|
3
|
+
// edit gate must never scream about pre-existing state (that noise is fatal to
|
|
4
|
+
// adoption — see docs/TWO-FALSE-POSITIVE-CLASSES-2026-07-19.md), so everything
|
|
5
|
+
// already true at arm time is silent and only the regression speaks.
|
|
6
|
+
//
|
|
7
|
+
// The load-bearing subtlety (E-GATE-1): a PostToolUse hook fires after EVERY
|
|
8
|
+
// edit, including the mid-sequence ones that leave a file temporarily
|
|
9
|
+
// unparseable. When a file fails to parse its routes vanish from the graph, so a
|
|
10
|
+
// naive diff reads that as ENTRYPOINT_REMOVED / GUARD_REMOVED and cries a false
|
|
11
|
+
// regression on the agent's own in-progress work. That false alarm, fired
|
|
12
|
+
// constantly, is what gets the gate uninstalled in five minutes. So: a regression
|
|
13
|
+
// whose baseline entrypoint lives in a now-unparseable file is NOT a regression —
|
|
14
|
+
// it is "unproven, come back when it parses". The gate ABSTAINS, never claims
|
|
15
|
+
// clean (it proved nothing) and never claims safe. An unparseable tree has no
|
|
16
|
+
// defined behavior to regress against — abstention is the sound call.
|
|
17
|
+
//
|
|
18
|
+
// Note on PostToolUse semantics: the edit has already landed, so exit 2 does not
|
|
19
|
+
// *prevent* it — it feeds the regression back to the agent as stderr, which the
|
|
20
|
+
// agent reads and corrects in the same session (self-heal in the loop). That is
|
|
21
|
+
// the honest claim: immediate feedback, not a hard block. (A hard pre-commit
|
|
22
|
+
// block is the pre-commit hook / CI Action's job — same command, `apocalypse`.)
|
|
23
|
+
// sparda gate report the edit's behavior delta vs baseline
|
|
24
|
+
// sparda gate --arm freeze the current graph as the accepted baseline
|
|
25
|
+
// sparda gate --hook Claude Code PostToolUse contract: silent when clean,
|
|
26
|
+
// report on stderr + exit 2 (feedback to the agent) on a
|
|
27
|
+
// real regression; abstains (exit 0) while a file is
|
|
28
|
+
// mid-edit; arms itself on first run — zero config.
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { compileUBG } from '../ubg/compile.js';
|
|
32
|
+
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
33
|
+
import { checkGraph, diffGraphs } from '../ubg/apocalypse.js';
|
|
34
|
+
import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
|
|
35
|
+
|
|
36
|
+
// severities that block the edit; medium/info regressions are reported, never fatal
|
|
37
|
+
const BLOCKING = new Set(['critical', 'high']);
|
|
38
|
+
const findingKey = (f) => `${f.rule}|${f.entrypoint}`;
|
|
39
|
+
// diff rules that mean "this route/protection disappeared" — the ones a vanished
|
|
40
|
+
// (unparseable) file can forge. Introduced-risk rules can never be forged by a
|
|
41
|
+
// missing file, so they are never suppressed.
|
|
42
|
+
const REMOVAL_RULES = new Set(['ENTRYPOINT_REMOVED', 'GUARD_REMOVED']);
|
|
43
|
+
|
|
44
|
+
// files the compiler could not read/parse this run — reason is prefixed by the
|
|
45
|
+
// module error (`parse error: …` / `unreadable: …`) in every extractor's skipped list.
|
|
46
|
+
function unparsedFiles(report) {
|
|
47
|
+
const out = new Set();
|
|
48
|
+
for (const s of report?.skipped ?? []) {
|
|
49
|
+
if (s.file && /(^|\b)(parse error|unreadable|Unexpected|SyntaxError)/i.test(s.reason))
|
|
50
|
+
out.add(s.file);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The gate's whole judgment, pure over two canonical graphs (testable without a repo):
|
|
56
|
+
// diff regressions (guard/route/invariant lost, blast radius grown) + static findings
|
|
57
|
+
// the candidate has that the baseline did not — the same composition `review` proved
|
|
58
|
+
// out (ADR-030), pointed at the baseline instead of a git ref. `unparsed` (the set of
|
|
59
|
+
// files that failed to parse in the candidate) splits the diff removals into real
|
|
60
|
+
// regressions vs in-progress edits whose baseline route lives in a now-broken file.
|
|
61
|
+
export function gateDelta(baseline, candidate, unparsed = new Set()) {
|
|
62
|
+
const baseLoc = new Map((baseline.nodes ?? []).map((n) => [n.id, n.loc?.file]));
|
|
63
|
+
const diff = diffGraphs(baseline, candidate).findings;
|
|
64
|
+
const before = new Set(checkGraph(baseline).findings.map(findingKey));
|
|
65
|
+
const introduced = checkGraph(candidate).findings.filter(
|
|
66
|
+
(f) => !before.has(findingKey(f)),
|
|
67
|
+
);
|
|
68
|
+
const seen = new Set(diff.map(findingKey));
|
|
69
|
+
const all = [...diff, ...introduced.filter((f) => !seen.has(findingKey(f)))];
|
|
70
|
+
|
|
71
|
+
const findings = [];
|
|
72
|
+
const abstained = [];
|
|
73
|
+
for (const f of all) {
|
|
74
|
+
if (REMOVAL_RULES.has(f.rule) && unparsed.has(baseLoc.get(f.entrypoint)))
|
|
75
|
+
abstained.push(f);
|
|
76
|
+
else findings.push(f);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
findings,
|
|
80
|
+
blocking: findings.filter((f) => BLOCKING.has(f.severity)),
|
|
81
|
+
abstained,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function locOf(canonical, f) {
|
|
86
|
+
const loc = canonical.nodes.find((n) => n.id === f.entrypoint)?.loc;
|
|
87
|
+
return loc ? ` (${loc.file}:${loc.line || 1})` : '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function runGate(opts) {
|
|
91
|
+
const t0 = process.hrtime.bigint();
|
|
92
|
+
const hook = opts.hook;
|
|
93
|
+
// in hook mode stdout belongs to nobody and stderr is what the agent reads
|
|
94
|
+
const say = hook ? (s) => console.error(s) : (s) => console.log(s);
|
|
95
|
+
|
|
96
|
+
const baselinePath = path.join(opts.cwd, '.sparda', 'ubg.baseline.json');
|
|
97
|
+
|
|
98
|
+
// A tree that does not compile at all (broken entry / detection throw) has no
|
|
99
|
+
// behavior to judge. Abstain instead of crashing the hook — the agent is mid-edit.
|
|
100
|
+
let graph, report;
|
|
101
|
+
try {
|
|
102
|
+
({ graph, report } = compileUBG(opts.cwd, { write: false }));
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (opts.json)
|
|
105
|
+
console.log(JSON.stringify({ ok: true, abstained: err.message }, null, 2));
|
|
106
|
+
else if (!hook)
|
|
107
|
+
say(
|
|
108
|
+
`⏳ GATE ABSTAINED — the tree does not compile yet (${err.message}). Fix it, then re-check.`,
|
|
109
|
+
);
|
|
110
|
+
return { armed: false, findings: [], abstained: true };
|
|
111
|
+
}
|
|
112
|
+
const candidate = canonicalizeGraph(graph);
|
|
113
|
+
const unparsed = unparsedFiles(report);
|
|
114
|
+
|
|
115
|
+
const arm = () => {
|
|
116
|
+
fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
|
|
117
|
+
atomicWrite(baselinePath, JSON.stringify(candidate, null, 2) + '\n');
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
if (opts.arm || opts.saveBaseline) {
|
|
121
|
+
arm();
|
|
122
|
+
say(
|
|
123
|
+
`✓ GATE ARMED — baseline frozen (.sparda/ubg.baseline.json). Every edit is now proven against it.`,
|
|
124
|
+
);
|
|
125
|
+
return { armed: true, findings: [] };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!fs.existsSync(baselinePath)) {
|
|
129
|
+
// first run arms instead of failing: a hook must work with zero configuration
|
|
130
|
+
arm();
|
|
131
|
+
say(
|
|
132
|
+
`✓ GATE ARMED (first run) — baseline frozen. Future edits are proven against it; re-arm with \`sparda gate --arm\` after intended changes.`,
|
|
133
|
+
);
|
|
134
|
+
return { armed: true, findings: [] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
|
|
138
|
+
const { findings, blocking, abstained } = gateDelta(baseline, candidate, unparsed);
|
|
139
|
+
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
|
140
|
+
|
|
141
|
+
if (opts.json) {
|
|
142
|
+
console.log(
|
|
143
|
+
JSON.stringify(
|
|
144
|
+
{
|
|
145
|
+
ok: blocking.length === 0,
|
|
146
|
+
ms: Math.round(ms),
|
|
147
|
+
findings,
|
|
148
|
+
abstained: abstained.map((f) => f.entrypoint),
|
|
149
|
+
},
|
|
150
|
+
null,
|
|
151
|
+
2,
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
if (blocking.length) process.exitCode = 1;
|
|
155
|
+
return { armed: false, findings };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Abstention notice: a route vanished only because its file doesn't parse yet.
|
|
159
|
+
// Never blocking — this is the normal mid-edit state, not a regression.
|
|
160
|
+
if (abstained.length) {
|
|
161
|
+
const files = [...new Set([...unparsed])].join(', ');
|
|
162
|
+
say(
|
|
163
|
+
`⏳ GATE HELD — ${abstained.length} route(s) can't be proven while ${files} doesn't parse (mid-edit?). Not a regression; I'll re-check once it parses.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (findings.length === 0) {
|
|
168
|
+
if (!hook && !abstained.length)
|
|
169
|
+
say(
|
|
170
|
+
`✓ GATE CLEAN — this edit lost no guard, dropped no route, grew no blast radius (proven vs baseline in ${Math.round(ms)} ms).`,
|
|
171
|
+
);
|
|
172
|
+
return { armed: false, findings };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
say(
|
|
176
|
+
`✗ SPARDA GATE — this edit changed the app's proven behavior (${Math.round(ms)} ms, deterministic):`,
|
|
177
|
+
);
|
|
178
|
+
for (const f of findings)
|
|
179
|
+
say(` [${f.severity}] ${f.rule} — ${f.message}${locOf(candidate, f)}`);
|
|
180
|
+
say(
|
|
181
|
+
blocking.length
|
|
182
|
+
? ` → fix the edit or, if intended, accept it with \`sparda gate --arm\`.`
|
|
183
|
+
: ` → advisory only (no critical/high regression) — review, then \`sparda gate --arm\` if intended.`,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (blocking.length) process.exitCode = hook ? 2 : 1;
|
|
187
|
+
return { armed: false, findings };
|
|
188
|
+
}
|
package/src/detect.js
CHANGED
|
@@ -92,7 +92,7 @@ export function detectStack(cwd) {
|
|
|
92
92
|
const known = ['fastify', 'koa'].find((d) => deps[d]);
|
|
93
93
|
if (known)
|
|
94
94
|
throw err(
|
|
95
|
-
`${known} detected — not supported yet. Express, NestJS &
|
|
95
|
+
`${known} detected — not supported yet. Express, NestJS, FastAPI & Flask in v0.`,
|
|
96
96
|
'+1 the framework vote: github.com/zyx77550/sparda/issues/1',
|
|
97
97
|
);
|
|
98
98
|
}
|
|
@@ -108,6 +108,18 @@ export function detectStack(cwd) {
|
|
|
108
108
|
return { framework: 'fastapi', entryFile, port, pythonCmd };
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
+
// Flask — checked AFTER FastAPI (a FastAPI project may pull flask transitively; FastAPI
|
|
112
|
+
// wins). The same Python extractor handles it; only the entry-file marker differs.
|
|
113
|
+
for (const f of ['requirements.txt', 'pyproject.toml']) {
|
|
114
|
+
const p = path.join(cwd, f);
|
|
115
|
+
if (
|
|
116
|
+
fs.existsSync(p) &&
|
|
117
|
+
/(^|[^a-z])flask([^a-z]|$)/i.test(fs.readFileSync(p, 'utf8'))
|
|
118
|
+
) {
|
|
119
|
+
const entryFile = findFlaskEntry(cwd);
|
|
120
|
+
return { framework: 'flask', entryFile, port: 5000, pythonCmd: detectPython() };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
111
123
|
|
|
112
124
|
// Last resort before giving up — monorepo app dirs whose framework config lives
|
|
113
125
|
// ELSEWHERE than the pointed dir. Purely structural, so it never mis-fires on a
|
|
@@ -130,7 +142,7 @@ export function detectStack(cwd) {
|
|
|
130
142
|
|
|
131
143
|
const suggestions = suggestAppDirs(cwd);
|
|
132
144
|
throw err(
|
|
133
|
-
'No supported framework found (Express, Next.js, NestJS, Medusa, FastAPI).',
|
|
145
|
+
'No supported framework found (Express, Next.js, NestJS, Medusa, FastAPI, Flask).',
|
|
134
146
|
suggestions.length
|
|
135
147
|
? `This looks like a monorepo — the analyzable app is in a sub-directory. Try:\n` +
|
|
136
148
|
suggestions
|
|
@@ -401,7 +413,28 @@ function findFastAPIEntry(cwd) {
|
|
|
401
413
|
);
|
|
402
414
|
}
|
|
403
415
|
|
|
404
|
-
|
|
416
|
+
// Flask's entry is the file that calls `Flask(__name__)` — conventionally app.py /
|
|
417
|
+
// wsgi.py / run.py / main.py, else the first .py declaring it. Same shape as the
|
|
418
|
+
// FastAPI finder; the Python extractor handles both frameworks from that entry.
|
|
419
|
+
function findFlaskEntry(cwd) {
|
|
420
|
+
const candidates = ['app.py', 'wsgi.py', 'run.py', 'main.py', 'src/app.py'];
|
|
421
|
+
for (const rel of candidates) {
|
|
422
|
+
const abs = path.resolve(cwd, rel);
|
|
423
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
|
|
424
|
+
if (fs.readFileSync(abs, 'utf8').includes('Flask(')) {
|
|
425
|
+
return path.relative(cwd, abs).split(path.sep).join('/');
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const entry = searchPyFiles(cwd, cwd, { val: 0 }, 'Flask(');
|
|
430
|
+
if (entry) return path.relative(cwd, entry).split(path.sep).join('/');
|
|
431
|
+
throw err(
|
|
432
|
+
'Could not locate your Flask entry file (the one calling Flask()).',
|
|
433
|
+
'Specify it manually, or make sure Flask() is declared in one of your python files.',
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function searchPyFiles(dir, root, countRef = { val: 0 }, marker = 'FastAPI(') {
|
|
405
438
|
const EXCLUDE = new Set([
|
|
406
439
|
'node_modules',
|
|
407
440
|
'.git',
|
|
@@ -434,7 +467,7 @@ function searchPyFiles(dir, root, countRef = { val: 0 }) {
|
|
|
434
467
|
countRef.val++;
|
|
435
468
|
if (countRef.val > 200) return null;
|
|
436
469
|
const src = fs.readFileSync(abs, 'utf8');
|
|
437
|
-
if (src.includes(
|
|
470
|
+
if (src.includes(marker)) {
|
|
438
471
|
return abs;
|
|
439
472
|
}
|
|
440
473
|
}
|
package/src/index.js
CHANGED
|
@@ -18,6 +18,7 @@ const opts = {
|
|
|
18
18
|
yes: flags.has('--yes') || flags.has('-y'),
|
|
19
19
|
saveBaseline: flags.has('--save-baseline'),
|
|
20
20
|
sarif: flags.has('--sarif'),
|
|
21
|
+
proof: flags.has('--proof'),
|
|
21
22
|
verbose: flags.has('--verbose'),
|
|
22
23
|
quiet: flags.has('--quiet'),
|
|
23
24
|
probe: flags.has('--probe'),
|
|
@@ -27,6 +28,8 @@ const opts = {
|
|
|
27
28
|
learn: flags.has('--learn'),
|
|
28
29
|
germinate: flags.has('--germinate'),
|
|
29
30
|
check: flags.has('--check'),
|
|
31
|
+
hook: flags.has('--hook'),
|
|
32
|
+
arm: flags.has('--arm'),
|
|
30
33
|
markdown: flags.has('--markdown'),
|
|
31
34
|
port: getOpt('port', null),
|
|
32
35
|
out: getOpt('out', null),
|
|
@@ -45,7 +48,8 @@ const opts = {
|
|
|
45
48
|
// reads opts.cwd, so `--dir <path>` works on all of them; only per-command extras are listed.
|
|
46
49
|
const HELP = {
|
|
47
50
|
prove: `sparda prove [--dir <path>] [--json | --markdown]\n The whole trust verdict: proof + coverage + a shareable seal.\n --markdown emit a sticky-PR-comment body (used by the GitHub Action).`,
|
|
48
|
-
apocalypse: `sparda apocalypse [--dir <path>] [--sarif] [--save-baseline]\n Prove the deploy — exit 1 on any critical/high finding.\n --sarif also write .sparda/apocalypse.sarif for the Security tab.\n --save-baseline freeze this graph; later runs diff against it.`,
|
|
51
|
+
apocalypse: `sparda apocalypse [--dir <path>] [--sarif] [--proof] [--save-baseline]\n Prove the deploy — exit 1 on any critical/high finding.\n --sarif also write .sparda/apocalypse.sarif for the Security tab.\n --proof also write .sparda/apocalypse.proof.json — a re-verifiable\n discharge trace (deny_path per guarded mutation) a third party\n can audit against the graph hash without re-compiling.\n --save-baseline freeze this graph; later runs diff against it.`,
|
|
52
|
+
gate: `sparda gate [--dir <path>] [--arm] [--hook] [--json]\n The agent edit-loop gate: prove THIS edit lost no guard, dropped no route,\n grew no blast radius — delta vs the armed baseline only (pre-existing state\n never blocks an edit). Arms itself on first run.\n --arm accept the current graph as the new baseline.\n --hook Claude Code PostToolUse contract: silent when clean, report on\n stderr + exit 2 (blocking feedback to the agent) on regression.`,
|
|
49
53
|
ubg: `sparda ubg [--dir <path>] [--json] [--out <file>] [--openapi <spec>]\n Compile the codebase to its Unified Behavior Graph (.sparda/ubg.json).`,
|
|
50
54
|
stitch: `sparda stitch <dir1> <dir2> [...] [--json]\n Cross-service proof: join one service's outbound HTTP calls to another's routes,\n surface cross-service trust boundaries + BOLA no mono-repo tool can see.`,
|
|
51
55
|
badge: `sparda badge [--dir <path>] [--out <file>] [--json]\n Emit a shareable SVG badge + README snippet (verdict · coverage · routes).`,
|
|
@@ -162,6 +166,11 @@ try {
|
|
|
162
166
|
await runApocalypse(opts);
|
|
163
167
|
break;
|
|
164
168
|
}
|
|
169
|
+
case 'gate': {
|
|
170
|
+
const { runGate } = await import('./commands/gate.js');
|
|
171
|
+
await runGate(opts);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
165
174
|
case 'review': {
|
|
166
175
|
const { runReview } = await import('./commands/review.js');
|
|
167
176
|
await runReview(opts);
|
|
@@ -234,6 +243,7 @@ try {
|
|
|
234
243
|
PROVE — the point
|
|
235
244
|
prove The whole trust verdict in one gesture: proof + coverage + seal (--json / --markdown / --openapi)
|
|
236
245
|
apocalypse Prove the deploy: guards, invariants, transactions, aggregates (--save-baseline)
|
|
246
|
+
gate The agent edit-loop gate: prove an edit lost no protection (--arm / --hook)
|
|
237
247
|
review Semantic diff of a PR vs a base ref (--base main / --json / --markdown)
|
|
238
248
|
blindspots Map SPARDA's own blindness — every unseen route/effect/guard, ranked (--json)
|
|
239
249
|
badge Emit a shareable SVG badge + README snippet (verdict · coverage · routes)
|