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 +20 -0
- package/package.json +3 -2
- package/src/commands/claude-hook.js +105 -0
- package/src/commands/gate.js +28 -2
- package/src/commands/prove.js +12 -1
- package/src/commands/remove.js +3 -0
- package/src/detect.js +34 -0
- package/src/index.js +24 -2
- package/src/server/stdio.js +1 -1
- package/src/ubg/apocalypse.js +89 -10
- package/src/ubg/compile.js +2 -0
- package/src/ubg/express.js +39 -1
- package/src/ubg/extract.js +393 -26
- package/src/ubg/nestjs.js +262 -11
- package/src/ubg/resolve.js +58 -5
- package/src/ubg/strapi.js +510 -0
- package/src/ubg/translate.js +3 -0
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.
|
|
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
|
+
}
|
package/src/commands/gate.js
CHANGED
|
@@ -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\`.`
|
package/src/commands/prove.js
CHANGED
|
@@ -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 —
|
|
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(
|
package/src/commands/remove.js
CHANGED
|
@@ -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 —
|
|
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;
|
package/src/server/stdio.js
CHANGED
|
@@ -356,7 +356,7 @@ export async function startStdioBridge({ cwd, portOverride }) {
|
|
|
356
356
|
{
|
|
357
357
|
name: 'sparda_info',
|
|
358
358
|
description:
|
|
359
|
-
'Info about this
|
|
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
|
{
|
package/src/ubg/apocalypse.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
327
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
711
|
-
//
|
|
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) ||
|
|
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
|
-
[
|
|
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'
|
package/src/ubg/compile.js
CHANGED
|
@@ -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
|
package/src/ubg/express.js
CHANGED
|
@@ -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,
|