sparda-mcp 0.66.2 → 0.68.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 CHANGED
@@ -35,6 +35,26 @@ npx sparda-mcp badge # a README badge: proven · coverage% · routes
35
35
 
36
36
  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.
37
37
 
38
+ ## The wedge — catch an AI edit that removes a guard, in the loop
39
+
40
+ The one thing a text-diff review and a pattern scanner structurally can't do: prove that **this specific edit** dropped a protection the previous version had. `sparda gate` diffs the behavior graph before/after an edit and blocks a regression — deterministic, offline, sub-second, exit 2 (the Claude Code `PostToolUse` contract that stops the agent's edit loop). See it end-to-end in one command, zero setup:
41
+
42
+ ```bash
43
+ npm run wedge # (from a clone) — or drive it on your own app with `sparda gate --arm` then `sparda gate --hook`
44
+ ```
45
+
46
+ ```
47
+ 1. baseline armed on the guarded code (POST /admin/delete-user · requireAdmin)
48
+ 2. an AI edit "simplifies" requireAdmin → a pass-through (still compiles, still 200s)
49
+ 3. sparda gate on the edit:
50
+ ✗ [critical] GUARD_REMOVED — POST /admin/delete-user was guarded in the baseline
51
+ and is now reachable without any guard (src/app.js:11)
52
+ ⏱ ~40 ms · deterministic · offline · no API key
53
+ ⛔ exit 2 on --hook — Claude Code PostToolUse blocks the edit
54
+ ```
55
+
56
+ **Wire it into Claude Code in one line** — the [plugin](integrations/claude-code-plugin) registers a `PostToolUse` hook that runs `npx -y sparda-mcp gate --hook` after every `Edit`/`Write`, so a guard-removing edit is caught before it lands.
57
+
38
58
  > [!IMPORTANT]
39
59
  > **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:
40
60
  >
@@ -44,7 +64,7 @@ Under the hood it compiles your backend into one language-agnostic graph — the
44
64
  >
45
65
  > 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.)
46
66
 
47
- **What the graph unlocks — 100% local, deterministic, zero runtime dependencies, zero API key:**
67
+ **What the graph unlocks — 100% local, deterministic, 4 exact-pinned dependencies, zero API key:**
48
68
 
49
69
  | Command | What it does |
50
70
  | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -341,12 +361,17 @@ runtime, so the guidance never goes stale.
341
361
  ## Supported frameworks
342
362
 
343
363
  - **Next.js App Router (13/14/15)** — file-based injection. SPARDA creates a catch-all route handler. It natively resolves wrapped handlers (`export const POST = withAuth(h)`) and deep effect chains.
344
- - **NestJS** — AST-based router injection. Deeply resolves Multi-hop Dependency Injection (Controller → Service → Repository), inherited DI, and `baseUrl`/`paths` imports. Supports Prisma, TypeORM, and Kysely.
345
- - **Express 4/5** (JS/TS, ESM/CJS) — AST-based router injection. Deeply resolves external controllers, Mongoose schemas, and barrel re-exports. Uses dynamic tree-scanning to find non-standard entry points (`bootstrap.ts`, etc).
364
+ - **NestJS** — AST-based router injection. Deeply resolves Multi-hop Dependency Injection (Controller → Service → Repository), inherited DI, and `baseUrl`/`paths` imports. Resolves ORM writes: Prisma, Kysely, and TypeORM injected repositories (`@InjectRepository(Entity)` → `this.repo.save()`).
365
+ - **Express 4/5** (JS/TS, ESM/CJS) — AST-based router injection. Deeply resolves external controllers, Mongoose schemas, barrel re-exports, and inline handlers. Uses dynamic tree-scanning to find non-standard entry points (`bootstrap.ts`, etc).
346
366
  - **MedusaJS** — Native AST ingestion of complex e-commerce routing.
347
367
  - **Any Backend On Earth (Go, Java, Rails, Laravel)** — Compiles flawlessly from OpenAPI 3.x specs.
348
368
  - **FastAPI** (Python >= 3.9) — AST-based router injection.
349
369
 
370
+ ### Effects it resolves (what makes the irreversibility & atomicity proofs bite)
371
+
372
+ - **Databases** — Prisma (incl. named/multiline relations and interactive `$transaction(tx ⇒ …)`), TypeORM, Kysely, Drizzle, Knex, Sequelize, Mongoose, and raw SQL. Foreign keys become aggregate/consistency domains, so a multi-table write outside a transaction is caught.
373
+ - **External side-effects** — recognized by call shape and by import origin, so an irreversible outbound effect next to a DB write is proven compensable-or-not: `fetch`/axios/got, Stripe, Twilio, SendGrid/Resend/nodemailer, AWS SDK v3 (`send(new PutObjectCommand())`), and other payment/mail/cloud/queue clients. A read on such a client stays a non-observable GET — no false alarms.
374
+
350
375
  ## Security posture (honest)
351
376
 
352
377
  - 4 runtime dependencies, exact-pinned.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.66.2",
3
+ "version": "0.68.0",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
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",
@@ -28,7 +28,8 @@
28
28
  "format": "prettier --write \"**/*.{js,cjs,mjs}\"",
29
29
  "format:check": "prettier --check \"**/*.{js,cjs,mjs}\"",
30
30
  "bench:check": "node bench/check-readme.mjs",
31
- "mutation": "node tests/mutation/run.mjs"
31
+ "mutation": "node tests/mutation/run.mjs",
32
+ "wedge": "node bench/wedge.mjs"
32
33
  },
33
34
  "files": [
34
35
  "src",
@@ -61,12 +62,12 @@
61
62
  "author": "Residual Labs (residual-labs.fr)",
62
63
  "license": "BUSL-1.1",
63
64
  "dependencies": {
65
+ "@babel/parser": "8.0.4",
66
+ "@babel/traverse": "8.0.4",
67
+ "@clack/prompts": "1.7.0",
64
68
  "@modelcontextprotocol/sdk": "1.29.0"
65
69
  },
66
70
  "devDependencies": {
67
- "@babel/parser": "^8.0.4",
68
- "@babel/traverse": "^8.0.4",
69
- "@clack/prompts": "^1.7.0",
70
71
  "@eslint/js": "^10.0.1",
71
72
  "@vitest/coverage-v8": "^4.1.10",
72
73
  "eslint": "^10.7.0",
@@ -0,0 +1,105 @@
1
+ // commands/claude-hook.js — one-command install of the SPARDA gate into Claude Code's
2
+ // PostToolUse hook, so every AI edit is proven in the loop with zero manual wiring. This is
3
+ // the DX that makes the gate irreproachable: the agent edits, the gate fires, a regression
4
+ // comes back on stderr with a fix (see gate.js). Writes to the PROJECT's .claude/settings.json
5
+ // so the whole team inherits it via the repo.
6
+ //
7
+ // Two hard constraints, both honored by the pure functions below:
8
+ // • idempotent — installing twice never duplicates the hook;
9
+ // • cleanly removable (hard rule #4) — uninstall strips EXACTLY our entry and prunes the
10
+ // containers it emptied, leaving any other hooks/settings byte-for-byte untouched.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
14
+
15
+ // the exact command the hook runs; also the marker that identifies OUR entry on removal.
16
+ export const GATE_HOOK_CMD = 'npx --no-install sparda-mcp gate --hook';
17
+ const MATCHER = 'Edit|Write';
18
+
19
+ const isOurHook = (h) => h?.type === 'command' && h?.command === GATE_HOOK_CMD;
20
+
21
+ // Ensure our PostToolUse hook is present. Pure: takes a settings object, returns
22
+ // { settings, changed }. Idempotent — if any PostToolUse block already runs our command,
23
+ // nothing changes. Never touches unrelated hooks or matchers.
24
+ export function applyClaudeHook(settings) {
25
+ const next = structuredClone(settings ?? {});
26
+ next.hooks ??= {};
27
+ const post = Array.isArray(next.hooks.PostToolUse) ? next.hooks.PostToolUse : [];
28
+ const already = post.some((block) => (block?.hooks ?? []).some(isOurHook));
29
+ if (already) return { settings: next, changed: false };
30
+ post.push({ matcher: MATCHER, hooks: [{ type: 'command', command: GATE_HOOK_CMD }] });
31
+ next.hooks.PostToolUse = post;
32
+ return { settings: next, changed: true };
33
+ }
34
+
35
+ // Strip EXACTLY our hook and prune what that emptied: our command out of each block's hooks,
36
+ // then blocks left with no hooks, then PostToolUse if empty, then hooks if empty. Any other
37
+ // hook a user added is preserved. Pure: returns { settings, changed }.
38
+ export function removeClaudeHook(settings) {
39
+ const next = structuredClone(settings ?? {});
40
+ const post = next.hooks?.PostToolUse;
41
+ if (!Array.isArray(post)) return { settings: next, changed: false };
42
+
43
+ let changed = false;
44
+ const kept = [];
45
+ for (const block of post) {
46
+ const hooks = block?.hooks ?? [];
47
+ const remaining = hooks.filter((h) => !isOurHook(h));
48
+ if (remaining.length !== hooks.length) changed = true;
49
+ if (remaining.length) kept.push({ ...block, hooks: remaining });
50
+ // a block whose only hook was ours is dropped entirely (never leave a matcher orphaned)
51
+ }
52
+ if (!changed) return { settings: next, changed: false };
53
+
54
+ if (kept.length) next.hooks.PostToolUse = kept;
55
+ else delete next.hooks.PostToolUse;
56
+ if (next.hooks && Object.keys(next.hooks).length === 0) delete next.hooks;
57
+ return { settings: next, changed: true };
58
+ }
59
+
60
+ function settingsPath(cwd) {
61
+ return path.join(cwd, '.claude', 'settings.json');
62
+ }
63
+
64
+ // read .claude/settings.json into an object. `{}` when absent; throws a USER error (never a
65
+ // silent overwrite) when present-but-unparseable — we will not clobber a file we can't read.
66
+ function readSettings(file) {
67
+ if (!fs.existsSync(file)) return {};
68
+ const raw = fs.readFileSync(file, 'utf8');
69
+ try {
70
+ return JSON.parse(raw);
71
+ } catch {
72
+ throw Object.assign(
73
+ new Error(`${file} is not valid JSON — refusing to overwrite it.`),
74
+ { code: 'USER', hint: 'Fix the JSON by hand, then re-run.' },
75
+ );
76
+ }
77
+ }
78
+
79
+ export function installClaudeHook(cwd) {
80
+ const file = settingsPath(cwd);
81
+ const { settings, changed } = applyClaudeHook(readSettings(file));
82
+ if (!changed) return { changed: false, file };
83
+ fs.mkdirSync(path.dirname(file), { recursive: true });
84
+ atomicWrite(file, JSON.stringify(settings, null, 2) + '\n');
85
+ return { changed: true, file };
86
+ }
87
+
88
+ // Uninstall for the CLI and for `sparda remove` (rule #4). Returns whether anything was
89
+ // removed. Never errors when the file/hook is absent — removal is best-effort cleanup.
90
+ export function uninstallClaudeHook(cwd) {
91
+ const file = settingsPath(cwd);
92
+ if (!fs.existsSync(file)) return { changed: false, file };
93
+ let current;
94
+ try {
95
+ current = JSON.parse(fs.readFileSync(file, 'utf8'));
96
+ } catch {
97
+ return { changed: false, file }; // unreadable — leave it exactly as-is
98
+ }
99
+ const { settings, changed } = removeClaudeHook(current);
100
+ if (!changed) return { changed: false, file };
101
+ // if we emptied the file to `{}` AND it holds nothing else, remove it; else write it back
102
+ if (Object.keys(settings).length === 0) fs.rmSync(file);
103
+ else atomicWrite(file, JSON.stringify(settings, null, 2) + '\n');
104
+ return { changed: true, file };
105
+ }
@@ -87,6 +87,30 @@ function locOf(canonical, f) {
87
87
  return loc ? ` (${loc.file}:${loc.line || 1})` : '';
88
88
  }
89
89
 
90
+ // Per-rule remediation the AGENT reads and acts on. The gate's whole value in the edit
91
+ // loop is self-heal: the agent gets exact, imperative feedback on stderr and fixes it in
92
+ // the same turn. A regression message that only says WHAT broke (not HOW to fix) makes the
93
+ // agent guess; naming the fix closes the loop. Each hint also names the honest escape hatch
94
+ // (`--arm`) for when the change was intentional, so an intended refactor is never a dead end.
95
+ const REMEDIATION = {
96
+ GUARD_REMOVED:
97
+ 'restore the guard/auth that protected this route in the baseline — or, if the route is now intentionally public, re-arm',
98
+ ENTRYPOINT_REMOVED:
99
+ 'restore this route if the removal was unintentional; if it was renamed or removed on purpose, re-arm',
100
+ INVARIANT_REMOVED:
101
+ 'restore the removed constraint on this table — or, if the schema change is intended, re-arm',
102
+ BLAST_RADIUS_GREW:
103
+ 'scope this write so it only touches its own aggregate, or confirm the wider write is intended and re-arm',
104
+ };
105
+ // A finding always resolves to actionable text: its own rule hint, or a safe generic fallback
106
+ // (never an empty string — the agent must always know its next move).
107
+ export function remediationFor(finding) {
108
+ return (
109
+ REMEDIATION[finding.rule] ??
110
+ 'review this change against the baseline; if intended, re-arm with `sparda gate --arm`'
111
+ );
112
+ }
113
+
90
114
  export async function runGate(opts) {
91
115
  const t0 = process.hrtime.bigint();
92
116
  const hook = opts.hook;
@@ -144,7 +168,7 @@ export async function runGate(opts) {
144
168
  {
145
169
  ok: blocking.length === 0,
146
170
  ms: Math.round(ms),
147
- findings,
171
+ findings: findings.map((f) => ({ ...f, fix: remediationFor(f) })),
148
172
  abstained: abstained.map((f) => f.entrypoint),
149
173
  },
150
174
  null,
@@ -175,8 +199,10 @@ export async function runGate(opts) {
175
199
  say(
176
200
  `✗ SPARDA GATE — this edit changed the app's proven behavior (${Math.round(ms)} ms, deterministic):`,
177
201
  );
178
- for (const f of findings)
202
+ for (const f of findings) {
179
203
  say(` [${f.severity}] ${f.rule} — ${f.message}${locOf(candidate, f)}`);
204
+ say(` ↳ fix: ${remediationFor(f)}`);
205
+ }
180
206
  say(
181
207
  blocking.length
182
208
  ? ` → fix the edit or, if intended, accept it with \`sparda gate --arm\`.`
@@ -99,8 +99,19 @@ export async function runProve(opts) {
99
99
  if (state === 'PROVEN') {
100
100
  console.log(` ${obligations} obligations discharged · 0 violations`);
101
101
  } else if (state === 'PARTIAL') {
102
+ // PARTIAL has three possible reasons (ADR-070): a mutation rests on an asserted-only guard
103
+ // (deny not proven), high-risk blind spots, or incomplete coverage. Name the real one(s) —
104
+ // "only X% resolved" is misleading when coverage is high but a guard is merely trusted.
105
+ const reasons = [];
106
+ if (verdict.assertedMutations > 0)
107
+ reasons.push(
108
+ `${verdict.assertedMutations} guarded mutation(s) rest on a guard SPARDA could not verify (trusted by name — the deny is not proven)`,
109
+ );
110
+ if (summary.blindHigh > 0)
111
+ reasons.push(`${summary.blindHigh} high-risk blind spot(s)`);
112
+ reasons.push(`${cov} of the surface resolved`);
102
113
  console.log(
103
- ` ${obligations} obligations discharged · 0 violations — but only ${cov} of the surface resolved; the rest is UNPROVEN, not safe`,
114
+ ` ${obligations} obligations discharged · 0 violations — PARTIAL: ${reasons.join('; ')}. The unproven part is not asserted safe.`,
104
115
  );
105
116
  } else if (state === 'NO_PROOF') {
106
117
  console.log(
@@ -6,6 +6,7 @@ import { removeInjection as removeExpress } from '../generator/express.js';
6
6
  import { removeInjection as removeFastAPI } from '../generator/fastapi.js';
7
7
  import { detectStack } from '../detect.js';
8
8
  import { removeHook } from './hook.js';
9
+ import { uninstallClaudeHook } from './claude-hook.js';
9
10
 
10
11
  export async function runRemove(opts) {
11
12
  const manifestPath = path.join(opts.cwd, 'sparda.json');
@@ -99,6 +100,8 @@ export async function runRemove(opts) {
99
100
  if (revertGitignore(opts.cwd, manifest.gitignore))
100
101
  console.log('✓ Reverted .gitignore edit');
101
102
  if (removeHook(opts.cwd)) console.log('✓ Uninstalled post-commit sentinel hook');
103
+ if (uninstallClaudeHook(opts.cwd).changed)
104
+ console.log('✓ Removed the SPARDA gate from Claude Code PostToolUse');
102
105
  fs.rmSync(manifestPath);
103
106
  fs.rmSync(path.join(opts.cwd, '.sparda'), { recursive: true, force: true });
104
107
  console.log('✓ Deleted sparda.json and .sparda/');
package/src/detect.js CHANGED
@@ -40,6 +40,15 @@ export function detectStack(cwd) {
40
40
  // out-of-the-box for a skeptic testing the framework repo directly (E-043).
41
41
  const medusaApi = medusaApiDir(cwd);
42
42
  if (medusaApi) return { framework: 'medusa', entryFile: medusaApi, port: 9000 };
43
+ // Strapi: routes are a DECLARATIVE data structure (`module.exports = { routes: [...] }`
44
+ // or `createCoreRouter(uid)`), invisible to every route-CALL scan. Detected by its dep
45
+ // (`@strapi/strapi`) OR — for the framework repo / a dep-less checkout — by its structural
46
+ // signature: a `src/api/*/routes/*` file whose export is a route table or a core router.
47
+ // Checked before express (Strapi lists koa/express transitively) so its config routes win
48
+ // over a stray bootstrap. Cheap on a non-Strapi app: the dep check is a map lookup, the
49
+ // structural probe two statSync + a bounded read only when src/api exists (E-043 style).
50
+ if (deps['@strapi/strapi'] || deps['@strapi/core'] || strapiApiDir(cwd))
51
+ return { framework: 'strapi', entryFile: 'src/api', port: 1337 };
43
52
  // A decorator-framework app (routes on `@Get`/`@Post` class methods, not
44
53
  // `app.get()`) is read by the decorator extractor even when `express` is a direct
45
54
  // dep and no `@nestjs/*` is present — n8n's home-made `@RestController` registry,
@@ -311,6 +320,31 @@ function medusaApiDir(cwd) {
311
320
  return null;
312
321
  }
313
322
 
323
+ // Strapi's structural signature: a `src/api/*/routes/*` file whose export is a route table
324
+ // (`routes:` array) or a `createCoreRouter(...)` — the two shapes strapi.js reads. Structural
325
+ // so a dep-less checkout (the framework repo, a monorepo app) is still detected. Bounded +
326
+ // short-circuits at the first match. Returns true, or false when src/api is absent/non-Strapi.
327
+ const STRAPI_ROUTE_SIG = /createCoreRouter\s*\(|routes\s*:\s*\[/;
328
+ function strapiApiDir(cwd) {
329
+ const abs = path.join(cwd, 'src/api');
330
+ try {
331
+ if (!fs.statSync(abs).isDirectory()) return false;
332
+ } catch {
333
+ return false;
334
+ }
335
+ for (const file of walkSourceFiles(abs, 400)) {
336
+ if (!/([\\/])routes([\\/])/.test(file)) continue;
337
+ let src;
338
+ try {
339
+ src = fs.readFileSync(file, 'utf8');
340
+ } catch {
341
+ continue;
342
+ }
343
+ if (STRAPI_ROUTE_SIG.test(src)) return true;
344
+ }
345
+ return false;
346
+ }
347
+
314
348
  const DECO_EXCLUDE = new Set([
315
349
  'node_modules',
316
350
  '.git',
package/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // SPARDA — Turn any codebase into an MCP server. Residual Labs.
2
+ // SPARDA — The trust layer for AI-written code. AI writes. SPARDA proves. Residual Labs.
3
3
  import { readFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
 
@@ -30,6 +30,8 @@ const opts = {
30
30
  check: flags.has('--check'),
31
31
  hook: flags.has('--hook'),
32
32
  arm: flags.has('--arm'),
33
+ installClaude: flags.has('--install-claude'),
34
+ uninstallClaude: flags.has('--uninstall-claude'),
33
35
  markdown: flags.has('--markdown'),
34
36
  port: getOpt('port', null),
35
37
  out: getOpt('out', null),
@@ -49,7 +51,7 @@ const opts = {
49
51
  const HELP = {
50
52
  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).`,
51
53
  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.`,
54
+ 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.\n --install-claude wire the gate into .claude/settings.json PostToolUse (one command).\n --uninstall-claude remove exactly that hook again (clean).`,
53
55
  ubg: `sparda ubg [--dir <path>] [--json] [--out <file>] [--openapi <spec>]\n Compile the codebase to its Unified Behavior Graph (.sparda/ubg.json).`,
54
56
  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.`,
55
57
  badge: `sparda badge [--dir <path>] [--out <file>] [--json]\n Emit a shareable SVG badge + README snippet (verdict · coverage · routes).`,
@@ -167,6 +169,26 @@ try {
167
169
  break;
168
170
  }
169
171
  case 'gate': {
172
+ if (opts.installClaude || opts.uninstallClaude) {
173
+ const { installClaudeHook, uninstallClaudeHook } =
174
+ await import('./commands/claude-hook.js');
175
+ if (opts.uninstallClaude) {
176
+ const { changed, file } = uninstallClaudeHook(opts.cwd);
177
+ console.log(
178
+ changed
179
+ ? `✓ Removed the SPARDA gate from Claude Code's PostToolUse hook (${file}).`
180
+ : `· No SPARDA gate hook found in ${file} — nothing to remove.`,
181
+ );
182
+ } else {
183
+ const { changed, file } = installClaudeHook(opts.cwd);
184
+ console.log(
185
+ changed
186
+ ? `✓ Installed — Claude Code now proves every edit in the loop.\n ${file} → PostToolUse runs \`sparda gate --hook\`. Remove with \`sparda gate --uninstall-claude\`.`
187
+ : `· Already installed — ${file} already runs the SPARDA gate on PostToolUse.`,
188
+ );
189
+ }
190
+ break;
191
+ }
170
192
  const { runGate } = await import('./commands/gate.js');
171
193
  await runGate(opts);
172
194
  break;
@@ -356,7 +356,7 @@ export async function startStdioBridge({ cwd, portOverride }) {
356
356
  {
357
357
  name: 'sparda_info',
358
358
  description:
359
- 'Info about this MCP server. Generated by SPARDA (npx sparda-mcp init) turn any codebase into an MCP server in 3 minutes. By Residual Labs (residual-labs.fr) — github.com/zyx77550/sparda',
359
+ 'Info about this SPARDA endpoint. SPARDA is the trust layer for AI-written code — AI writes, SPARDA proves: deterministic, offline behavior proofs (guards, invariants, irreversible effects) plus a live gate for AI edits, no API key. By Residual Labs (residual-labs.fr) — github.com/zyx77550/sparda',
360
360
  inputSchema: { type: 'object', properties: {} },
361
361
  },
362
362
  {
@@ -123,6 +123,36 @@ export function reachOf(epId, cfOut) {
123
123
 
124
124
  const locOf = (node) => (node?.loc ? `${node.loc.file}:${node.loc.line}` : 'unknown');
125
125
 
126
+ // The type-lock (ADR-070). Counts CLEAN mutation routes whose protection is ASSERTED-only — a
127
+ // route that mutates state, is guarded (so no UNGUARDED_MUTATION fires), but where NO guard is
128
+ // VERIFIED (SPARDA never saw a deny; the guards are trusted by name / opaque middleware). Such a
129
+ // route is clean only because an UNPROVEN signal suppressed the finding, so the app's safety rests
130
+ // on TRUST, not PROOF — it must read PARTIAL, never PROVEN. Computed here in ONE place (the verdict
131
+ // chokepoint) so the honesty rule is structural: no signal author can accidentally let an asserted
132
+ // guard buy PROVEN (E-060 was exactly that class of mistake). Only ever SOFTENS PROVEN→PARTIAL —
133
+ // same safe direction as the coverage/blind-spot rungs — never masks a finding, never touches the
134
+ // CI gate. Verified guards (incl. the ADR-069 auth-library catalog) keep a route PROVEN.
135
+ const MUTATING_EFFECT = new Set(['db_write', 'http_call', 'fs_write']);
136
+ function assertedOnlyMutations(graph) {
137
+ if (!graph) return 0;
138
+ const g = indexGraph(graph);
139
+ let count = 0;
140
+ for (const ep of g.entrypoints) {
141
+ const reached = [...reachOf(ep.id, g.cfOut)].map((id) => g.nodes.get(id));
142
+ const mutates = reached.some(
143
+ (n) =>
144
+ n?.kind === 'effect' &&
145
+ MUTATING_EFFECT.has(n.meta.effectType) &&
146
+ !n.meta.bypassesGuard,
147
+ );
148
+ if (!mutates) continue;
149
+ const guards = reached.filter((n) => n?.kind === 'guard');
150
+ if (guards.length === 0) continue; // unguarded → a hard finding already, not "clean"
151
+ if (!guards.some((n) => n.meta.verified === true)) count++; // guarded, but by trust only
152
+ }
153
+ return count;
154
+ }
155
+
126
156
  // ---------------------------------------------------------------------------
127
157
  // static obligations
128
158
  // ---------------------------------------------------------------------------
@@ -149,7 +179,16 @@ export function checkGraph(graph) {
149
179
  const writes = []; // { effect, stateId }
150
180
  for (const n of reached) {
151
181
  if (n?.kind !== 'effect') continue;
152
- for (const e of g.mutOut.get(n.id) ?? []) writes.push({ effect: n, stateId: e.to });
182
+ const outs = g.mutOut.get(n.id) ?? [];
183
+ for (const e of outs) writes.push({ effect: n, stateId: e.to });
184
+ // Opaque persistence write (ADR-068): a proven-handle call with an UNKNOWN target table has
185
+ // no mutation edge (the linker needs a table), but it IS a mutation. Count it with a null
186
+ // state so the guard obligation (O1) fires — while O2/O3/O5 skip it for lack of a state
187
+ // node, so it can NEVER fabricate value/atomicity/aggregate precision, only the unguarded
188
+ // check. This is the effect-bias inversion: a missed write is a hidden hole; an over-counted
189
+ // one is a surfaceable false positive.
190
+ if (n.meta.opaque && n.meta.effectType === 'db_write' && outs.length === 0)
191
+ writes.push({ effect: n, stateId: null });
153
192
  }
154
193
  const observables = reached.filter((n) => n?.kind === 'effect' && n.meta.observable);
155
194
  const vec = { auth: 0, validation: 0, atomicity: 0, reversibility: 0, aggregate: 0 };
@@ -304,21 +343,39 @@ export function checkGraph(graph) {
304
343
  });
305
344
  }
306
345
 
307
- // O2 — writes into constrained tables need validated input
346
+ // O2 — writes into constrained tables need validated input. A DELETE removes a row, so it
347
+ // can never violate a value constraint (CHECK / NOT NULL / UNIQUE) — those constrain the
348
+ // values a row CARRIES, not its removal. Flagging a delete here was noise (medium finding on
349
+ // `DELETE /users/:id` because `users.email` is UNIQUE). Only value-writing ops qualify; an
350
+ // unknown op (raw SQL we couldn't classify) is kept, conservatively.
308
351
  obligations++;
309
- const constrained = writes.filter((w) =>
310
- (g.nodes.get(w.stateId)?.meta.invariants ?? []).some((i) =>
311
- CONSTRAINING.has(i.type),
312
- ),
352
+ const constrained = writes.filter(
353
+ (w) =>
354
+ w.effect.meta.op !== 'delete' &&
355
+ (g.nodes.get(w.stateId)?.meta.invariants ?? []).some((i) =>
356
+ CONSTRAINING.has(i.type),
357
+ ),
313
358
  );
314
359
  if (constrained.length) vec.validation = ep.meta.inputValidated ? 1 : -1;
315
360
  if (!ep.meta.inputValidated && constrained.length) {
361
+ // Proof-grade tier: a constrained write the taint pass PROVED carries request data
362
+ // (`const { email } = req.body; insert({ email })`) is a demonstrated source→sink —
363
+ // unvalidated input reaches a column whose CHECK/NOT NULL/UNIQUE it can violate. The
364
+ // conservative flag still fires when taint is not proven (taint under-approximates —
365
+ // never drop the finding, only sharpen it when the flow is visible).
366
+ const taintedConstrained = constrained.filter((w) => w.effect.meta.tainted);
367
+ const proven = taintedConstrained.length > 0;
316
368
  findings.push({
317
369
  rule: 'UNVALIDATED_CONSTRAINED_WRITE',
318
370
  severity: 'medium',
319
371
  entrypoint: ep.id,
320
- message: `${ep.label} writes ${fmtStates(constrained)} whose declared invariants (CHECK/NOT NULL/UNIQUE) can be violated by unvalidated input`,
321
- evidence: constrained.map((w) => w.stateId),
372
+ message: proven
373
+ ? `${ep.label} lets unvalidated request data flow straight into ${fmtStates(taintedConstrained)}, whose declared invariants (CHECK/NOT NULL/UNIQUE) it can violate`
374
+ : `${ep.label} writes ${fmtStates(constrained)} whose declared invariants (CHECK/NOT NULL/UNIQUE) can be violated by unvalidated input`,
375
+ evidence: proven
376
+ ? taintedConstrained.map((w) => `${w.effect.id} (${locOf(w.effect)})`)
377
+ : constrained.map((w) => w.stateId),
378
+ ...(proven ? { tainted: true } : {}),
322
379
  });
323
380
  }
324
381
 
@@ -357,14 +414,28 @@ export function checkGraph(graph) {
357
414
  for (const obs of observables) {
358
415
  if (obs.meta.compensable) continue;
359
416
  if (g.compensators.has(obs.id)) continue; // the undo itself is not a risk
360
- bad = true;
417
+ // Innate immunity (ADR-072): only a KNOWN-dangerous observable — a recognized
418
+ // payment / mail / SMS / push effect from the SDK PAMP catalog (`target: sdk:…`) — is a
419
+ // HARD irreversible risk: you genuinely cannot un-send an email or un-charge a card, so an
420
+ // email+write with no compensation is a real saga hole. A GENERIC external call
421
+ // (`target: dynamic` — an unknown fetch, and in the wild almost always a READ: a search
422
+ // hitting an embedding API, an OAuth token handshake) is TOLERATED as an ADVISORY, not
423
+ // cried-wolf as a hard finding. This is the immune principle — attack the KNOWN pathogen,
424
+ // tolerate the unfamiliar (autoimmunity = the false positive). It kills the wolf-cry
425
+ // WITHOUT hiding a hole: the unknown call stays surfaced (advisory), at the honest
426
+ // confidence level (we cannot prove a generic fetch is irreversible), never silenced.
427
+ const knownDangerous = String(obs.meta.target ?? '').startsWith('sdk:');
361
428
  findings.push({
362
429
  rule: 'IRREVERSIBLE_OBSERVABLE',
363
- severity: 'high',
430
+ severity: knownDangerous ? 'high' : 'info',
431
+ ...(knownDangerous ? {} : { advisory: true }),
364
432
  entrypoint: ep.id,
365
- message: `${ep.label} makes an irreversible external call (${obs.meta.target ?? obs.label}) while also mutating state — no compensation path exists if the write fails`,
433
+ message: knownDangerous
434
+ ? `${ep.label} makes an irreversible external call (${obs.meta.target ?? obs.label}) while also mutating state — no compensation path exists if the write fails`
435
+ : `${ep.label} makes an external call (${obs.meta.target ?? obs.label}) while also mutating state — if that call is irreversible (payment/mail/…), add a compensation path; a generic fetch/read is fine`,
366
436
  evidence: [`${obs.id} (${locOf(obs)})`],
367
437
  });
438
+ if (knownDangerous) bad = true;
368
439
  }
369
440
  vec.reversibility = bad ? -1 : 1;
370
441
  }
@@ -701,8 +772,9 @@ export function verdictOf(findings, graph, { coverage, blindHigh = 0 } = {}) {
701
772
  provable && entrypoints > 0 && (provableBehavior === 0 || lowCoverage);
702
773
  // guard provenance: how many guards did SPARDA actually VERIFY (see a deny path in
703
774
  // the body) vs only assert by name (opaque middleware/decorators it couldn't read).
704
- // Honest signal it does not change the verdict, but it tells you how much of the
705
- // auth posture rests on trust vs proof.
775
+ // As of the type-lock (ADR-070) this is LOAD-BEARING: a clean mutation route protected
776
+ // ONLY by asserted guards forces PARTIAL (see `assertedMutations` below), so PROVEN can
777
+ // never rest on trust. The count here is the headline for the dossier/CLI.
706
778
  const guards = graph ? graph.nodes.filter((n) => n.kind === 'guard') : [];
707
779
  const guardsVerified = guards.filter((g) => g.meta?.verified).length;
708
780
  // A clean whole-app proof below the completeness bar is PARTIAL — proved, but not over
@@ -718,10 +790,16 @@ export function verdictOf(findings, graph, { coverage, blindHigh = 0 } = {}) {
718
790
  // finding, never touches the CI gate (`safe`). blindHigh defaults to 0, so a caller that
719
791
  // did not survey blind spots (heal delta) is unaffected.
720
792
  const clean = provable && !surfaceOnly && hardCount === 0;
793
+ // The type-lock (ADR-070): PROVEN requires that no CLEAN mutation route rests on an
794
+ // ASSERTED-only guard. If any does, the app is proved-but-on-trust → PARTIAL, not PROVEN.
795
+ // Only computed when the app is otherwise clean (so it can only SOFTEN PROVEN→PARTIAL).
796
+ const assertedMutations = clean ? assertedOnlyMutations(graph) : 0;
721
797
  const partial =
722
798
  clean &&
723
799
  entrypoints > 0 &&
724
- ((coverage != null && coverage < COVERAGE_COMPLETE) || blindHigh > 0);
800
+ ((coverage != null && coverage < COVERAGE_COMPLETE) ||
801
+ blindHigh > 0 ||
802
+ assertedMutations > 0);
725
803
  // `safe` is the CI gate (block a risky deploy): a surface-only app has no
726
804
  // critical/high findings and is NOT risky, so it does not fail the gate — it just
727
805
  // isn't a positive proof. `clean` is the strong claim (PROVEN) and DOES require
@@ -734,6 +812,7 @@ export function verdictOf(findings, graph, { coverage, blindHigh = 0 } = {}) {
734
812
  surfaceOnly,
735
813
  guards: guards.length,
736
814
  guardsVerified,
815
+ assertedMutations,
737
816
  safe: provable && counts.critical === 0 && counts.high === 0,
738
817
  clean,
739
818
  partial,
@@ -800,7 +879,13 @@ function sortFindings(findings) {
800
879
  }
801
880
 
802
881
  const fmtStates = (writes) =>
803
- [...new Set(writes.map((w) => w.stateId.split(':').pop()))].sort().join(', ');
882
+ [
883
+ ...new Set(
884
+ writes.map((w) => (w.stateId ? w.stateId.split(':').pop() : 'an unknown table')),
885
+ ),
886
+ ]
887
+ .sort()
888
+ .join(', ');
804
889
 
805
890
  const fmtInvariant = (inv) =>
806
891
  inv.type === 'check'
@@ -8,6 +8,7 @@ import { clearModuleCache } from './extract.js';
8
8
  import { extractExpress } from './express.js';
9
9
  import { extractNest } from './nestjs.js';
10
10
  import { extractMedusa } from './medusa.js';
11
+ import { extractStrapi } from './strapi.js';
11
12
  import { extractNext } from './nextjs.js';
12
13
  import { extractFastAPI } from './fastapi.js';
13
14
  import { extractOpenAPI } from './openapi.js';
@@ -32,6 +33,7 @@ export function compileUBG(
32
33
  express: () => extractExpress(cwd, stack.entryFile),
33
34
  nestjs: () => extractNest(cwd, stack.entryFile),
34
35
  medusa: () => extractMedusa(cwd, stack.entryFile),
36
+ strapi: () => extractStrapi(cwd, stack.entryFile),
35
37
  nextjs: () => extractNext(cwd, stack.entryFile),
36
38
  fastapi: () => extractFastAPI(cwd, stack.entryFile, stack.pythonCmd),
37
39
  // Flask reuses the FastAPI (Python) extractor — same stdlib-ast walk, Flask route