sparda-mcp 0.58.0 → 0.63.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 +74 -30
- package/SKILL.md +39 -10
- package/package.json +5 -4
- package/src/commands/stitch.js +58 -0
- package/src/index.js +37 -0
- package/src/server/stdio.js +121 -2
- package/src/ubg/apocalypse.js +107 -2
- package/src/ubg/llm-resolve.js +46 -0
- package/src/ubg/prisma.js +77 -30
- package/src/ubg/stitch.js +105 -0
package/README.md
CHANGED
|
@@ -35,42 +35,47 @@ npx sparda-mcp badge # a README badge: proven · coverage% · routes
|
|
|
35
35
|
Under the hood it compiles your backend into one language-agnostic graph — the **Unified Behavior Graph (UBG)**, serialized as `.sparda/ubg.json` under the **SBIR** specification ([SPARDA Behavior IR](docs/SBIR_SPEC_V1.1.md)) — and every command is a pass over that graph.
|
|
36
36
|
|
|
37
37
|
> [!IMPORTANT]
|
|
38
|
-
> **The Route-Compilation Proof — reproduce it yourself.** SPARDA compiles real open-source monsters to their behavior graph with **zero crashes**, each in **≈1–2 seconds**: Next.js
|
|
38
|
+
> **The Route-Compilation Proof — reproduce it yourself.** SPARDA compiles real open-source monsters to their behavior graph with **zero crashes**, each in **≈1–2 seconds**: Next.js _Dub_ (579 routes), NestJS _Immich_ (281), _MedusaJS_ (477). It natively resolves deep Dependency Injection, external controllers, and Next.js handlers. One command clones them and re-measures on your machine:
|
|
39
|
+
>
|
|
39
40
|
> ```bash
|
|
40
41
|
> node bench/repro.mjs # → bench/route-proof.json
|
|
41
42
|
> ```
|
|
42
|
-
>
|
|
43
|
+
>
|
|
44
|
+
> Honesty first: _compiling_ a route is a parser result (the number above); _proving_ it safe is a separate per-repo verdict — and most real apps come back **NOT_PROVEN**, which is the true state, not a failure. (Our full 25-repo corpus stress compiles **3,565 routes** at ~150 routes/s; that one needs the corpus checked out.)
|
|
43
45
|
|
|
44
46
|
**What the graph unlocks — 100% local, deterministic, zero runtime dependencies, zero API key:**
|
|
45
47
|
|
|
46
|
-
| Command
|
|
47
|
-
|
|
48
|
-
| **`prove`**
|
|
49
|
-
| **`apocalypse`**
|
|
50
|
-
| **`
|
|
51
|
-
| **`
|
|
52
|
-
| **`
|
|
53
|
-
| **`
|
|
54
|
-
| **`
|
|
55
|
-
| **`mirror`**
|
|
56
|
-
| **`init` / `dev`** |
|
|
48
|
+
| Command | What it does |
|
|
49
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
50
|
+
| **`prove`** | _The whole trust verdict in one gesture_ — proof + coverage + a shareable seal (`--json` / `--markdown`) |
|
|
51
|
+
| **`apocalypse`** | _Prove the deploy_ — no guard, invariant, transaction or aggregate boundary can be broken (SARIF + CI gate) |
|
|
52
|
+
| **`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
|
+
| **`badge`** | _The shareable artifact_ — a self-contained SVG badge + README snippet (verdict · coverage · routes) |
|
|
54
|
+
| **`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
|
+
| **`timeless`** | _Time-travel_ — record a production request, replay it byte-identically, export the bug as a test |
|
|
57
|
+
| **`mirror`** | _Execute the graph_ — serve the compiled behavior over HTTP with no framework and no source |
|
|
58
|
+
| **`init` / `dev`** | _Runtime, optional_ — expose the graph to AI clients as a live MCP server (+ Twin, Immune, Evolution) |
|
|
57
59
|
|
|
58
|
-
The prover is the product. The MCP server is one
|
|
60
|
+
The prover is the product. The MCP server is one _output_ of the graph, not the point — SPARDA compiles the whole system's behavior, then proves, replays, heals, and (optionally) serves it.
|
|
59
61
|
|
|
60
|
-
**Nomenclature:** **SBIR** is the specification (the format, like "JSON"); **UBG** is the compiled graph itself (the artifact, `ubg.json`). The MCP server is one
|
|
62
|
+
**Nomenclature:** **SBIR** is the specification (the format, like "JSON"); **UBG** is the compiled graph itself (the artifact, `ubg.json`). The MCP server is one _output_ of the graph, not the product.
|
|
61
63
|
|
|
62
64
|
## Optional: expose the graph to AI clients (MCP runtime)
|
|
63
65
|
|
|
64
66
|
Beyond proving, SPARDA can turn your running app into a live MCP server — the graph, executable, with write-safety and an immune layer. This is optional and separate from the prover above.
|
|
65
67
|
|
|
66
68
|
1. **Scan + inject** — run once, from your app's directory:
|
|
69
|
+
|
|
67
70
|
```bash
|
|
68
71
|
npx sparda-mcp init
|
|
69
72
|
```
|
|
73
|
+
|
|
70
74
|
SPARDA parses your routes (AST), generates a marked `/mcp` router, injects it into
|
|
71
75
|
your app (with a backup), and writes `sparda.json`. Every step is reversible.
|
|
72
76
|
|
|
73
77
|
2. **Start your app, then start the bridge:**
|
|
78
|
+
|
|
74
79
|
```bash
|
|
75
80
|
npx sparda-mcp dev
|
|
76
81
|
```
|
|
@@ -94,25 +99,31 @@ Beyond proving, SPARDA can turn your running app into a live MCP server — the
|
|
|
94
99
|
## Try the Standalone Demo
|
|
95
100
|
|
|
96
101
|
To see SPARDA in action instantly without modifying your codebase:
|
|
102
|
+
|
|
97
103
|
```bash
|
|
98
104
|
npx sparda-mcp demo
|
|
99
105
|
```
|
|
106
|
+
|
|
100
107
|
This runs the entire MCP lifecycle (detect → parse → generate → inject → remove) on a bundled demo app in a temporary folder, in about 10 seconds. For the compiler itself, run `npx sparda-mcp ubg` then `apocalypse` on any Express/FastAPI app.
|
|
101
108
|
|
|
102
109
|
## Black Box Report
|
|
103
110
|
|
|
104
111
|
SPARDA is designed as a local organism. To see what it remembers and how much compute it has recycled:
|
|
112
|
+
|
|
105
113
|
```bash
|
|
106
114
|
npx sparda-mcp report
|
|
107
115
|
```
|
|
116
|
+
|
|
108
117
|
This prints a terminal dashboard aggregating your exposed tools, write opt-ins, proof journal decisions, and crystallized composite tools.
|
|
109
118
|
|
|
110
119
|
To write a self-contained, offline HTML dashboard at `.sparda/report.html`, append the `--html` flag:
|
|
120
|
+
|
|
111
121
|
```bash
|
|
112
122
|
npx sparda-mcp report --html
|
|
113
123
|
```
|
|
114
124
|
|
|
115
125
|
To output raw JSON for integration:
|
|
126
|
+
|
|
116
127
|
```bash
|
|
117
128
|
npx sparda-mcp report --json
|
|
118
129
|
```
|
|
@@ -120,28 +131,35 @@ npx sparda-mcp report --json
|
|
|
120
131
|
## Deployment Proof: Apocalypse
|
|
121
132
|
|
|
122
133
|
SPARDA's Behavior Graph is a formal model of your system. Instead of waiting for runtime failures or relying on static analysis vibes, you can statically prove the safety of your backend before any deployment:
|
|
134
|
+
|
|
123
135
|
```bash
|
|
124
136
|
npx sparda-mcp apocalypse
|
|
125
137
|
```
|
|
138
|
+
|
|
126
139
|
This command reads the compiled `.sparda/ubg.json` (with zero source code parsing at runtime) and discharges five static correctness obligations:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
140
|
+
|
|
141
|
+
- **Unguarded Mutation (Critical)**: Flags any mutation path that does not cross a security `guard`.
|
|
142
|
+
- **Non-Atomic Aggregate Write (High)**: Flags when an API writes to multiple tables of the same Consistency Domain (Aggregate) outside a single transaction scope.
|
|
143
|
+
- **Unvalidated Constrained Write (Medium)**: Flags writes into columns with declared invariants (CHECK, NOT NULL, UNIQUE — parsed from your `.sql` DDL **or `schema.prisma`**, Prisma enums included) without prior validation (Zod/Pydantic).
|
|
144
|
+
- **Irreversible Observable Effect (High)**: Flags out-of-process actions (like Stripe charges) that happen alongside state writes without a structural compensation path (like a catch-refund).
|
|
145
|
+
- **Aggregate Member Bypass (Info)**: Flags mutating a member table directly without routing through the aggregate root.
|
|
132
146
|
|
|
133
147
|
To save your current graph as a safe baseline:
|
|
148
|
+
|
|
134
149
|
```bash
|
|
135
150
|
npx sparda-mcp apocalypse --save-baseline
|
|
136
151
|
```
|
|
152
|
+
|
|
137
153
|
Subsequent runs will diff the candidate graph against this baseline to detect regression vectors:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
154
|
+
|
|
155
|
+
- Deletion of any security `guard` (Critical).
|
|
156
|
+
- Deletion of a database SQL invariant (High).
|
|
157
|
+
- API blast radius expansion (Medium).
|
|
141
158
|
|
|
142
159
|
If any Critical or High finding is found, `apocalypse` exits with a non-zero code to block your CI pipeline.
|
|
143
160
|
|
|
144
161
|
**One step in your workflow — findings land in the GitHub Security tab (SARIF):**
|
|
162
|
+
|
|
145
163
|
```yaml
|
|
146
164
|
- uses: zyx77550/sparda@main
|
|
147
165
|
with:
|
|
@@ -159,11 +177,13 @@ npx sparda-mcp timeless export <id> # the production bug is now a vitest test
|
|
|
159
177
|
```
|
|
160
178
|
|
|
161
179
|
Recording is two lines in your app (ESM), with deterministic sampling and GDPR redaction built in:
|
|
180
|
+
|
|
162
181
|
```js
|
|
163
182
|
import { getFlightBox } from 'sparda-mcp/src/flight/box.js';
|
|
164
|
-
const box = getFlightBox();
|
|
165
|
-
|
|
166
|
-
|
|
183
|
+
const box = getFlightBox();
|
|
184
|
+
box.arm();
|
|
185
|
+
app.use(box.middleware({ sample: 100 })); // 1 request in 100; passwords/tokens redacted by default
|
|
186
|
+
const db = box.wrapClient(pgPool); // your query client, tapped
|
|
167
187
|
```
|
|
168
188
|
|
|
169
189
|
The closed loop nobody else has: **production bug → recorded flight → failing test → AI writes the fix → `apocalypse` proves the fix breaks no guard, invariant or transaction → deploy.** Replay is per-request (concurrent-race capture is out of scope for v1 — stated, not hidden).
|
|
@@ -180,7 +200,7 @@ npx sparda-mcp heal <flightId> --check --expect '{"status":404}'
|
|
|
180
200
|
|
|
181
201
|
The brief is built from the graph itself — it hands the fixer the handler's `file:line`, the capabilities the fix must not grow, and the guards it must not remove. Then the **gate** — the actual product — proves the fix on three axes at once:
|
|
182
202
|
|
|
183
|
-
1. **Behavior** — lenient replay of the recorded flight (same deterministic inputs) now produces the
|
|
203
|
+
1. **Behavior** — lenient replay of the recorded flight (same deterministic inputs) now produces the _expected_ response, not the recorded bug. The fix may reformulate a query (the tap is relabeled, allowed); it may **not** change the effect order or kinds.
|
|
184
204
|
2. **Compiler laws** — `verify` still passes: the graph is still sound and deterministic.
|
|
185
205
|
3. **No regression** — `apocalypse` diff against the frozen pre-fix graph: zero new critical/high findings, no guard removed, no blast radius grown.
|
|
186
206
|
|
|
@@ -188,7 +208,7 @@ The brief is built from the graph itself — it hands the fixer the handler's `f
|
|
|
188
208
|
✓ HEALED & PROVEN — same recorded inputs, correct output, zero law broken, zero protection lost. Ship it.
|
|
189
209
|
```
|
|
190
210
|
|
|
191
|
-
The gate is honest in both directions: an unfixed bug, or a "fix" that silently drops a guard, keeps it **closed** (exit 1). This is the difference between an AI that writes plausible code and a system that
|
|
211
|
+
The gate is honest in both directions: an unfixed bug, or a "fix" that silently drops a guard, keeps it **closed** (exit 1). This is the difference between an AI that writes plausible code and a system that _proves_ the code is correct — the trust layer the agent era is missing.
|
|
192
212
|
|
|
193
213
|
## Any Backend On Earth: OpenAPI Lowering
|
|
194
214
|
|
|
@@ -233,7 +253,7 @@ To undo everything: **`npx sparda-mcp remove`** restores your code byte-for-byte
|
|
|
233
253
|
5. **Nothing leaves your machine.** No telemetry to us, no cloud, local key auth, 4 exact-pinned dependencies.
|
|
234
254
|
6. **What it learns is never lost.** Diagnoses, descriptions, settings — versioned with your git, surviving every re-init.
|
|
235
255
|
|
|
236
|
-
What we
|
|
256
|
+
What we _don't_ promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY.md).
|
|
237
257
|
|
|
238
258
|
## How it works
|
|
239
259
|
|
|
@@ -250,34 +270,52 @@ What we *don't* promise: the honest limits in [docs/SECURITY.md](./docs/SECURITY
|
|
|
250
270
|
## What SPARDA gives your AI
|
|
251
271
|
|
|
252
272
|
### Operate, not just read
|
|
273
|
+
|
|
253
274
|
Every route becomes a tool that runs against your live process — real auth, real data,
|
|
254
275
|
warm connections. One call to **`sparda_get_context`** hands the AI the whole living
|
|
255
276
|
picture: enabled tools, suggested workflows, runtime telemetry, quarantine state, and
|
|
256
277
|
immune memory — so every session resumes where the last one stopped.
|
|
257
278
|
|
|
279
|
+
### Prove the edit before you commit — the one check an LLM can't do to itself
|
|
280
|
+
|
|
281
|
+
The AI just edited a route. Did it quietly drop a guard? It calls **`sparda_prove`** and
|
|
282
|
+
finds out **now**, not in a CI run later. The tool recompiles the app to its behavior graph,
|
|
283
|
+
discharges the same static obligations as `sparda apocalypse`, and returns a deterministic
|
|
284
|
+
verdict — the exact word the CLI and badge emit, so it can never over-claim (a low-coverage
|
|
285
|
+
clean app reads `SURFACE`, never a bare `PROVEN`). Save a baseline once
|
|
286
|
+
(`sparda apocalypse --save-baseline`) and every later `sparda_prove` flags any finding with
|
|
287
|
+
`regression: true` — the guard your edit removed, the route it dropped, the blast radius it
|
|
288
|
+
grew. That's _"AI writes. SPARDA proves."_ inside the edit loop. Clients that list MCP prompts
|
|
289
|
+
also get the **`prove-my-edit`** workflow.
|
|
290
|
+
|
|
258
291
|
### Write-safety: the AI can't write until you say so
|
|
292
|
+
|
|
259
293
|
- Writes (POST/PUT/DELETE) ship **disabled**. Enable them per tool in `sparda.json`; your choice survives every re-init.
|
|
260
294
|
- An enabled write is **never executed on the first call**. SPARDA returns an `awaiting_confirmation` envelope — a single-use token plus a preview of the action — and commits only after an explicit confirm step.
|
|
261
295
|
- When your client supports MCP elicitation, that confirmation prompt appears **in the AI's own UI**.
|
|
262
296
|
- **Proof-after-write**: every successful write is followed by a read-back of the same resource, so the AI — and you — see the real effect, not a hopeful guess.
|
|
263
297
|
|
|
264
298
|
### Your app defends itself — zero LLM on the hot path
|
|
299
|
+
|
|
265
300
|
- **Quarantine.** A tool that returns 3 consecutive 5xx is quarantined: further calls get a `503` with a reason and a retry delay instead of hammering your broken route. After a cooldown it half-opens for a single probe.
|
|
266
301
|
- **Latency & anomaly flags.** The router learns each route's baseline and flags deviations locally, in a few lines of math.
|
|
267
302
|
- **Adaptive diagnosis, only on surprise.** A genuinely new failure wakes your AI client's own model to diagnose it once; the diagnosis is cached as an "antibody" in `sparda.json`, so the same failure later costs zero tokens. Cloning your code doesn't clone its immune memory.
|
|
268
303
|
|
|
269
304
|
### A free intelligence layer, zero API key
|
|
305
|
+
|
|
270
306
|
On first connection your AI client's own model (via MCP sampling) rewrites raw routes
|
|
271
307
|
into business-language tool descriptions and proposes multi-step workflows — cached in
|
|
272
308
|
`sparda.json` and exposed as MCP prompts. Nothing to configure, nothing to pay.
|
|
273
309
|
|
|
274
310
|
### It gets cheaper the more you use it
|
|
311
|
+
|
|
275
312
|
- **Response recycling.** When a read keeps returning the same answer, SPARDA serves the next identical call straight from memory — without touching your host app. Reads only; writes always hit the host.
|
|
276
313
|
- **A recycling gauge.** `GET /mcp/stats` counts how many calls were answered from SPARDA's own knowledge vs. how many paid the host route. It reads 0% on day one and fills with usage — a measure, never a promise.
|
|
277
314
|
|
|
278
315
|
### Tools nobody wrote — Labs, opt-in, default OFF
|
|
316
|
+
|
|
279
317
|
Turn it on with `"labs": { "recordSequences": true }` in `sparda.json`. SPARDA then
|
|
280
|
-
notices when one tool's output feeds the next tool's input and records the
|
|
318
|
+
notices when one tool's output feeds the next tool's input and records the _circuit_ —
|
|
281
319
|
structure only (tool names, argument names, counts), never your data. A read-only
|
|
282
320
|
circuit seen enough times **crystallizes into a composite tool**, announced
|
|
283
321
|
mid-session: one call runs the whole chain, auto-feeding each step from the previous
|
|
@@ -285,11 +323,13 @@ step's real response. Write routes are never absorbed — their per-call confirm
|
|
|
285
323
|
always stands.
|
|
286
324
|
|
|
287
325
|
### Living context & telemetry
|
|
326
|
+
|
|
288
327
|
`GET /mcp/stats` (per-tool calls/errors, tool "purity", quarantine state) and
|
|
289
328
|
`GET /mcp/events` (errors, latency anomalies, cached diagnoses) expose exactly what
|
|
290
329
|
your app is doing — surfaced to the AI as live notifications.
|
|
291
330
|
|
|
292
331
|
## Built for AI clients: the bundled Skill
|
|
332
|
+
|
|
293
333
|
SPARDA ships with an Agent Skill ([`SKILL.md`](./SKILL.md)) that teaches any compatible
|
|
294
334
|
AI client how to drive a SPARDA server to its **full potential** — call
|
|
295
335
|
`sparda_get_context` first, exploit response recycling, honor quarantine, prefer
|
|
@@ -307,6 +347,7 @@ runtime, so the guidance never goes stale.
|
|
|
307
347
|
- **FastAPI** (Python >= 3.9) — AST-based router injection.
|
|
308
348
|
|
|
309
349
|
## Security posture (honest)
|
|
350
|
+
|
|
310
351
|
- 4 runtime dependencies, exact-pinned.
|
|
311
352
|
- **Dynamic Local Key Resolution.** The generated router contains no baked secrets. It resolves authorization keys at runtime from the `SPARDA_LOCAL_KEY` environment variable or the local gitignored `.sparda/key` file, and fails closed (503) when neither is found. For custom production or staging setups, you can override this behavior by exposing `SPARDA_LOCAL_KEY` in your environment.
|
|
312
353
|
- Local key on every router call; self-reference loop protection; 30s timeouts; 8 KB output truncation.
|
|
@@ -316,18 +357,21 @@ runtime, so the guidance never goes stale.
|
|
|
316
357
|
Full threat model and known gaps: [docs/SECURITY.md](./docs/SECURITY.md).
|
|
317
358
|
|
|
318
359
|
## Documentation
|
|
360
|
+
|
|
319
361
|
- [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) — how `init`, the injected router, and the bridge fit together, plus the `sparda.json` schema.
|
|
320
362
|
- [docs/SECURITY.md](./docs/SECURITY.md) — threat model, defenses, and honest known gaps.
|
|
321
363
|
- [docs/TESTING.md](./docs/TESTING.md) — how the promises above are kept honest in CI.
|
|
322
364
|
- [docs/ERRORS.md](./docs/ERRORS.md) — the error knowledge base.
|
|
323
365
|
|
|
324
366
|
## Beyond the open core
|
|
367
|
+
|
|
325
368
|
SPARDA is free, including in production (see License). Team-scale capabilities —
|
|
326
369
|
fine-grained per-person access policies and a signed, tamper-evident audit log — are
|
|
327
370
|
planned for a future paid tier. The open core stands on its own; nothing here is
|
|
328
371
|
crippled to upsell you.
|
|
329
372
|
|
|
330
373
|
## License
|
|
374
|
+
|
|
331
375
|
[Business Source License 1.1](./LICENSE) — free to use, including in production.
|
|
332
376
|
You may not resell SPARDA or offer it as a competing commercial service.
|
|
333
377
|
Each version converts to Apache 2.0 four years after its release.
|
package/SKILL.md
CHANGED
|
@@ -13,11 +13,11 @@ description: >-
|
|
|
13
13
|
|
|
14
14
|
A SPARDA server is driven by a compiled **Unified Behavior Graph (UBG)** — the graph SPARDA's compiler produces from the host application's states, transitions, permissions, and side-effects, serialized under the **SBIR** specification. Instead of exposing raw, disconnected endpoints, SPARDA compiles the app into a deterministic behavioral model. The local **SPARDA Runtime** dynamically executes this graph inside the live host process, powering the MCP interface, the Twin simulation clone, and the Immune system offline.
|
|
15
15
|
|
|
16
|
-
> This skill covers the **runtime** (driving a live MCP server) — the live half of SPARDA's trust layer:
|
|
16
|
+
> This skill covers the **runtime** (driving a live MCP server) — the live half of SPARDA's trust layer: _"AI writes. SPARDA proves."_ The same graph also powers dev-time proof commands you run in the app's repo — `sparda review` (the behavior diff of a PR), `apocalypse` (prove the deploy), `timeless` (record/replay a request), `heal` (prove a fix), `mirror` (serve the graph), `ubg` (compile), `verify` (prove the compiler's laws). Those are CLI, not MCP tools; see the project README.
|
|
17
17
|
|
|
18
18
|
## Rule 0 — call `sparda_get_context` first, every session
|
|
19
19
|
|
|
20
|
-
Before anything else, call **`sparda_get_context`** (no params). It returns the
|
|
20
|
+
Before anything else, call **`sparda_get_context`** (no params). It returns the _live_ state of the SPARDA Behavior Graph:
|
|
21
21
|
|
|
22
22
|
- the active routes/tools, workflows, and type-propagated schemas;
|
|
23
23
|
- `runtime` — current stats (calls, errors, quarantine states, Twin mode active);
|
|
@@ -35,19 +35,45 @@ Read it to orient yourself inside the graph. `sparda_info` gives a lighter summa
|
|
|
35
35
|
parameter schema was only partially inferred — pass arguments carefully.
|
|
36
36
|
- **Meta-tools** — `sparda_get_context`, `sparda_info`,
|
|
37
37
|
`sparda_list_disabled_tools`, `sparda_confirm`.
|
|
38
|
+
- **The proof tool** — `sparda_prove`. Call it **after you edit a route, before you
|
|
39
|
+
commit** — see _Prove your own edit_ below. It's the one check you can't do to
|
|
40
|
+
yourself by re-reading your code.
|
|
38
41
|
- **Composite tools** — labelled `[Labs circuit ×N]`, `readOnly`. One call runs a
|
|
39
|
-
whole proven multi-step chain (see
|
|
42
|
+
whole proven multi-step chain (see _Crystallized circuits_ below).
|
|
40
43
|
|
|
41
44
|
Only **enabled** tools appear. Write tools are hidden until the user opts in, so a
|
|
42
|
-
missing write is a config state, not an error — see
|
|
45
|
+
missing write is a config state, not an error — see _Writing safely_.
|
|
46
|
+
|
|
47
|
+
## Prove your own edit — call `sparda_prove` before you commit
|
|
48
|
+
|
|
49
|
+
This is the tool an LLM needs most and can least fake. After you edit a route,
|
|
50
|
+
**call `sparda_prove`** — it recompiles the app to its behavior graph and discharges
|
|
51
|
+
the same static obligations as `sparda apocalypse` (unguarded mutation, non-atomic
|
|
52
|
+
aggregate write, unvalidated constrained write), then returns a deterministic verdict.
|
|
53
|
+
|
|
54
|
+
- **Focus it.** Pass `route` with the method+path you just touched (e.g.
|
|
55
|
+
`{ "route": "DELETE /orders" }`) to narrow the finding list. The **verdict still
|
|
56
|
+
reflects the whole app** — a filter never buys you a greener light.
|
|
57
|
+
- **Read the verdict honestly.** `PROVEN` / `PARTIAL` are safe to commit. `SURFACE`
|
|
58
|
+
and `NO_PROOF` mean SPARDA _couldn't resolve enough to prove it_ — that is
|
|
59
|
+
"unknown", **never** a pass. The word is the exact one the CLI and badge emit; it
|
|
60
|
+
physically cannot over-claim.
|
|
61
|
+
- **The regression check is the point.** If a baseline was saved
|
|
62
|
+
(`sparda apocalypse --save-baseline` on a known-good state), any finding with
|
|
63
|
+
`regression: true` means _your edit_ removed a guard, dropped a route, or grew the
|
|
64
|
+
blast radius vs the last proven state. Fix those before committing — this is the
|
|
65
|
+
check you cannot perform by re-reading your own diff.
|
|
66
|
+
- Clients that list MCP prompts also see **`prove-my-edit`**, the built-in workflow
|
|
67
|
+
that walks these steps.
|
|
43
68
|
|
|
44
69
|
## Exploit the intelligence layer (this is the "full potential")
|
|
45
70
|
|
|
46
71
|
**1. Response-recycling flywheel — make repeated reads free.**
|
|
47
|
-
When the
|
|
72
|
+
When the _same_ read tool returns a byte-identical result for the _same_ arguments
|
|
48
73
|
**3 times within 30 seconds**, SPARDA serves the next identical call straight from
|
|
49
74
|
RAM (`servedByFlywheel: true`) **without touching the host app**. So:
|
|
50
|
-
|
|
75
|
+
|
|
76
|
+
- Don't fear repeating stable GETs — repetition is what _activates_ the cache.
|
|
51
77
|
- Don't bolt your own client-side cache on top; you'd hide the signal that lets
|
|
52
78
|
SPARDA recycle, and you'd lose freshness control.
|
|
53
79
|
- Watch `recycling.flywheel.servedFromMemory` climb in context — that's free work.
|
|
@@ -55,7 +81,7 @@ RAM (`servedByFlywheel: true`) **without touching the host app**. So:
|
|
|
55
81
|
|
|
56
82
|
**2. Circuit-breaker / quarantine — stop hammering a sick backend.**
|
|
57
83
|
After **3 consecutive 5xx** on a tool, SPARDA quarantines it: subsequent calls
|
|
58
|
-
return **HTTP 503** with `reason` and `retryInMs`
|
|
84
|
+
return **HTTP 503** with `reason` and `retryInMs` _instead of_ hitting the failing
|
|
59
85
|
host. Honor `retryInMs` — do not retry-loop. Check `runtime.quarantine` in context
|
|
60
86
|
before depending on a tool. After a cooldown (~60s) the tool half-opens for one
|
|
61
87
|
probe; one more 5xx re-quarantines it.
|
|
@@ -68,7 +94,7 @@ single **composite tool** for that chain and announces it mid-session via
|
|
|
68
94
|
call and it's marked read-only. (Writes are never absorbed into a circuit.)
|
|
69
95
|
|
|
70
96
|
**4. Adaptive immunity — read the diagnosis before retrying.**
|
|
71
|
-
Repeated, unfamiliar failures trigger a
|
|
97
|
+
Repeated, unfamiliar failures trigger a _one-shot_ LLM diagnosis that SPARDA caches
|
|
72
98
|
as an "antibody" (keyed by `source|tool|status`). Recurrences reuse the cached
|
|
73
99
|
diagnosis at zero cost. When an error event carries a diagnosis, **read it** and
|
|
74
100
|
adapt — don't blindly retry the same call.
|
|
@@ -78,11 +104,13 @@ an `immune` event in `/mcp/events`. Treat it as a hint to back off or warn the u
|
|
|
78
104
|
|
|
79
105
|
**6. Twin Simulation Mode — practice safely on a clone.**
|
|
80
106
|
When `/mcp/stats` or `sparda_get_context.runtime` contains `"twin": true`, you are connected to a safe, in-memory mock clone of the application.
|
|
107
|
+
|
|
81
108
|
- All GET reads return learned exemplars (observed response shapes and mock values).
|
|
82
109
|
- All write tools return simulated `202` echoes but do not write to database or external APIs.
|
|
83
110
|
- Use this twin mode to practice multi-step workflows, debug tool sequences, and test your plans without touching the live production backend.
|
|
84
111
|
|
|
85
112
|
**7. Grammar & Evolution — discover optimal workflows.**
|
|
113
|
+
|
|
86
114
|
- You can query or contribute to the app's grammar (`.sparda/grammar.json`). The grammar maps valid sequences of tool calls (edges).
|
|
87
115
|
- Running `sparda evolve` mutates and runs candidate chains against the twin. The successful evolved sequences are suggested as mid-session workflows.
|
|
88
116
|
|
|
@@ -151,7 +179,8 @@ Writes are **disabled by default**. The protocol is not optional:
|
|
|
151
179
|
- **Learn exemplars** → Start your live app and run `sparda twin --learn` to fetch actual response data and construct `.sparda/twin.json` locally.
|
|
152
180
|
|
|
153
181
|
---
|
|
154
|
-
|
|
182
|
+
|
|
183
|
+
_This skill ships with `sparda-mcp` and is regenerated from SPARDA's capability
|
|
155
184
|
surface each release, so it tracks new tools and behaviors. The **live, per-project**
|
|
156
185
|
tool list, stats, and workflows always come from `sparda_get_context` at runtime —
|
|
157
|
-
trust it over any static list
|
|
186
|
+
trust it over any static list._
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sparda-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.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",
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
"lint": "eslint .",
|
|
27
27
|
"lint:fix": "eslint . --fix",
|
|
28
28
|
"format": "prettier --write \"**/*.{js,cjs,mjs}\"",
|
|
29
|
-
"format:check": "prettier --check \"**/*.{js,cjs,mjs}\""
|
|
29
|
+
"format:check": "prettier --check \"**/*.{js,cjs,mjs}\"",
|
|
30
|
+
"bench:check": "node bench/check-readme.mjs",
|
|
31
|
+
"mutation": "node tests/mutation/run.mjs"
|
|
30
32
|
},
|
|
31
33
|
"files": [
|
|
32
34
|
"src",
|
|
@@ -55,8 +57,7 @@
|
|
|
55
57
|
"@babel/parser": "7.26.5",
|
|
56
58
|
"@babel/traverse": "7.26.5",
|
|
57
59
|
"@clack/prompts": "0.9.1",
|
|
58
|
-
"@modelcontextprotocol/sdk": "1.29.0"
|
|
59
|
-
"js-tokens": "^10.0.0"
|
|
60
|
+
"@modelcontextprotocol/sdk": "1.29.0"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
63
|
"@eslint/js": "^9.39.4",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// commands/stitch.js — cross-repo / cross-service behavior stitching. Point it at two or more app
|
|
2
|
+
// directories (microservices of the same system); it compiles each, joins the outbound HTTP calls
|
|
3
|
+
// of one to the entrypoints of another, and reports the trust boundaries + any cross-service BOLA
|
|
4
|
+
// the join reveals — a finding no mono-repo tool can produce. Quorum sensing: each service's graph
|
|
5
|
+
// is a signal; the collective behavior emerges from reading them together.
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { compileUBG } from '../ubg/compile.js';
|
|
8
|
+
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
9
|
+
import { checkGraph } from '../ubg/apocalypse.js';
|
|
10
|
+
import { stitchServices } from '../ubg/stitch.js';
|
|
11
|
+
|
|
12
|
+
export async function runStitch(opts, dirs) {
|
|
13
|
+
if (!dirs || dirs.length < 2) {
|
|
14
|
+
console.error(
|
|
15
|
+
'Usage: sparda stitch <dir1> <dir2> [...] — two or more service/app directories',
|
|
16
|
+
);
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const services = [];
|
|
21
|
+
for (const d of dirs) {
|
|
22
|
+
const abs = path.resolve(opts.cwd, d);
|
|
23
|
+
try {
|
|
24
|
+
const { graph } = compileUBG(abs, { write: false });
|
|
25
|
+
const g = canonicalizeGraph(graph);
|
|
26
|
+
const { findings } = checkGraph(g);
|
|
27
|
+
services.push({ name: path.basename(abs), graph: g, findings });
|
|
28
|
+
} catch (e) {
|
|
29
|
+
console.error(` ✗ ${d}: ${e.message.slice(0, 70)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { edges, findings } = stitchServices(services);
|
|
34
|
+
|
|
35
|
+
if (opts.json) {
|
|
36
|
+
console.log(
|
|
37
|
+
JSON.stringify({ services: services.map((s) => s.name), edges, findings }, null, 2),
|
|
38
|
+
);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log(`\nSPARDA · stitch — ${services.map((s) => s.name).join(' · ')}`);
|
|
43
|
+
console.log('─'.repeat(52));
|
|
44
|
+
if (!edges.length) {
|
|
45
|
+
console.log(
|
|
46
|
+
' no cross-service calls resolved (targets may be dynamic or unrelated)',
|
|
47
|
+
);
|
|
48
|
+
} else {
|
|
49
|
+
console.log(` ${edges.length} cross-service call(s):`);
|
|
50
|
+
for (const e of edges)
|
|
51
|
+
console.log(` ${e.fromService} → ${e.toService} ${e.method} ${e.path}`);
|
|
52
|
+
}
|
|
53
|
+
if (findings.length) {
|
|
54
|
+
console.log(`\n ◐ ${findings.length} cross-service advisory(ies):`);
|
|
55
|
+
for (const f of findings) console.log(` ${f.message}`);
|
|
56
|
+
}
|
|
57
|
+
console.log('');
|
|
58
|
+
}
|
package/src/index.js
CHANGED
|
@@ -40,6 +40,34 @@ const opts = {
|
|
|
40
40
|
cwd: path.resolve(process.cwd(), getOpt('dir', null) ?? getOpt('cwd', null) ?? '.'),
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
// Per-command help — `sparda <cmd> --help` (or -h) prints usage and exits, instead of running
|
|
44
|
+
// the command (the old behavior: `--help` was ignored and the command just ran). Every command
|
|
45
|
+
// reads opts.cwd, so `--dir <path>` works on all of them; only per-command extras are listed.
|
|
46
|
+
const HELP = {
|
|
47
|
+
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.`,
|
|
49
|
+
ubg: `sparda ubg [--dir <path>] [--json] [--out <file>] [--openapi <spec>]\n Compile the codebase to its Unified Behavior Graph (.sparda/ubg.json).`,
|
|
50
|
+
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
|
+
badge: `sparda badge [--dir <path>] [--out <file>] [--json]\n Emit a shareable SVG badge + README snippet (verdict · coverage · routes).`,
|
|
52
|
+
dossier: `sparda dossier [--dir <path>] [--json]\n Render the whole proof as one self-contained, shareable HTML page.`,
|
|
53
|
+
review: `sparda review [--dir <path>] --base <ref> [--json | --markdown]\n Semantic behavior diff of the working tree vs a base git ref.`,
|
|
54
|
+
init: `sparda init [--dir <path>] [--yes]\n Scan the app, generate & inject the reversible /mcp router.`,
|
|
55
|
+
dev: `sparda dev [--dir <path>] [--port <n>]\n Run the MCP stdio bridge (connect an AI client).`,
|
|
56
|
+
remove: `sparda remove [--dir <path>] [--yes]\n Remove SPARDA from this project — byte-for-byte clean git diff.`,
|
|
57
|
+
heal: `sparda heal <flightId> [--check] [--expect <json>] [--agent <cli>]\n Turn a production bug into a fix brief + a proof gate.`,
|
|
58
|
+
timeless: `sparda timeless [list | replay <id> | export <id>]\n Record / replay a production request byte-identically.`,
|
|
59
|
+
mirror: `sparda mirror [--dir <path>] [--port <n>]\n Serve the compiled graph over HTTP — no framework, no source.`,
|
|
60
|
+
};
|
|
61
|
+
if (flags.has('--help') || rest.includes('-h')) {
|
|
62
|
+
if (cmd && HELP[cmd]) console.log(HELP[cmd]);
|
|
63
|
+
else if (cmd)
|
|
64
|
+
console.log(
|
|
65
|
+
`No detailed help for \`${cmd}\`. Run \`sparda\` for the full command list.`,
|
|
66
|
+
);
|
|
67
|
+
else console.log('Run `sparda` for the command list, or `sparda <command> --help`.');
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
43
71
|
try {
|
|
44
72
|
switch (cmd) {
|
|
45
73
|
case 'demo': {
|
|
@@ -121,6 +149,14 @@ try {
|
|
|
121
149
|
await runBadge(opts);
|
|
122
150
|
break;
|
|
123
151
|
}
|
|
152
|
+
case 'stitch': {
|
|
153
|
+
const { runStitch } = await import('./commands/stitch.js');
|
|
154
|
+
await runStitch(
|
|
155
|
+
opts,
|
|
156
|
+
rest.filter((a) => !a.startsWith('--')),
|
|
157
|
+
);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
124
160
|
case 'apocalypse': {
|
|
125
161
|
const { runApocalypse } = await import('./commands/apocalypse.js');
|
|
126
162
|
await runApocalypse(opts);
|
|
@@ -201,6 +237,7 @@ PROVE — the point
|
|
|
201
237
|
review Semantic diff of a PR vs a base ref (--base main / --json / --markdown)
|
|
202
238
|
blindspots Map SPARDA's own blindness — every unseen route/effect/guard, ranked (--json)
|
|
203
239
|
badge Emit a shareable SVG badge + README snippet (verdict · coverage · routes)
|
|
240
|
+
stitch Cross-service proof — join N repos' graphs, find boundary-crossing BOLA
|
|
204
241
|
dossier Render the whole proof as one self-contained HTML page anyone can read
|
|
205
242
|
verify Prove the compiler's own laws (determinism, soundness, round-trip)
|
|
206
243
|
heal Turn a production bug into a fix brief + a gate (--check / --expect / --agent)
|
package/src/server/stdio.js
CHANGED
|
@@ -26,8 +26,39 @@ import { createSpardaEngine } from './engine.js';
|
|
|
26
26
|
import { initiateWrite, preapproveWrite, confirmWrite } from './confirmation.js';
|
|
27
27
|
import { resolveContext, injectContext, fingerprintContext } from './context-carrier.js';
|
|
28
28
|
import { resolveSpardaKey } from '../generator/manifest.js';
|
|
29
|
+
import { compileUBG } from '../ubg/compile.js';
|
|
30
|
+
import { canonicalizeGraph } from '../ubg/schema.js';
|
|
31
|
+
import { checkGraph, diffGraphs, verdictOf, verdictState } from '../ubg/apocalypse.js';
|
|
32
|
+
import { surveyBlindspots } from '../ubg/blindspots.js';
|
|
29
33
|
|
|
30
34
|
const EVENT_POLL_MS = Number(process.env.SPARDA_EVENT_POLL_MS ?? 5000);
|
|
35
|
+
|
|
36
|
+
// Built-in MCP prompts (workflows) served by every SPARDA server, regardless of the app's own
|
|
37
|
+
// inferred workflows. `prove-my-edit` is the discoverability surface for `sparda_prove`: the
|
|
38
|
+
// workflow an editing agent lists and follows to check its OWN change before committing — the
|
|
39
|
+
// one check an LLM cannot do to itself by re-reading its code.
|
|
40
|
+
export const BUILTIN_WORKFLOWS = [
|
|
41
|
+
{
|
|
42
|
+
name: 'prove-my-edit',
|
|
43
|
+
description:
|
|
44
|
+
"Prove an edit didn't break a guard before you commit — the check an LLM can't do to itself by re-reading its own code.",
|
|
45
|
+
steps: [
|
|
46
|
+
'Call sparda_prove. If you just edited one route, pass route with its method+path (e.g. "DELETE /orders") to focus the finding list — the verdict still reflects the whole app.',
|
|
47
|
+
'Read the verdict: PROVEN / PARTIAL are safe to commit. SURFACE / NO_PROOF mean SPARDA could not resolve enough to prove it — treat that as "unknown", never as a pass.',
|
|
48
|
+
'Fix every finding with regression:true first — that edit removed a guard, dropped a route, or grew the blast radius vs the last proven baseline.',
|
|
49
|
+
'If baselined is false, run `sparda apocalypse --save-baseline` once on a known-good state so future sparda_prove calls can catch regressions.',
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// App-inferred workflows (carry-over, sacred) win on a name clash; built-ins fill the rest.
|
|
55
|
+
export function mergeWorkflows(appWorkflows) {
|
|
56
|
+
const out = [...(appWorkflows ?? [])];
|
|
57
|
+
for (const b of BUILTIN_WORKFLOWS) {
|
|
58
|
+
if (!out.some((w) => w.name === b.name)) out.push(b);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
31
62
|
// Advertised MCP server version — read from package.json so it can never drift from the
|
|
32
63
|
// published package (it was pinned at a stale '0.5.2' for many releases).
|
|
33
64
|
const SPARDA_VERSION = (() => {
|
|
@@ -340,6 +371,21 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
340
371
|
'Call this FIRST. Returns the full living context of this app: every tool with its description, known workflows, runtime telemetry (per-tool calls/errors/latency), quarantined tools, and the immune memory of past diagnosed failures. Lets any AI session resume exactly where the previous one stopped.',
|
|
341
372
|
inputSchema: { type: 'object', properties: {} },
|
|
342
373
|
},
|
|
374
|
+
{
|
|
375
|
+
name: 'sparda_prove',
|
|
376
|
+
description:
|
|
377
|
+
'Prove this app is safe to deploy — NOW, before you commit. Compiles the current source to its behavior graph and discharges the static proof obligations (unguarded mutation, non-atomic aggregate write, unvalidated constrained write). If a baseline was saved (`sparda apocalypse --save-baseline`), it ALSO diffs against it: any finding flagged `regression:true` means your edit removed a guard, dropped a route, or grew the blast radius vs the last proven state — the check an LLM cannot do to itself by re-reading code. Returns a deterministic verdict (PROVEN / PARTIAL / SURFACE / NO_PROOF / RISKY / NOT_PROVEN), the coverage % it resolved, and every finding with its route. The verdict is the exact word `sparda apocalypse` and the badge emit; it never over-claims (a low-coverage clean app reads SURFACE/PARTIAL, never a bare PROVEN). Pass `route` (e.g. "DELETE /orders") to focus the finding list on the route you just edited — the verdict still reflects the whole app.',
|
|
378
|
+
inputSchema: {
|
|
379
|
+
type: 'object',
|
|
380
|
+
properties: {
|
|
381
|
+
route: {
|
|
382
|
+
type: 'string',
|
|
383
|
+
description:
|
|
384
|
+
'Optional. Substring of a route label (method + path, e.g. "POST /invoices") to filter the findings to the route you just edited. Omit to see the whole app.',
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
},
|
|
343
389
|
{
|
|
344
390
|
name: 'sparda_confirm',
|
|
345
391
|
description:
|
|
@@ -360,14 +406,14 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
360
406
|
}));
|
|
361
407
|
|
|
362
408
|
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
363
|
-
prompts: (manifest.semantic?.workflows
|
|
409
|
+
prompts: mergeWorkflows(manifest.semantic?.workflows).map((w) => ({
|
|
364
410
|
name: w.name,
|
|
365
411
|
description: w.description,
|
|
366
412
|
})),
|
|
367
413
|
}));
|
|
368
414
|
|
|
369
415
|
server.setRequestHandler(GetPromptRequestSchema, async (req) => {
|
|
370
|
-
const w = (manifest.semantic?.workflows
|
|
416
|
+
const w = mergeWorkflows(manifest.semantic?.workflows).find(
|
|
371
417
|
(x) => x.name === req.params.name,
|
|
372
418
|
);
|
|
373
419
|
if (!w) throw new Error(`unknown prompt: ${req.params.name}`);
|
|
@@ -411,6 +457,9 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
411
457
|
),
|
|
412
458
|
);
|
|
413
459
|
}
|
|
460
|
+
if (name === 'sparda_prove') {
|
|
461
|
+
return text(JSON.stringify(proveApp(cwd, { route: args.route }), null, 2));
|
|
462
|
+
}
|
|
414
463
|
if (name === 'sparda_get_context') {
|
|
415
464
|
const headers = hostHeaders(key, ctx);
|
|
416
465
|
const [stats, events] = await Promise.all([
|
|
@@ -1081,6 +1130,76 @@ function text(t) {
|
|
|
1081
1130
|
return { content: [{ type: 'text', text: t }] };
|
|
1082
1131
|
}
|
|
1083
1132
|
|
|
1133
|
+
// The proof layer, served live over MCP — same compile + obligations as `sparda apocalypse`,
|
|
1134
|
+
// so an AI can prove its own edit structurally the moment it writes it, not a CI run later.
|
|
1135
|
+
// Reuses `verdictState` verbatim: this tool physically cannot show a word the CLI/badge won't
|
|
1136
|
+
// (the no-false-PROVEN invariant is one function, shared). Read-only (write:false); an
|
|
1137
|
+
// AI-initiated call, never on the host's request path, so Law 1 holds.
|
|
1138
|
+
// opts.route — substring; keep only findings on routes whose label matches (focus the
|
|
1139
|
+
// answer on the route the AI just edited, e.g. "DELETE /orders").
|
|
1140
|
+
export function proveApp(cwd, { route } = {}) {
|
|
1141
|
+
let canonical, report;
|
|
1142
|
+
try {
|
|
1143
|
+
const compiled = compileUBG(cwd, { write: false });
|
|
1144
|
+
canonical = canonicalizeGraph(compiled.graph);
|
|
1145
|
+
report = compiled.report;
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
// A parser gap is NOT a pass — say so honestly, never a silent green.
|
|
1148
|
+
return {
|
|
1149
|
+
verdict: 'NO_PROOF',
|
|
1150
|
+
provable: false,
|
|
1151
|
+
note: `SPARDA could not compile this app's surface (${err.message}). An uncompiled app proves nothing — this is NOT a pass.`,
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
const { findings: staticFindings, obligations } = checkGraph(canonical);
|
|
1155
|
+
|
|
1156
|
+
// Baseline diff — the regression check that IS apocalypse's edge over a plain linter: did
|
|
1157
|
+
// this edit REMOVE a guard, drop a route, or grow the blast radius vs the last saved proof?
|
|
1158
|
+
// Only if a baseline was recorded (`sparda apocalypse --save-baseline`); absent → static only.
|
|
1159
|
+
let diffFindings = [];
|
|
1160
|
+
let baselined = false;
|
|
1161
|
+
const baselinePath = path.join(cwd, '.sparda', 'ubg.baseline.json');
|
|
1162
|
+
if (fs.existsSync(baselinePath)) {
|
|
1163
|
+
try {
|
|
1164
|
+
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
|
|
1165
|
+
diffFindings = diffGraphs(baseline, canonical).findings;
|
|
1166
|
+
baselined = true;
|
|
1167
|
+
} catch {
|
|
1168
|
+
// unreadable baseline → degrade to static-only, never crash the AI's call
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
const all = [...staticFindings, ...diffFindings];
|
|
1173
|
+
const label = (id) => canonical.nodes.find((n) => n.id === id)?.label ?? id;
|
|
1174
|
+
let findings = all.map((f) => ({
|
|
1175
|
+
severity: f.severity,
|
|
1176
|
+
rule: f.rule,
|
|
1177
|
+
route: label(f.entrypoint),
|
|
1178
|
+
regression: diffFindings.includes(f),
|
|
1179
|
+
message: f.message,
|
|
1180
|
+
}));
|
|
1181
|
+
if (route) {
|
|
1182
|
+
const needle = route.toLowerCase();
|
|
1183
|
+
findings = findings.filter((f) => f.route.toLowerCase().includes(needle));
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const blind = surveyBlindspots(canonical, report);
|
|
1187
|
+
// The verdict word always reflects the WHOLE app (a route filter narrows the finding list,
|
|
1188
|
+
// never the safety claim — an AI must never read "PROVEN" because it hid the rest).
|
|
1189
|
+
const verdict = verdictOf(all, canonical, { coverage: blind.coverage.ratio });
|
|
1190
|
+
return {
|
|
1191
|
+
verdict: verdictState(verdict),
|
|
1192
|
+
provable: verdict.provable,
|
|
1193
|
+
coverage: Math.round(blind.coverage.ratio * 100) / 100,
|
|
1194
|
+
baselined,
|
|
1195
|
+
obligations,
|
|
1196
|
+
counts: verdict.counts,
|
|
1197
|
+
findings,
|
|
1198
|
+
blindspots: { surface: blind.surface, coverage: blind.coverage.ratio },
|
|
1199
|
+
...(route ? { scopedTo: route } : {}),
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1084
1203
|
// AI-facing message when the Signal-2 gate (Brief #1) refuses a `sparda_confirm`. Each reason
|
|
1085
1204
|
// tells the model what to do next without implying it can approve the write itself.
|
|
1086
1205
|
function signal2Denial(reason) {
|
package/src/ubg/apocalypse.js
CHANGED
|
@@ -68,6 +68,10 @@ export function checkGraph(graph) {
|
|
|
68
68
|
const g = indexGraph(graph);
|
|
69
69
|
const findings = [];
|
|
70
70
|
let obligations = 0;
|
|
71
|
+
// Resource tables by name — the input to BolaRay ownership-model inference (O7 below).
|
|
72
|
+
const statesByTable = new Map();
|
|
73
|
+
for (const n of g.nodes.values())
|
|
74
|
+
if (n?.kind === 'state' && n.meta?.table) statesByTable.set(n.meta.table, n);
|
|
71
75
|
// Behavior polarity (ADR-036): per entrypoint, a ternary vector over the same
|
|
72
76
|
// obligations checked below — +1 protection present, 0 not applicable, -1
|
|
73
77
|
// violated. Built HERE so a -1 is literally the same condition as the finding
|
|
@@ -208,9 +212,15 @@ export function checkGraph(graph) {
|
|
|
208
212
|
);
|
|
209
213
|
vec.aggregate = touchesRoot && vec.aggregate !== -1 ? 1 : -1;
|
|
210
214
|
if (!touchesRoot) {
|
|
215
|
+
// ADVISORY (E-046): a direct member-table write is a design-smell, not a proven
|
|
216
|
+
// violation — many ORMs/apps legitimately write members directly. On a real
|
|
217
|
+
// schema-rich app it fires in bulk (dub: 174), so it points a human at the pattern,
|
|
218
|
+
// it never gates the verdict. (Surfaced once the split-schema state layer became
|
|
219
|
+
// visible; making it hard would have flooded every folder-schema app.)
|
|
211
220
|
findings.push({
|
|
212
221
|
rule: 'AGGREGATE_MEMBER_BYPASS',
|
|
213
222
|
severity: 'info',
|
|
223
|
+
advisory: true,
|
|
214
224
|
entrypoint: ep.id,
|
|
215
225
|
message: `${ep.label} mutates member table "${state.meta.table}" of aggregate "${state.meta.consistencyDomain}" without going through its root`,
|
|
216
226
|
evidence: [w.stateId],
|
|
@@ -238,12 +248,27 @@ export function checkGraph(graph) {
|
|
|
238
248
|
const tables = [
|
|
239
249
|
...new Set(idScoped.map((n) => n.meta.table).filter(Boolean)),
|
|
240
250
|
].sort();
|
|
251
|
+
// BolaRay (CCS 2024) step 1: infer the ownership MODEL each accessed table SHOULD carry
|
|
252
|
+
// (from its declared columns/FKs), so the advisory names the scope the access is MISSING
|
|
253
|
+
// instead of a vague "verify authorization". Still advisory (soundness unchanged): the
|
|
254
|
+
// schema tells us the model, never the runtime intent — that gap is why O7 never gates.
|
|
255
|
+
const ownership = tables.map((t) => ({
|
|
256
|
+
table: t,
|
|
257
|
+
...(inferOwnershipModel(t, statesByTable) ?? { model: null, key: null }),
|
|
258
|
+
}));
|
|
259
|
+
const hints = ownership
|
|
260
|
+
.filter((o) => o.model)
|
|
261
|
+
.map((o) => `${o.table} should be ${o.model} (${o.key})`);
|
|
241
262
|
findings.push({
|
|
242
263
|
rule: 'OBJECT_SCOPE_UNPROVEN',
|
|
243
264
|
severity: 'info',
|
|
244
265
|
advisory: true,
|
|
245
266
|
entrypoint: ep.id,
|
|
246
|
-
|
|
267
|
+
ownership,
|
|
268
|
+
message:
|
|
269
|
+
`${ep.label} accesses ${tables.join(', ')} by a request-supplied id with no ownership scope proven on the path` +
|
|
270
|
+
(hints.length ? ` — ${hints.join('; ')}` : '') +
|
|
271
|
+
` — verify object-level authorization (BOLA/IDOR)`,
|
|
247
272
|
evidence: idScoped.map((n) => `${n.id} (${locOf(n)})`),
|
|
248
273
|
});
|
|
249
274
|
}
|
|
@@ -251,7 +276,55 @@ export function checkGraph(graph) {
|
|
|
251
276
|
polarity.push({ entrypoint: ep.id, vector: vec });
|
|
252
277
|
}
|
|
253
278
|
|
|
254
|
-
|
|
279
|
+
// Lateral inhibition (ADR-060): a rule that fires on a large FRACTION of the routes is a
|
|
280
|
+
// codebase-wide PATTERN, not N independent findings — 97 identical lines bury the rare, sharp
|
|
281
|
+
// signals (loss of contrast). Collapse a flooding rule into ONE summary at its MAX severity.
|
|
282
|
+
// SOUND + verdict-neutral: a hard rule stays hard and still gates (we never HIDE a finding — a
|
|
283
|
+
// suppressed danger would be a false PROVEN); we only stop it flooding the report. The retina
|
|
284
|
+
// suppresses uniform light, never an edge.
|
|
285
|
+
const collapsed = collapseFloods(findings, g.entrypoints.length);
|
|
286
|
+
|
|
287
|
+
return { findings: sortFindings(collapsed), obligations, polarity };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// A rule is "pervasive" when it fires on more than this fraction of the routes AND on at least
|
|
291
|
+
// FLOOD_MIN of them — measured on the corpus: real floods sit at 18% (dub member-bypass) / 41%
|
|
292
|
+
// (directus irreversible) on 100+ routes, while the sharp per-route signals (BOLA 10%, unvalidated
|
|
293
|
+
// 11%, unguarded 1%) stay below. The absolute floor keeps a small app (a 2-route fixture where one
|
|
294
|
+
// finding is trivially "50%") from ever collapsing — a pattern needs real breadth to be a pattern.
|
|
295
|
+
const FLOOD_DENSITY = 0.15;
|
|
296
|
+
const FLOOD_MIN = 10;
|
|
297
|
+
export function collapseFloods(findings, totalRoutes) {
|
|
298
|
+
if (!totalRoutes) return findings;
|
|
299
|
+
const byRule = new Map();
|
|
300
|
+
for (const f of findings) {
|
|
301
|
+
if (!byRule.has(f.rule)) byRule.set(f.rule, []);
|
|
302
|
+
byRule.get(f.rule).push(f);
|
|
303
|
+
}
|
|
304
|
+
const out = [];
|
|
305
|
+
for (const [rule, list] of [...byRule].sort((a, b) => cmp(a[0], b[0]))) {
|
|
306
|
+
const routes = new Set(list.map((f) => f.entrypoint));
|
|
307
|
+
if (routes.size >= FLOOD_MIN && routes.size / totalRoutes > FLOOD_DENSITY) {
|
|
308
|
+
// strongest severity wins; advisory only if EVERY collapsed finding was advisory (a hard
|
|
309
|
+
// finding in the flood keeps the summary hard — it must still gate the verdict).
|
|
310
|
+
const severity = list
|
|
311
|
+
.map((f) => f.severity)
|
|
312
|
+
.sort((a, b) => SEVERITY_RANK[a] - SEVERITY_RANK[b])[0];
|
|
313
|
+
const anyHard = list.some((f) => !f.advisory);
|
|
314
|
+
out.push({
|
|
315
|
+
rule,
|
|
316
|
+
severity,
|
|
317
|
+
...(anyHard ? {} : { advisory: true }),
|
|
318
|
+
entrypoint: '(codebase-wide)',
|
|
319
|
+
pervasive: routes.size,
|
|
320
|
+
message: `${rule} is pervasive — fires on ${routes.size}/${totalRoutes} routes; review the pattern, not each route (all routes listed in evidence)`,
|
|
321
|
+
evidence: [...routes].sort(),
|
|
322
|
+
});
|
|
323
|
+
} else {
|
|
324
|
+
out.push(...list);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
255
328
|
}
|
|
256
329
|
|
|
257
330
|
// ---------------------------------------------------------------------------
|
|
@@ -365,6 +438,38 @@ export function countProvable(graph) {
|
|
|
365
438
|
return n;
|
|
366
439
|
}
|
|
367
440
|
|
|
441
|
+
// Ownership-model inference (BolaRay, CCS 2024): from a resource table's declared columns and
|
|
442
|
+
// foreign keys, which object-level authorization model SHOULD it carry? DIRECT_OWNER (a
|
|
443
|
+
// user/owner column), GROUP_SCOPED (a workspace/team/tenant column — caller must belong to the
|
|
444
|
+
// group), TRANSITIVE (ownership reached via a FK to an owned table), or null (a shared/global
|
|
445
|
+
// resource, or intent SPARDA cannot see). Columns are lowercased (prisma.js / sql.js). Pure and
|
|
446
|
+
// bounded. Used ONLY to enrich the O7 advisory — it never changes the verdict (soundness: the
|
|
447
|
+
// schema reveals the model, never the runtime authorization intent, which is the semantic gap
|
|
448
|
+
// OWASP/BolaRay both name as the reason no static tool can PROVE access control).
|
|
449
|
+
const OWNER_DIRECT = /^(user|owner|author|creator)_?id$/;
|
|
450
|
+
const OWNER_GROUP =
|
|
451
|
+
/^(workspace|project|team|tenant|org|organization|store|customer|partner|program|account|company|group)_?id$/;
|
|
452
|
+
function inferOwnershipModel(tableName, statesByTable, seen = new Set(), depth = 0) {
|
|
453
|
+
const st = statesByTable.get(tableName);
|
|
454
|
+
if (!st || seen.has(tableName) || depth > 3) return null;
|
|
455
|
+
seen.add(tableName);
|
|
456
|
+
const cols = (st.meta?.columns ?? []).map((c) => c.name);
|
|
457
|
+
const direct = cols.find((n) => OWNER_DIRECT.test(n));
|
|
458
|
+
if (direct) return { model: 'direct-owner', key: direct };
|
|
459
|
+
const group = cols.find((n) => OWNER_GROUP.test(n));
|
|
460
|
+
if (group) return { model: 'group-scoped', key: group };
|
|
461
|
+
for (const ref of st.meta?.references ?? []) {
|
|
462
|
+
if (!ref.table || ref.table === tableName) continue;
|
|
463
|
+
const sub = inferOwnershipModel(ref.table, statesByTable, seen, depth + 1);
|
|
464
|
+
if (sub)
|
|
465
|
+
return {
|
|
466
|
+
model: 'transitive',
|
|
467
|
+
key: `${(ref.fields ?? []).join(',')}->${ref.table}.${sub.key}`,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
|
|
368
473
|
// Below this resolved/surface coverage ratio, a CLEAN app cannot claim PROVEN — it
|
|
369
474
|
// resolved too little of its own behavior for "no violation" to mean anything (the
|
|
370
475
|
// PROVEN-COMPLETE vs PROVEN-PARTIAL line). Measured floor: real PROVEN apps sit at 60%+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ubg/llm-resolve.js — LLM-assisted resolution of unverified guards, with the
|
|
2
|
+
// verify-before-admit guardrail the literature identifies as the critical step (Thakur et al.,
|
|
3
|
+
// "Interleaving static analysis and LLM prompting", STTT 2025; "Boosting Pointer Analysis with
|
|
4
|
+
// LLM-Enhanced Allocation Detection", arXiv 2509.22530). See docs/RESEARCH-AND-10X-IDEAS §Part 1.
|
|
5
|
+
//
|
|
6
|
+
// THE CONTRACT (soundness — non-negotiable): an LLM may only produce a *resolution hint* (where a
|
|
7
|
+
// guard's deny logic likely lives) or a *candidate classification*. It never asserts behavior.
|
|
8
|
+
// SPARDA re-verifies the hint STRUCTURALLY against the real graph before anything enters the
|
|
9
|
+
// proof. A guard becomes `verified` ONLY when a deny path is structurally proven — never on the
|
|
10
|
+
// model's word. Skipping this exact step is what produced E-022/E-025/E-026 (false PROVEN), the
|
|
11
|
+
// cardinal sin. This module makes the guardrail executable and testable; the hint *producer*
|
|
12
|
+
// (MCP sampling — the host never pays, CLAUDE.md rule 1) plugs in on top and is out of scope
|
|
13
|
+
// here on purpose, so no LLM dependency and no un-verified path can ever leak into the graph.
|
|
14
|
+
|
|
15
|
+
// The unverified guards worth an LLM resolution attempt: protection asserted by name, whose body
|
|
16
|
+
// SPARDA never saw deny (the `unverified-guard` blind-spot category, blindspots.js). These are
|
|
17
|
+
// the ambiguous DI/factory/opaque-import patterns the AST walker can't resolve structurally.
|
|
18
|
+
export function resolutionTargets(graph) {
|
|
19
|
+
if (!graph?.nodes) return [];
|
|
20
|
+
return graph.nodes.filter((n) => n.kind === 'guard' && !n.meta?.verified);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Admit an LLM resolution hint for ONE guard — and ONLY if SPARDA's own structural prover
|
|
24
|
+
// confirms a deny path at the hinted location. `proveDeny(hint)` is SPARDA's deny-detection,
|
|
25
|
+
// injected so this module stays testable and carries no LLM dependency itself. The hint is NEVER
|
|
26
|
+
// trusted on its own; it only tells the structural prover where to look.
|
|
27
|
+
//
|
|
28
|
+
// On success the guard is marked `verified: true` with `verifiedVia: 'llm-guided'` — a distinct
|
|
29
|
+
// provenance so an LLM-guided verification is always auditable and never silently indistinguish-
|
|
30
|
+
// able from a natively-proven guard (guardsVerified counting, dossier, audits can separate them).
|
|
31
|
+
// Returns { admitted, reason }; mutates guardNode.meta only on a confirmed admit.
|
|
32
|
+
export function admitResolutionHint(guardNode, hint, { proveDeny } = {}) {
|
|
33
|
+
if (!guardNode || guardNode.kind !== 'guard')
|
|
34
|
+
return { admitted: false, reason: 'not-a-guard' };
|
|
35
|
+
if (guardNode.meta?.verified) return { admitted: false, reason: 'already-verified' };
|
|
36
|
+
if (typeof proveDeny !== 'function')
|
|
37
|
+
return { admitted: false, reason: 'no-structural-prover' };
|
|
38
|
+
if (!hint) return { admitted: false, reason: 'no-hint' };
|
|
39
|
+
|
|
40
|
+
// THE GUARDRAIL. The model's hint buys nothing until SPARDA itself proves the deny.
|
|
41
|
+
const denies = proveDeny(hint) === true;
|
|
42
|
+
if (!denies) return { admitted: false, reason: 'hint-not-structurally-confirmed' };
|
|
43
|
+
|
|
44
|
+
guardNode.meta = { ...guardNode.meta, verified: true, verifiedVia: 'llm-guided' };
|
|
45
|
+
return { admitted: true, reason: 'structurally-confirmed' };
|
|
46
|
+
}
|
package/src/ubg/prisma.js
CHANGED
|
@@ -12,6 +12,48 @@ import path from 'node:path';
|
|
|
12
12
|
import { cmp } from './schema.js';
|
|
13
13
|
|
|
14
14
|
const SCHEMA_CANDIDATES = ['prisma/schema.prisma', 'schema.prisma', 'db/schema.prisma'];
|
|
15
|
+
// Prisma's `prismaSchemaFolder` layout (stable since 6.x): the schema is SPLIT across many
|
|
16
|
+
// `*.prisma` files under a directory, no single schema.prisma. Modern apps use it (dub: 36
|
|
17
|
+
// files). Without this, their entire state layer — invariants, aggregates, ownership — is
|
|
18
|
+
// invisible (E-046). These directory candidates are scanned when no single file is found.
|
|
19
|
+
const SCHEMA_DIR_CANDIDATES = ['prisma/schema', 'schema', 'db/schema'];
|
|
20
|
+
|
|
21
|
+
// Gather every `.prisma` file to parse: the single file if present, else all files in a schema
|
|
22
|
+
// folder (bounded, deterministic order). Returns [{ file, rel }] or [] if nothing found.
|
|
23
|
+
function collectSchemaFiles(cwd, fileCandidates, dirCandidates) {
|
|
24
|
+
for (const c of fileCandidates) {
|
|
25
|
+
const abs = path.resolve(cwd, c);
|
|
26
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return [abs];
|
|
27
|
+
}
|
|
28
|
+
for (const c of dirCandidates) {
|
|
29
|
+
const abs = path.resolve(cwd, c);
|
|
30
|
+
let stat;
|
|
31
|
+
try {
|
|
32
|
+
stat = fs.statSync(abs);
|
|
33
|
+
} catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!stat.isDirectory()) continue;
|
|
37
|
+
const files = [];
|
|
38
|
+
const walk = (dir, depth) => {
|
|
39
|
+
if (depth > 3) return;
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
47
|
+
const p = path.join(dir, e.name);
|
|
48
|
+
if (e.isDirectory()) walk(p, depth + 1);
|
|
49
|
+
else if (e.name.endsWith('.prisma')) files.push(p);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
walk(abs, 0);
|
|
53
|
+
if (files.length) return files;
|
|
54
|
+
}
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
15
57
|
|
|
16
58
|
const TYPE_MAP = {
|
|
17
59
|
string: 'string',
|
|
@@ -36,42 +78,47 @@ export function parsePrismaSchemas(cwd) {
|
|
|
36
78
|
} catch {
|
|
37
79
|
// no package.json / unparsable — the default candidates stand
|
|
38
80
|
}
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
81
|
+
const files = collectSchemaFiles(cwd, candidates, SCHEMA_DIR_CANDIDATES);
|
|
82
|
+
if (!files.length) return { tables: [], skipped, sourceFile: null };
|
|
83
|
+
|
|
84
|
+
// Read + strip comments once per file; keep the relative path for correct per-table locs.
|
|
85
|
+
const parts = [];
|
|
86
|
+
for (const abs of files) {
|
|
87
|
+
try {
|
|
88
|
+
parts.push({
|
|
89
|
+
rel: path.relative(cwd, abs).split(path.sep).join('/'),
|
|
90
|
+
clean: fs.readFileSync(abs, 'utf8').replace(/\/\/[^\n]*/g, ''),
|
|
91
|
+
});
|
|
92
|
+
} catch (err) {
|
|
93
|
+
skipped.push({ reason: `unreadable prisma schema: ${err.message}` });
|
|
94
|
+
}
|
|
50
95
|
}
|
|
51
|
-
|
|
52
|
-
const
|
|
96
|
+
if (!parts.length) return { tables: [], skipped, sourceFile: null };
|
|
97
|
+
const sourceFile = parts.length === 1 ? parts[0].rel : path.dirname(parts[0].rel);
|
|
53
98
|
|
|
54
|
-
//
|
|
99
|
+
// Enums and model names are collected ACROSS ALL files first — a model in one file may
|
|
100
|
+
// reference an enum (or a relation target) declared in another (the whole point of the
|
|
101
|
+
// folder layout). Then models are parsed per-file so each table keeps its true file:line.
|
|
55
102
|
const enums = new Map(); // lowercase name -> [values lowercase]
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
103
|
+
const modelNames = new Set();
|
|
104
|
+
for (const { clean } of parts) {
|
|
105
|
+
for (const m of clean.matchAll(/enum\s+(\w+)\s*\{([^}]*)\}/g)) {
|
|
106
|
+
const values = m[2]
|
|
107
|
+
.split(/\s+/)
|
|
108
|
+
.map((v) => v.trim())
|
|
109
|
+
.filter((v) => v && !v.startsWith('@'))
|
|
110
|
+
.map((v) => v.toLowerCase());
|
|
111
|
+
enums.set(m[1].toLowerCase(), values);
|
|
112
|
+
}
|
|
113
|
+
for (const m of clean.matchAll(/model\s+(\w+)\s*\{/g)) modelNames.add(m[1]);
|
|
63
114
|
}
|
|
64
115
|
|
|
65
|
-
const modelNames = new Set([...clean.matchAll(/model\s+(\w+)\s*\{/g)].map((m) => m[1]));
|
|
66
|
-
|
|
67
116
|
const tables = [];
|
|
68
|
-
for (const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
parseModel(modelName, body, line, sourceFile, enums, modelNames, skipped),
|
|
74
|
-
);
|
|
117
|
+
for (const { rel, clean } of parts) {
|
|
118
|
+
for (const m of clean.matchAll(/model\s+(\w+)\s*\{([^}]*)\}/g)) {
|
|
119
|
+
const line = clean.slice(0, m.index).split('\n').length;
|
|
120
|
+
tables.push(parseModel(m[1], m[2], line, rel, enums, modelNames, skipped));
|
|
121
|
+
}
|
|
75
122
|
}
|
|
76
123
|
tables.sort((a, b) => a.name.localeCompare(b.name));
|
|
77
124
|
return { tables, skipped, sourceFile };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// ubg/stitch.js — cross-repo / cross-service stitching (the moat). SAST incumbents
|
|
2
|
+
// (CodeQL/Semgrep/Snyk) are mono-repo by construction — a violation that crosses a service
|
|
3
|
+
// boundary (service A forwards a caller-supplied id to service B, which trusts it blindly) is
|
|
4
|
+
// invisible to all of them. SPARDA already produces the artifact that makes it possible: a
|
|
5
|
+
// committed `ubg.json` per repo. This pass reads N of them and joins the outbound HTTP calls of
|
|
6
|
+
// one to the entrypoints of another — the reproduction of BACTERIAL QUORUM SENSING: each service
|
|
7
|
+
// emits a signal (its graph); the collective behavior emerges from reading them together, with no
|
|
8
|
+
// central coordinator and no network access between services (each graph is committed in git).
|
|
9
|
+
//
|
|
10
|
+
// Soundness: cross-service findings are ADVISORY (same stance as BOLA/O7) — the join is a
|
|
11
|
+
// structural match, never a proof of runtime intent across the boundary. It points a human at a
|
|
12
|
+
// trust boundary to review; it never gates a verdict.
|
|
13
|
+
|
|
14
|
+
// A route path or an outbound URL target → comparable segment list. Strips scheme+host and query,
|
|
15
|
+
// lowercases, collapses every parameter form (:id, {id}, *) to a single '*' token.
|
|
16
|
+
function segmentsOf(target) {
|
|
17
|
+
let s = String(target ?? '');
|
|
18
|
+
const url = s.match(/^[a-z][a-z0-9+.-]*:\/\/[^/]+(\/.*)?$/i);
|
|
19
|
+
if (url) s = url[1] ?? '/';
|
|
20
|
+
s = s.split(/[?#]/)[0].toLowerCase();
|
|
21
|
+
return s
|
|
22
|
+
.split('/')
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.map((seg) => (/^[:{*]/.test(seg) || seg.includes('*') ? '*' : seg));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// method from an entrypoint label ("GET /users/:id") or an http_call ("httpMethod"); default GET.
|
|
28
|
+
const methodOf = (label) =>
|
|
29
|
+
(String(label).match(/^([A-Z]+)\s/)?.[1] ?? 'GET').toUpperCase();
|
|
30
|
+
const pathPart = (label) => String(label).replace(/^[A-Z]+\s+/, '');
|
|
31
|
+
|
|
32
|
+
// B's entrypoint (callee) matches A's outbound call when the methods agree and the callee's path
|
|
33
|
+
// segments are a SUFFIX of the caller's target segments — so a base-URL prefix
|
|
34
|
+
// (`http://host/api/v1/users/*` calling B's `/users/:id`) still matches. '*' matches anything.
|
|
35
|
+
function suffixMatch(callerSegs, calleeSegs) {
|
|
36
|
+
if (calleeSegs.length === 0 || calleeSegs.length > callerSegs.length) return false;
|
|
37
|
+
const off = callerSegs.length - calleeSegs.length;
|
|
38
|
+
for (let i = 0; i < calleeSegs.length; i++) {
|
|
39
|
+
const a = callerSegs[off + i];
|
|
40
|
+
const b = calleeSegs[i];
|
|
41
|
+
if (a !== b && a !== '*' && b !== '*') return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// services: [{ name, graph (canonical), findings? }]. Returns cross-service call edges and the
|
|
47
|
+
// advisory findings that ride them.
|
|
48
|
+
export function stitchServices(services) {
|
|
49
|
+
const edges = [];
|
|
50
|
+
const findings = [];
|
|
51
|
+
|
|
52
|
+
// index every service's entrypoints (the callees) by method + segments
|
|
53
|
+
const callees = [];
|
|
54
|
+
for (const svc of services) {
|
|
55
|
+
for (const n of svc.graph?.nodes ?? []) {
|
|
56
|
+
if (n.kind !== 'entrypoint') continue;
|
|
57
|
+
callees.push({
|
|
58
|
+
service: svc.name,
|
|
59
|
+
entrypoint: n.id,
|
|
60
|
+
label: n.label,
|
|
61
|
+
method: methodOf(n.label),
|
|
62
|
+
segs: segmentsOf(pathPart(n.label)),
|
|
63
|
+
bola: (svc.findings ?? []).some(
|
|
64
|
+
(f) => f.rule === 'OBJECT_SCOPE_UNPROVEN' && f.entrypoint === n.id,
|
|
65
|
+
),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const svc of services) {
|
|
71
|
+
for (const n of svc.graph?.nodes ?? []) {
|
|
72
|
+
if (n.kind !== 'effect' || n.meta?.effectType !== 'http_call') continue;
|
|
73
|
+
const callerMethod = (n.meta.httpMethod ?? 'GET').toUpperCase();
|
|
74
|
+
const callerSegs = segmentsOf(n.meta.target);
|
|
75
|
+
if (!callerSegs.length) continue;
|
|
76
|
+
for (const c of callees) {
|
|
77
|
+
if (c.service === svc.name) continue; // never stitch a service to itself
|
|
78
|
+
if (c.method !== callerMethod) continue;
|
|
79
|
+
if (!suffixMatch(callerSegs, c.segs)) continue;
|
|
80
|
+
edges.push({
|
|
81
|
+
fromService: svc.name,
|
|
82
|
+
fromEffect: n.id,
|
|
83
|
+
toService: c.service,
|
|
84
|
+
toEntrypoint: c.entrypoint,
|
|
85
|
+
method: callerMethod,
|
|
86
|
+
path: '/' + c.segs.join('/'),
|
|
87
|
+
});
|
|
88
|
+
// the trust-boundary finding: A forwards to a B endpoint B itself flagged as accessing an
|
|
89
|
+
// object by a request id with no ownership scope — so the object-level authorization
|
|
90
|
+
// across the A→B boundary is unproven. Advisory (a structural match, not runtime intent).
|
|
91
|
+
if (c.bola) {
|
|
92
|
+
findings.push({
|
|
93
|
+
rule: 'CROSS_SERVICE_OBJECT_SCOPE',
|
|
94
|
+
severity: 'info',
|
|
95
|
+
advisory: true,
|
|
96
|
+
entrypoint: c.entrypoint,
|
|
97
|
+
message: `${svc.name} calls ${c.service} ${c.method} ${'/' + c.segs.join('/')}, which accesses an object by a request id with no ownership scope proven — the object-level authorization across this service boundary is unproven (cross-service BOLA/IDOR)`,
|
|
98
|
+
evidence: [`${svc.name}:${n.id}`, `${c.service}:${c.entrypoint}`],
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { edges, findings };
|
|
105
|
+
}
|