sparda-mcp 0.67.0 → 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
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.67.0",
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",
@@ -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 };
@@ -319,12 +358,24 @@ export function checkGraph(graph) {
319
358
  );
320
359
  if (constrained.length) vec.validation = ep.meta.inputValidated ? 1 : -1;
321
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;
322
368
  findings.push({
323
369
  rule: 'UNVALIDATED_CONSTRAINED_WRITE',
324
370
  severity: 'medium',
325
371
  entrypoint: ep.id,
326
- message: `${ep.label} writes ${fmtStates(constrained)} whose declared invariants (CHECK/NOT NULL/UNIQUE) can be violated by unvalidated input`,
327
- 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 } : {}),
328
379
  });
329
380
  }
330
381
 
@@ -363,14 +414,28 @@ export function checkGraph(graph) {
363
414
  for (const obs of observables) {
364
415
  if (obs.meta.compensable) continue;
365
416
  if (g.compensators.has(obs.id)) continue; // the undo itself is not a risk
366
- 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:');
367
428
  findings.push({
368
429
  rule: 'IRREVERSIBLE_OBSERVABLE',
369
- severity: 'high',
430
+ severity: knownDangerous ? 'high' : 'info',
431
+ ...(knownDangerous ? {} : { advisory: true }),
370
432
  entrypoint: ep.id,
371
- 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`,
372
436
  evidence: [`${obs.id} (${locOf(obs)})`],
373
437
  });
438
+ if (knownDangerous) bad = true;
374
439
  }
375
440
  vec.reversibility = bad ? -1 : 1;
376
441
  }
@@ -707,8 +772,9 @@ export function verdictOf(findings, graph, { coverage, blindHigh = 0 } = {}) {
707
772
  provable && entrypoints > 0 && (provableBehavior === 0 || lowCoverage);
708
773
  // guard provenance: how many guards did SPARDA actually VERIFY (see a deny path in
709
774
  // the body) vs only assert by name (opaque middleware/decorators it couldn't read).
710
- // Honest signal it does not change the verdict, but it tells you how much of the
711
- // 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.
712
778
  const guards = graph ? graph.nodes.filter((n) => n.kind === 'guard') : [];
713
779
  const guardsVerified = guards.filter((g) => g.meta?.verified).length;
714
780
  // A clean whole-app proof below the completeness bar is PARTIAL — proved, but not over
@@ -724,10 +790,16 @@ export function verdictOf(findings, graph, { coverage, blindHigh = 0 } = {}) {
724
790
  // finding, never touches the CI gate (`safe`). blindHigh defaults to 0, so a caller that
725
791
  // did not survey blind spots (heal delta) is unaffected.
726
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;
727
797
  const partial =
728
798
  clean &&
729
799
  entrypoints > 0 &&
730
- ((coverage != null && coverage < COVERAGE_COMPLETE) || blindHigh > 0);
800
+ ((coverage != null && coverage < COVERAGE_COMPLETE) ||
801
+ blindHigh > 0 ||
802
+ assertedMutations > 0);
731
803
  // `safe` is the CI gate (block a risky deploy): a surface-only app has no
732
804
  // critical/high findings and is NOT risky, so it does not fail the gate — it just
733
805
  // isn't a positive proof. `clean` is the strong claim (PROVEN) and DOES require
@@ -740,6 +812,7 @@ export function verdictOf(findings, graph, { coverage, blindHigh = 0 } = {}) {
740
812
  surfaceOnly,
741
813
  guards: guards.length,
742
814
  guardsVerified,
815
+ assertedMutations,
743
816
  safe: provable && counts.critical === 0 && counts.high === 0,
744
817
  clean,
745
818
  partial,
@@ -806,7 +879,13 @@ function sortFindings(findings) {
806
879
  }
807
880
 
808
881
  const fmtStates = (writes) =>
809
- [...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(', ');
810
889
 
811
890
  const fmtInvariant = (inv) =>
812
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
@@ -6,11 +6,24 @@
6
6
  // Depth stays bounded like the v0 parser: entry file + mounted routers.
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { parseModule, resolveRelImport } from './extract.js';
9
+ import { parseModule, resolveRelImport, authDenyCall } from './extract.js';
10
10
  import { createResolver, relOf } from './resolve.js';
11
11
 
12
12
  const HTTP = new Set(['get', 'post', 'put', 'patch', 'delete']);
13
13
 
14
+ // A minimal scan carrying ONLY the deny signal — attached to a middleware recognized as a known
15
+ // auth-library guard (ADR-069: `passport.authenticate()`, `expressjwt({…})`) whose body lives in
16
+ // node_modules and so can't be read in-repo. The catalog is a VERIFIED published fact, so the guard
17
+ // earns `verified` (→ can reach PROVEN), not merely `asserted`. No effects/reads enter the graph.
18
+ const AUTH_DENY_SCAN = {
19
+ effects: [],
20
+ returnShapes: [],
21
+ calls: [],
22
+ async: false,
23
+ validatesInput: false,
24
+ guardSignals: { deniesWithStatus: true },
25
+ };
26
+
14
27
  // → { routes, globalMiddlewares, helpers, skipped, scannedFiles }
15
28
  export function extractExpress(cwd, entryFile) {
16
29
  const routes = [];
@@ -251,6 +264,20 @@ export function extractExpress(cwd, entryFile) {
251
264
  scan: resolver.deepScan(fnArg, mod),
252
265
  };
253
266
  }
267
+ // known auth-library deny-form middleware (ADR-069): `passport.authenticate('jwt')`,
268
+ // `expressjwt({…})`. Its body is in node_modules (opaque), but the catalog is a verified
269
+ // published fact that it denies — so the guard reads VERIFIED, not asserted, and its app
270
+ // can legitimately reach PROVEN. Deny-FORM precision (a custom callback / credentialsRequired
271
+ // false) is handled in authDenyCall, which abstains rather than over-verify.
272
+ if (authDenyCall(arg, mod.authGuards?.pkgOf)) {
273
+ return {
274
+ name,
275
+ sourceFile: relFile,
276
+ sourceLine: arg.loc?.start.line ?? 0,
277
+ fn: null,
278
+ scan: AUTH_DENY_SCAN,
279
+ };
280
+ }
254
281
  // factory middleware: validate(schema), rateLimit({…}) — the factory
255
282
  // name is known, the produced closure is out of static reach (blind node)
256
283
  return {
@@ -333,6 +360,17 @@ export function extractExpress(cwd, entryFile) {
333
360
  };
334
361
  }
335
362
  }
363
+ // aliased auth-library guard (ADR-069): `const requireAuth = passport.authenticate('jwt')`
364
+ // used by name here — verified by the catalog, exactly like the inline form above.
365
+ if (mod.authGuards?.denyBindings?.has(arg.name)) {
366
+ return {
367
+ name: arg.name,
368
+ sourceFile: relFile,
369
+ sourceLine: arg.loc?.start.line ?? 0,
370
+ fn: null,
371
+ scan: AUTH_DENY_SCAN,
372
+ };
373
+ }
336
374
  // known but bodyless — node exists, microscope stays blind
337
375
  return {
338
376
  name: arg.name,