sparda-mcp 0.64.0 → 0.66.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
@@ -6,11 +6,12 @@
6
6
 
7
7
  <br/>
8
8
 
9
- > 🇫🇷 **Français** : Pour comprendre SPARDA en 10 minutes (douleur, architecture, vision), lisez le document du fondateur : [SPARDA-EXPLIQUE.md](docs/SPARDA-EXPLIQUE.md).
9
+ > 🇫🇷 **Français** _L'IA écrit. SPARDA prouve._ Un gate déterministe et hors-ligne qui détecte quand une modif d'IA retire une garde, expose une route ou casse un invariant — sans clé API, directement dans la boucle d'édition de l'agent. Pour tout comprendre en 10 minutes (douleur, architecture, vision) : [SPARDA-EXPLIQUE.md](docs/SPARDA-EXPLIQUE.md).
10
10
 
11
11
  ---
12
12
 
13
13
  <h1 align="center">AI writes. SPARDA proves.</h1>
14
+ <p align="center"><em>L'IA écrit. SPARDA prouve.</em></p>
14
15
 
15
16
  **The trust layer for AI-written backends.** SPARDA compiles your backend — routes, database queries, state mutations, guards, side-effects — into one deterministic behavior graph, then **statically proves what can and can't break before you ship**: no unguarded mutation, no broken invariant, no non-atomic aggregate write.
16
17
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.64.0",
3
+ "version": "0.66.0",
4
4
  "mcpName": "io.github.zyx77550/sparda-mcp",
5
- "description": "Compile backends to behavior graphs. Statically prove security, simulate APIs, and replay bugs.",
5
+ "description": "AI writes. SPARDA proves. A deterministic, offline gate that catches when an AI edit removes a guard, exposes a route, or breaks an invariant — no API key, right in the agent edit loop.",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -46,10 +46,16 @@
46
46
  "mcp",
47
47
  "model-context-protocol",
48
48
  "claude",
49
- "ai",
50
- "express",
51
- "fastapi",
52
- "codegen"
49
+ "claude-code",
50
+ "ai-code-review",
51
+ "code-review",
52
+ "guardrails",
53
+ "static-analysis",
54
+ "security",
55
+ "authorization",
56
+ "bola",
57
+ "ai-agents",
58
+ "trust-layer"
53
59
  ],
54
60
  "author": "Residual Labs (residual-labs.fr)",
55
61
  "license": "BUSL-1.1",
@@ -8,12 +8,38 @@
8
8
  // sparda apocalypse --json raw findings for tooling
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
+ import crypto from 'node:crypto';
11
12
  import { compileUBG } from '../ubg/compile.js';
12
13
  import { canonicalizeGraph } from '../ubg/schema.js';
13
- import { checkGraph, diffGraphs, verdictOf } from '../ubg/apocalypse.js';
14
+ import {
15
+ checkGraph,
16
+ diffGraphs,
17
+ verdictOf,
18
+ verdictState,
19
+ buildProofObjects,
20
+ } from '../ubg/apocalypse.js';
14
21
  import { surveyBlindspots } from '../ubg/blindspots.js';
15
22
  import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
16
23
 
24
+ // version travels with the proof so an audit knows which prover produced it
25
+ const SPARDA_VERSION = (() => {
26
+ try {
27
+ return JSON.parse(
28
+ fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
29
+ ).version;
30
+ } catch {
31
+ return '0.0.0';
32
+ }
33
+ })();
34
+
35
+ // a deterministic fingerprint of the canonical graph: same source → same graph → same hash.
36
+ // The proof object binds to it, so a third party proves it audited the SAME artifact.
37
+ function graphHash(canonical) {
38
+ const h = crypto.createHash('sha256');
39
+ h.update(JSON.stringify({ nodes: canonical.nodes, edges: canonical.edges }));
40
+ return 'bh1_' + h.digest('hex').slice(0, 16);
41
+ }
42
+
17
43
  const ICONS = { critical: '✗', high: '✗', medium: '⚠', info: '·' };
18
44
 
19
45
  export async function runApocalypse(opts) {
@@ -61,6 +87,21 @@ export async function runApocalypse(opts) {
61
87
  );
62
88
  }
63
89
 
90
+ if (opts.proof) {
91
+ const proofPath = path.join(opts.cwd, '.sparda', 'apocalypse.proof.json');
92
+ fs.mkdirSync(path.dirname(proofPath), { recursive: true });
93
+ const proof = {
94
+ sparda_version: SPARDA_VERSION,
95
+ graph_hash: graphHash(canonical),
96
+ verdict: verdictState(verdict),
97
+ discharged: buildProofObjects(canonical),
98
+ };
99
+ atomicWrite(proofPath, JSON.stringify(proof, null, 2) + '\n');
100
+ console.log(
101
+ `✓ Proof written: .sparda/apocalypse.proof.json (${proof.discharged.length} discharged obligation(s), re-verifiable against ${proof.graph_hash})`,
102
+ );
103
+ }
104
+
64
105
  if (opts.json) {
65
106
  console.log(
66
107
  JSON.stringify({ verdict, obligations, findings, blindspots: blind }, null, 2),
@@ -0,0 +1,114 @@
1
+ // commands/gate.js — the agent edit-loop gate: did THIS edit lose a protection
2
+ // or introduce a risk, relative to the armed baseline? Delta-only by design: an
3
+ // edit gate must never scream about pre-existing state (that noise is fatal to
4
+ // adoption — see docs/TWO-FALSE-POSITIVE-CLASSES-2026-07-19.md), so everything
5
+ // already true at arm time is silent and only the regression speaks.
6
+ // sparda gate report the edit's behavior delta vs baseline (exit 1 on critical/high)
7
+ // sparda gate --arm freeze the current graph as the accepted baseline
8
+ // sparda gate --hook Claude Code PostToolUse contract: silent when clean,
9
+ // report on stderr + exit 2 (blocking feedback) on regression;
10
+ // arms itself on first run so there is nothing to configure.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { compileUBG } from '../ubg/compile.js';
14
+ import { canonicalizeGraph } from '../ubg/schema.js';
15
+ import { checkGraph, diffGraphs } from '../ubg/apocalypse.js';
16
+ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
17
+
18
+ // severities that block the edit; medium/info regressions are reported, never fatal
19
+ const BLOCKING = new Set(['critical', 'high']);
20
+ const findingKey = (f) => `${f.rule}|${f.entrypoint}`;
21
+
22
+ // The gate's whole judgment, pure over two canonical graphs (testable without a repo):
23
+ // diff regressions (guard/route/invariant lost, blast radius grown) + static findings
24
+ // the candidate has that the baseline did not — the same composition `review` proved
25
+ // out (ADR-030), pointed at the baseline instead of a git ref.
26
+ export function gateDelta(baseline, candidate) {
27
+ const diff = diffGraphs(baseline, candidate).findings;
28
+ const before = new Set(checkGraph(baseline).findings.map(findingKey));
29
+ const introduced = checkGraph(candidate).findings.filter(
30
+ (f) => !before.has(findingKey(f)),
31
+ );
32
+ const seen = new Set(diff.map(findingKey));
33
+ const findings = [...diff, ...introduced.filter((f) => !seen.has(findingKey(f)))];
34
+ return {
35
+ findings,
36
+ blocking: findings.filter((f) => BLOCKING.has(f.severity)),
37
+ };
38
+ }
39
+
40
+ function locOf(canonical, f) {
41
+ const loc = canonical.nodes.find((n) => n.id === f.entrypoint)?.loc;
42
+ return loc ? ` (${loc.file}:${loc.line || 1})` : '';
43
+ }
44
+
45
+ export async function runGate(opts) {
46
+ const t0 = process.hrtime.bigint();
47
+ const hook = opts.hook;
48
+ // in hook mode stdout belongs to nobody and stderr is what the agent reads
49
+ const say = hook ? (s) => console.error(s) : (s) => console.log(s);
50
+
51
+ const { graph } = compileUBG(opts.cwd, { write: false });
52
+ const candidate = canonicalizeGraph(graph);
53
+ const baselinePath = path.join(opts.cwd, '.sparda', 'ubg.baseline.json');
54
+
55
+ const arm = () => {
56
+ fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
57
+ atomicWrite(baselinePath, JSON.stringify(candidate, null, 2) + '\n');
58
+ };
59
+
60
+ if (opts.arm || opts.saveBaseline) {
61
+ arm();
62
+ say(
63
+ `✓ GATE ARMED — baseline frozen (.sparda/ubg.baseline.json). Every edit is now proven against it.`,
64
+ );
65
+ return { armed: true, findings: [] };
66
+ }
67
+
68
+ if (!fs.existsSync(baselinePath)) {
69
+ // first run arms instead of failing: a hook must work with zero configuration
70
+ arm();
71
+ say(
72
+ `✓ GATE ARMED (first run) — baseline frozen. Future edits are proven against it; re-arm with \`sparda gate --arm\` after intended changes.`,
73
+ );
74
+ return { armed: true, findings: [] };
75
+ }
76
+
77
+ const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
78
+ const { findings, blocking } = gateDelta(baseline, candidate);
79
+ const ms = Number(process.hrtime.bigint() - t0) / 1e6;
80
+
81
+ if (opts.json) {
82
+ console.log(
83
+ JSON.stringify(
84
+ { ok: blocking.length === 0, ms: Math.round(ms), findings },
85
+ null,
86
+ 2,
87
+ ),
88
+ );
89
+ if (blocking.length) process.exitCode = 1;
90
+ return { armed: false, findings };
91
+ }
92
+
93
+ if (findings.length === 0) {
94
+ if (!hook)
95
+ say(
96
+ `✓ GATE CLEAN — this edit lost no guard, dropped no route, grew no blast radius (proven vs baseline in ${Math.round(ms)} ms).`,
97
+ );
98
+ return { armed: false, findings };
99
+ }
100
+
101
+ say(
102
+ `✗ SPARDA GATE — this edit changed the app's proven behavior (${Math.round(ms)} ms, deterministic):`,
103
+ );
104
+ for (const f of findings)
105
+ say(` [${f.severity}] ${f.rule} — ${f.message}${locOf(candidate, f)}`);
106
+ say(
107
+ blocking.length
108
+ ? ` → fix the edit or, if intended, accept it with \`sparda gate --arm\`.`
109
+ : ` → advisory only (no critical/high regression) — review, then \`sparda gate --arm\` if intended.`,
110
+ );
111
+
112
+ if (blocking.length) process.exitCode = hook ? 2 : 1;
113
+ return { armed: false, findings };
114
+ }
package/src/index.js CHANGED
@@ -18,6 +18,7 @@ const opts = {
18
18
  yes: flags.has('--yes') || flags.has('-y'),
19
19
  saveBaseline: flags.has('--save-baseline'),
20
20
  sarif: flags.has('--sarif'),
21
+ proof: flags.has('--proof'),
21
22
  verbose: flags.has('--verbose'),
22
23
  quiet: flags.has('--quiet'),
23
24
  probe: flags.has('--probe'),
@@ -27,6 +28,8 @@ const opts = {
27
28
  learn: flags.has('--learn'),
28
29
  germinate: flags.has('--germinate'),
29
30
  check: flags.has('--check'),
31
+ hook: flags.has('--hook'),
32
+ arm: flags.has('--arm'),
30
33
  markdown: flags.has('--markdown'),
31
34
  port: getOpt('port', null),
32
35
  out: getOpt('out', null),
@@ -45,7 +48,8 @@ const opts = {
45
48
  // reads opts.cwd, so `--dir <path>` works on all of them; only per-command extras are listed.
46
49
  const HELP = {
47
50
  prove: `sparda prove [--dir <path>] [--json | --markdown]\n The whole trust verdict: proof + coverage + a shareable seal.\n --markdown emit a sticky-PR-comment body (used by the GitHub Action).`,
48
- apocalypse: `sparda apocalypse [--dir <path>] [--sarif] [--save-baseline]\n Prove the deploy — exit 1 on any critical/high finding.\n --sarif also write .sparda/apocalypse.sarif for the Security tab.\n --save-baseline freeze this graph; later runs diff against it.`,
51
+ apocalypse: `sparda apocalypse [--dir <path>] [--sarif] [--proof] [--save-baseline]\n Prove the deploy — exit 1 on any critical/high finding.\n --sarif also write .sparda/apocalypse.sarif for the Security tab.\n --proof also write .sparda/apocalypse.proof.json — a re-verifiable\n discharge trace (deny_path per guarded mutation) a third party\n can audit against the graph hash without re-compiling.\n --save-baseline freeze this graph; later runs diff against it.`,
52
+ gate: `sparda gate [--dir <path>] [--arm] [--hook] [--json]\n The agent edit-loop gate: prove THIS edit lost no guard, dropped no route,\n grew no blast radius — delta vs the armed baseline only (pre-existing state\n never blocks an edit). Arms itself on first run.\n --arm accept the current graph as the new baseline.\n --hook Claude Code PostToolUse contract: silent when clean, report on\n stderr + exit 2 (blocking feedback to the agent) on regression.`,
49
53
  ubg: `sparda ubg [--dir <path>] [--json] [--out <file>] [--openapi <spec>]\n Compile the codebase to its Unified Behavior Graph (.sparda/ubg.json).`,
50
54
  stitch: `sparda stitch <dir1> <dir2> [...] [--json]\n Cross-service proof: join one service's outbound HTTP calls to another's routes,\n surface cross-service trust boundaries + BOLA no mono-repo tool can see.`,
51
55
  badge: `sparda badge [--dir <path>] [--out <file>] [--json]\n Emit a shareable SVG badge + README snippet (verdict · coverage · routes).`,
@@ -162,6 +166,11 @@ try {
162
166
  await runApocalypse(opts);
163
167
  break;
164
168
  }
169
+ case 'gate': {
170
+ const { runGate } = await import('./commands/gate.js');
171
+ await runGate(opts);
172
+ break;
173
+ }
165
174
  case 'review': {
166
175
  const { runReview } = await import('./commands/review.js');
167
176
  await runReview(opts);
@@ -234,6 +243,7 @@ try {
234
243
  PROVE — the point
235
244
  prove The whole trust verdict in one gesture: proof + coverage + seal (--json / --markdown / --openapi)
236
245
  apocalypse Prove the deploy: guards, invariants, transactions, aggregates (--save-baseline)
246
+ gate The agent edit-loop gate: prove an edit lost no protection (--arm / --hook)
237
247
  review Semantic diff of a PR vs a base ref (--base main / --json / --markdown)
238
248
  blindspots Map SPARDA's own blindness — every unseen route/effect/guard, ranked (--json)
239
249
  badge Emit a shareable SVG badge + README snippet (verdict · coverage · routes)
@@ -42,6 +42,66 @@ export function indexGraph(graph) {
42
42
  return { nodes, cfOut, mutOut, gateTargets, compensators, entrypoints };
43
43
  }
44
44
 
45
+ // Proof objects (the re-verifiable discharge trace, ADR-corroboration). `apocalypse` emits a
46
+ // VERDICT; a skeptic must re-run SPARDA to believe it. A proof object makes each discharged
47
+ // obligation auditable WITHOUT re-compiling: for every mutating route that passes its guard
48
+ // obligation, the exact `deny_path` — a real chain of node ids in the committed ubg.json — that
49
+ // a third party can trace to confirm the guard gates the write. Deterministic (same graph → same
50
+ // object, testable like a `verify` law), and honest: it reports provenance (verified vs asserted)
51
+ // and corroboration (which independent paths agree), never a bare "trust me, it's PROVEN".
52
+ export function buildProofObjects(graph) {
53
+ const g = indexGraph(graph);
54
+ const proofs = [];
55
+ for (const ep of g.entrypoints) {
56
+ const reached = [...reachOf(ep.id, g.cfOut)]
57
+ .sort()
58
+ .map((id) => g.nodes.get(id))
59
+ .filter(Boolean);
60
+ const guards = reached.filter((n) => n.kind === 'guard');
61
+ const writes = [];
62
+ for (const n of reached)
63
+ if (n.kind === 'effect')
64
+ for (const e of g.mutOut.get(n.id) ?? [])
65
+ writes.push({ effect: n, stateId: e.to });
66
+ // a proof object is emitted ONLY for a genuinely DISCHARGED obligation: a mutating route
67
+ // (O1 applies) that actually reaches a guard. A guardless mutation is a FINDING, not a proof.
68
+ if (!writes.length || !guards.length) continue;
69
+ const guardInfo = guards
70
+ .map((gd) => ({
71
+ guard: gd.label,
72
+ node: gd.id,
73
+ // MUST-analysis honesty: 'verified' only when a deny path was SEEN in the body;
74
+ // an opaque middleware trusted by name is 'asserted' — the proof says which.
75
+ provenance: gd.meta?.verified ? 'verified' : 'asserted',
76
+ via: gd.meta?.verifiedVia ?? 'structural',
77
+ }))
78
+ .sort((a, b) => cmp(a.node, b.node));
79
+ proofs.push({
80
+ obligation: 'GUARDED_MUTATION',
81
+ route: ep.label,
82
+ entrypoint: ep.id,
83
+ discharged_by: {
84
+ // the obligation is only as strong as its weakest guard: verified iff ALL are verified
85
+ provenance: guardInfo.every((x) => x.provenance === 'verified')
86
+ ? 'verified'
87
+ : 'asserted',
88
+ corroboratedBy: [...new Set(guardInfo.map((x) => x.via))].sort(),
89
+ guards: guardInfo.map(({ guard, node, provenance }) => ({
90
+ guard,
91
+ node,
92
+ provenance,
93
+ })),
94
+ // the re-checkable path: every id exists in ubg.json; a gate edge links guard→handler,
95
+ // and the handler control-flow-reaches each listed mutation. Trace it to confirm.
96
+ deny_path: [ep.id, ...guardInfo.map((x) => x.node)],
97
+ mutations: [...new Set(writes.map((w) => w.effect.id))].sort(),
98
+ },
99
+ });
100
+ }
101
+ proofs.sort((a, b) => cmp(a.entrypoint, b.entrypoint));
102
+ return proofs;
103
+ }
104
+
45
105
  export function reachOf(epId, cfOut) {
46
106
  const seen = new Set();
47
107
  const queue = [epId];
@@ -100,15 +160,100 @@ export function checkGraph(graph) {
100
160
  // sharpens the worst routes (open AND fed by user input) without adding false
101
161
  // alarms. Under-approximated (SOUNDNESS Direction 1): a missed tag hides nothing.
102
162
  const tainted = writes.filter((w) => w.effect.meta.tainted);
163
+ // G2 (guard taxonomy families B–D): a public-by-design route whose body checks a
164
+ // CREDENTIAL and can refuse — a stored-token lookup (reset/invite/verification), a
165
+ // signature/token verify call, or a webhook/callback handshake — is not "unguarded"
166
+ // in the critical sense; it is gated by a mechanism the guard model doesn't carry.
167
+ // The invariant (non-negotiable): this can only DOWNGRADE critical → advisory, with
168
+ // the mechanism named. It never proves (identity scope stays unproven), never
169
+ // silences (the finding remains, visible), so it can never make a false PROVEN on
170
+ // its own — though fewer hard findings CAN promote a clean app; that is the
171
+ // taxonomy's sanctioned direction (a stored-token reset flow is not a vuln).
172
+ // Credential/refusal signals reach the entrypoint two ways: from its own middleware chain
173
+ // (ep.meta.*, set in translate) OR from a body it REACHES through the call graph (the
174
+ // owner.meta.body* tags — the imported/DI service where the throw actually lives). The
175
+ // second path is what closes the first-run and API-key false criticals: their refusal sits
176
+ // in a service method with no effect of its own, so the signal never rode the direct chain.
177
+ const reachedDenies = reached.some((n) => n?.meta?.bodyDenies);
178
+ const reachedVerifies = reached.some((n) => n?.meta?.bodyVerifies);
179
+ const reachedRedirects = reached.some((n) => n?.meta?.bodyRedirects);
180
+ const credGates = ep.meta?.credentialGates === true || reachedDenies;
181
+ const credVerify = ep.meta?.credentialVerify === true || reachedVerifies;
182
+ const credRedirects = ep.meta?.credentialRedirects === true || reachedRedirects;
183
+ // family A/B: a stored-credential lookup — reset/invite/OTP/magic tokens AND long-lived API
184
+ // keys / access tokens / personal-access tokens. Same "read the secret, refuse if it doesn't
185
+ // match" mechanism; only the secret's lifetime differs.
186
+ const tokenRead = reached.some(
187
+ (n) =>
188
+ n?.kind === 'effect' &&
189
+ n.meta.effectType === 'db_read' &&
190
+ /token|verif|invite|otp|magic|api[_-]?key|access[_-]?token|credential|personal[_-]?access/i.test(
191
+ n.meta.table ?? '',
192
+ ),
193
+ );
194
+ const callbackish = /(^|[:/])(callback|webhook|hook)s?([/:]|$)/i.test(ep.label);
195
+ // family E (first-run / admin-setup): a bootstrap-shaped route — sign-up-admin, setup,
196
+ // install, onboard, restore/recover — whose body reads an identity table and refuses. The
197
+ // "only before an admin exists" / "only an admin may" gate. Bounded to bootstrap-shaped
198
+ // PATHS so an ordinary route's generic "user not found → throw" can never trip it, AND still
199
+ // requires a real refusal shape below (credGates) to downgrade.
200
+ const bootstrapish =
201
+ /(^|[:/\- ])(setup|install|onboard(ing)?|bootstrap|first[-_ ]?run|initiali[sz]e|admin[-_ ]?sign[-_ ]?up|register[-_ ]?admin|restore|recover)([/:\- ]|$)/i.test(
202
+ ep.label,
203
+ );
204
+ const identityRead = reached.some(
205
+ (n) =>
206
+ n?.kind === 'effect' &&
207
+ n.meta.effectType === 'db_read' &&
208
+ /user|account|admin|member|identit/i.test(n.meta.table ?? ''),
209
+ );
210
+ const family = tokenRead
211
+ ? 'a stored credential-token lookup'
212
+ : credVerify
213
+ ? 'a signature/token verification call'
214
+ : callbackish
215
+ ? 'a webhook/callback handshake'
216
+ : bootstrapish && identityRead
217
+ ? 'a first-run/admin-setup guard'
218
+ : null;
219
+ // refusal shapes per family: token/verify/first-run families must throw or 4xx; the callback
220
+ // family may also refuse by redirecting away (the browser-facing OAuth deny idiom).
221
+ const credentialGated =
222
+ family !== null && (credGates || (callbackish && credRedirects));
223
+ // Class 1 (public-by-design re-label): a route whose PATH is a curated public signature —
224
+ // login/register/logout, forgot/reset-password, verify-email, oauth/sso/saml, callback/webhook,
225
+ // health/metrics/.well-known — is *conventionally* meant to run without a session guard. Unlike
226
+ // the credential families above this carries NO evidence of a gate; it is triage, not proof. So
227
+ // it only ever RE-LABELS critical → info (`expectedPublic`), names the convention, and says
228
+ // "confirm intent" — never hidden, never marked safe, never touches PROVEN. The list is the
229
+ // HEAD of the distribution (deliberately precise, not `**/auth/**` blanket: change-password /
230
+ // 2fa / session management are NOT public, and re-labeling those would hide a real hole).
231
+ const expectedPublic =
232
+ !credentialGated &&
233
+ (/(^|[:/\- ])(log-?in|sign-?in|sign-?up|register|log-?out|sign-?out|forgot-?password|reset-?password|forgot|verify-?email|email-?verification|confirm-?email|magic-?link|oauth\d?|openid|sso|saml|health(z|check|-?check)?|livez|readyz|metrics|well-known)([/:\- ]|$)/i.test(
234
+ ep.label,
235
+ ) ||
236
+ /\.well-known/i.test(ep.label) ||
237
+ callbackish);
238
+ const softened = credentialGated || expectedPublic;
103
239
  findings.push({
104
240
  rule: 'UNGUARDED_MUTATION',
105
- severity: 'critical',
241
+ severity: softened ? 'info' : 'critical',
242
+ ...(credentialGated
243
+ ? { advisory: true, credentialFamily: family }
244
+ : expectedPublic
245
+ ? { advisory: true, expectedPublic: true }
246
+ : {}),
106
247
  entrypoint: ep.id,
107
- message: `${ep.label} mutates ${fmtStates(writes)} with no guard anywhere on the path${
108
- tainted.length ? 'and request data flows straight into the write' : ''
109
- }`,
248
+ message: credentialGated
249
+ ? `${ep.label} mutates ${fmtStates(writes)} with no modeled guard but the body checks ${family} and can refuse; verify that credential's scope covers the mutation`
250
+ : expectedPublic
251
+ ? `${ep.label} mutates ${fmtStates(writes)} with no modeled guard — but its path matches a public-by-design signature (auth / oauth / callback / health / metrics); confirm this endpoint is meant to be unauthenticated`
252
+ : `${ep.label} mutates ${fmtStates(writes)} with no guard anywhere on the path${
253
+ tainted.length ? ' — and request data flows straight into the write' : ''
254
+ }`,
110
255
  evidence: writes.map((w) => `${w.effect.id} (${locOf(w.effect)})`),
111
- ...(tainted.length ? { tainted: true } : {}),
256
+ ...(tainted.length && !softened ? { tainted: true } : {}),
112
257
  });
113
258
  }
114
259
 
@@ -244,7 +389,18 @@ export function checkGraph(graph) {
244
389
  const adminish =
245
390
  /(^|[:/])(admin|internal|system|cron|webhooks?|callback)([/:]|$)/i.test(ep.label) ||
246
391
  guards.some((gd) => /admin|internal|system|cron|super/i.test(gd?.label ?? ''));
247
- if (idScoped.length && !ownerScopedSeen && guards.length && !adminish) {
392
+ // G1: a call-site ownership assertion on the route (`getXOrThrow({ workspaceId:
393
+ // workspace.id })`) scopes the object to the caller even when the proof lives in an
394
+ // imported helper we don't expand — so it clears the BOLA advisory, same as a visible
395
+ // `ownerScoped` query. Advisory-only, so this can never mask a hard finding.
396
+ const ownerAsserted = ep.meta?.ownerAsserted === true;
397
+ if (
398
+ idScoped.length &&
399
+ !ownerScopedSeen &&
400
+ !ownerAsserted &&
401
+ guards.length &&
402
+ !adminish
403
+ ) {
248
404
  const tables = [
249
405
  ...new Set(idScoped.map((n) => n.meta.table).filter(Boolean)),
250
406
  ].sort();
@@ -831,6 +831,14 @@ export function scanFunction(fnNode, env = {}) {
831
831
  calls: [],
832
832
  guardSignals: { deniesWithStatus: false },
833
833
  validatesInput: false,
834
+ // O7/BOLA only (G1): the body asserts caller-ownership at a call site
835
+ // (`getXOrThrow({ workspaceId: workspace.id })`) — an advisory-only signal.
836
+ ownerAsserted: false,
837
+ // G2 (guard-taxonomy families B–F): credential-check signals, SEPARATE from
838
+ // guardSignals on purpose — they may only DOWNGRADE an UNGUARDED_MUTATION critical to
839
+ // an advisory (never prove a guard, never silence a finding), so widening them can
840
+ // never fabricate a gate (E-042) or a false PROVEN.
841
+ credentialSignals: { verifyCall: false, denies4xxOrThrows: false, redirects: false },
834
842
  async: Boolean(fnNode?.async),
835
843
  };
836
844
  if (!fnNode) return result;
@@ -926,6 +934,64 @@ function whereOwnerScoped(whereNode) {
926
934
  return scoped;
927
935
  }
928
936
 
937
+ // Values that reference the caller's VERIFIED identity — the principal a guard puts on the
938
+ // path: dub's `withWorkspace` → `workspace`, a session → `session.user`, `req.user`. Broader
939
+ // than OWNERSHIP_ROOT because it must catch the framework's identity object by its conventional
940
+ // name (the thing a scoped mutation is measured against).
941
+ const IDENTITY_ROOT =
942
+ /^(workspace|session|auth|user|me|currentuser|actor|viewer|principal|org|organization|team|project|tenant|account|membership)s?$/i;
943
+ // an identity token anywhere in an access path (`req.session.user`, `workspace.id`)
944
+ const IDENTITY_TOKEN =
945
+ /^(workspace|session|auth|user|me|currentuser|actor|viewer|principal|org|organization|team|project|tenant|account|member|membership)s?$/i;
946
+ // request INPUT the caller controls — never the verified identity, even if named `workspaceId`
947
+ // (`req.body.workspaceId` is an attacker value; matching it would silence a REAL BOLA).
948
+ const REQ_INPUT = /^(body|query|params|input|payload|args|data)$/i;
949
+ function valueIsIdentity(node) {
950
+ if (!node) return false;
951
+ if (node.type === 'Identifier') return IDENTITY_ROOT.test(node.name);
952
+ if (node.type !== 'MemberExpression') return false;
953
+ // collect every identifier segment of the access path
954
+ const parts = [];
955
+ let cur = node;
956
+ while (cur?.type === 'MemberExpression') {
957
+ if (cur.property?.type === 'Identifier') parts.push(cur.property.name);
958
+ cur = cur.object;
959
+ }
960
+ if (cur?.type === 'Identifier') parts.push(cur.name);
961
+ if (parts.some((p) => REQ_INPUT.test(p))) return false; // caller-controlled → not identity
962
+ return parts.some((p) => IDENTITY_TOKEN.test(p));
963
+ }
964
+
965
+ // Does this call assert caller-ownership at the site — an argument object binding an ownership
966
+ // KEY to a VERIFIED-IDENTITY value, `getCustomerOrThrow({ workspaceId: workspace.id, id })`?
967
+ // This is the caller stating, before it mutates, that the object it is about to touch is scoped
968
+ // to its own identity — visible WITHOUT resolving the (imported) helper. It feeds ONLY the O7
969
+ // BOLA advisory, never a hard rule, so it can never create a false PROVEN: at worst it silences
970
+ // an advisory the field test wants silenced (dub's ~60 false BOLA are exactly this pattern).
971
+ // the identity object passed to the helper BY NAME — `getDomainOrThrow({ workspace, domain })`
972
+ // hands the whole verified principal, not a `workspaceId:` key.
973
+ const IDENTITY_KEY =
974
+ /^(workspace|session|user|team|project|tenant|account|org|organization|member|membership|owner|actor|principal)$/i;
975
+ function callAssertsOwnership(node) {
976
+ for (const arg of node.arguments ?? []) {
977
+ if (arg?.type !== 'ObjectExpression') continue;
978
+ for (const p of arg.properties) {
979
+ if (p.type !== 'ObjectProperty' || p.key?.type !== 'Identifier') continue;
980
+ // (1) the verified identity handed in by name: `{ workspace, … }` / `{ session }`
981
+ if (IDENTITY_KEY.test(p.key.name)) return true;
982
+ // (2) an ownership key bound to the identity OR to a scope-named local (shorthand):
983
+ // `{ workspaceId: workspace.id }` / `{ workspaceId }`. Same generosity whereOwnerScoped
984
+ // already grants a `where` key — and O7 is advisory, so it can only silence an advisory.
985
+ if (
986
+ OWNERSHIP_KEY.test(p.key.name) &&
987
+ (valueIsIdentity(p.value) || p.value?.type === 'Identifier')
988
+ )
989
+ return true;
990
+ }
991
+ }
992
+ return false;
993
+ }
994
+
929
995
  // does this `where` target a specific object by a bare `id` key (the BOLA shape)?
930
996
  function whereHasIdKey(whereNode) {
931
997
  if (whereNode?.type !== 'ObjectExpression') return false;
@@ -1021,6 +1087,11 @@ function visit(node, out, ctx) {
1021
1087
  return;
1022
1088
  }
1023
1089
 
1090
+ // G2 credential signal (advisory-only): the body can refuse — any throw, or any 4xx
1091
+ // response. Deliberately BROADER than guardSignals.deniesWithStatus (401/403) because it
1092
+ // can only ever downgrade a critical to an advisory, never verify a guard.
1093
+ if (node.type === 'ThrowStatement') out.credentialSignals.denies4xxOrThrows = true;
1094
+
1024
1095
  // try/catch — try-effects are compensable by catch-effects (SBIR §2.2)
1025
1096
  if (node.type === 'TryStatement') {
1026
1097
  const tryLine = node.loc?.start.line ?? 0;
@@ -1123,9 +1194,42 @@ function inspectCall(node, out, ctx) {
1123
1194
  if (out.returnShapes.length < MAX_RETURN_SHAPES && jsonShape !== null)
1124
1195
  out.returnShapes.push({ line, shape: jsonShape });
1125
1196
  if (deniedStatusOf(node)) out.guardSignals.deniesWithStatus = true;
1197
+ if (statusIn4xx(node)) out.credentialSignals.denies4xxOrThrows = true;
1126
1198
  return;
1127
1199
  }
1128
1200
  if (deniedStatusOf(node)) out.guardSignals.deniesWithStatus = true;
1201
+ if (statusIn4xx(node)) out.credentialSignals.denies4xxOrThrows = true;
1202
+
1203
+ // G1 (O7/BOLA only): a call that asserts caller-ownership at the site scopes the path —
1204
+ // `getCustomerOrThrow({ workspaceId: workspace.id, id })`. Advisory-only, so it can never
1205
+ // create a false PROVEN; it silences the false BOLA the imported ownership helper would raise.
1206
+ if (callAssertsOwnership(node)) out.ownerAsserted = true;
1207
+
1208
+ // G2 (advisory-only): a call whose NAME says it verifies a credential — `verifyUnsubscribeToken`,
1209
+ // `jwt.verify`, an HMAC/signature check. Family B/D of the guard taxonomy. Never a guard by
1210
+ // itself (E-042: a name fabricates nothing) — it only feeds the UNGUARDED downgrade.
1211
+ const verifyName =
1212
+ callee.type === 'Identifier'
1213
+ ? callee.name
1214
+ : callee.type === 'MemberExpression' && callee.property?.type === 'Identifier'
1215
+ ? callee.property.name
1216
+ : '';
1217
+ if (/verify|signature|hmac/i.test(verifyName)) out.credentialSignals.verifyCall = true;
1218
+ // OAuth/browser callbacks refuse by REDIRECTING away (family F) — no throw, no 4xx.
1219
+ // Only ever consulted for callback-shaped routes, and only to downgrade to advisory.
1220
+ if (verifyName === 'redirect') out.credentialSignals.redirects = true;
1221
+ // A named REFUSAL helper is the dominant Next.js / App-Router idiom — the deny is a call like
1222
+ // `responses.notAuthenticatedResponse()` / `unauthorized()` / `throwForbidden()`, never a literal
1223
+ // `res.status(401)` or a bare `throw`, so statusIn4xx and the ThrowStatement check both miss it.
1224
+ // The refusal is real; only its spelling is a helper. Advisory-only (feeds the UNGUARDED
1225
+ // downgrade, never a guard): a route still needs a matching credential FAMILY to downgrade, so a
1226
+ // plain 404 helper on an ordinary route changes nothing.
1227
+ if (
1228
+ /(not_?authenticated|un_?authenticated|not_?authori[sz]ed|un_?authori[sz]ed|forbidden|access_?denied|bad_?request|too_?many_?requests|rate_?limit(ed|_?exceeded)?|missing_?fields|unprocessable|invalid_?(api_?key|token|credential|secret|request|permission))/i.test(
1229
+ verifyName,
1230
+ )
1231
+ )
1232
+ out.credentialSignals.denies4xxOrThrows = true;
1129
1233
 
1130
1234
  // ---- local calls: bare identifier calls → call-graph edges later
1131
1235
  if (callee.type === 'Identifier') {
@@ -1484,6 +1588,45 @@ function isDenyOptions(arg) {
1484
1588
  return false;
1485
1589
  }
1486
1590
 
1591
+ // G2 (advisory-only): ANY 4xx refusal — broader than deniedStatusOf's 401/403 on purpose.
1592
+ // A stored-token flow refuses with 400/404/410 as often as 401; since this signal can only
1593
+ // downgrade a critical to an advisory (never verify a guard), the width is safe.
1594
+ function statusIn4xx(node) {
1595
+ const is4xx = (v) => typeof v === 'number' && v >= 400 && v < 500;
1596
+ const inArgs = (args) =>
1597
+ (args ?? []).some(
1598
+ (a) =>
1599
+ a?.type === 'ObjectExpression' &&
1600
+ a.properties.some(
1601
+ (p) =>
1602
+ p.type === 'ObjectProperty' &&
1603
+ p.key?.name === 'status' &&
1604
+ p.value?.type === 'NumericLiteral' &&
1605
+ is4xx(p.value.value),
1606
+ ),
1607
+ );
1608
+ let cur = node;
1609
+ while (cur) {
1610
+ if (cur.type === 'CallExpression') {
1611
+ const m =
1612
+ cur.callee?.type === 'MemberExpression' ? cur.callee.property?.name : null;
1613
+ if (
1614
+ (m === 'status' || m === 'sendStatus') &&
1615
+ cur.arguments[0]?.type === 'NumericLiteral' &&
1616
+ is4xx(cur.arguments[0].value)
1617
+ )
1618
+ return true;
1619
+ if (m === 'json' && inArgs(cur.arguments)) return true;
1620
+ }
1621
+ cur =
1622
+ cur.callee?.type === 'MemberExpression' &&
1623
+ cur.callee.object?.type === 'CallExpression'
1624
+ ? cur.callee.object
1625
+ : null;
1626
+ }
1627
+ return false;
1628
+ }
1629
+
1487
1630
  function deniedStatusOf(node) {
1488
1631
  const isDeny = (v) => v === 401 || v === 403;
1489
1632
  // NextResponse.json(_, { status: 401 }) / res.json(_, { status: 403 }) — the deny is
@@ -73,6 +73,12 @@ function mergeNodes(graph, aId, bId) {
73
73
  if (b.meta.returnShapes) {
74
74
  a.meta.returnShapes = [...(a.meta.returnShapes ?? []), ...b.meta.returnShapes];
75
75
  }
76
+ // Carry G1/G2 advisory body signals (a credential refusal or an ownership assertion) from the
77
+ // absorbed node onto the survivor. A thin delegator whose only job is to call a helper that
78
+ // throws/refuses would otherwise lose that signal at the merge and read as a false critical.
79
+ // These flags only ever DOWNGRADE a critical to advisory — never prove.
80
+ for (const k of ['bodyDenies', 'bodyVerifies', 'bodyRedirects', 'ownerAsserted'])
81
+ if (b.meta[k]) a.meta[k] = true;
76
82
 
77
83
  const nextEdges = [];
78
84
  for (const e of graph.edges) {
@@ -39,6 +39,23 @@ export function mergeScan(into, add) {
39
39
  into.validatesInput = into.validatesInput || add.validatesInput;
40
40
  into.async = into.async || add.async;
41
41
  if (add.guardSignals?.deniesWithStatus) into.guardSignals.deniesWithStatus = true;
42
+ // G1/G2 (advisory-only): a delegated service method that asserts caller-ownership, or refuses on
43
+ // a credential check (throw/4xx/verify/redirect), carries that signal UP to the handler it is
44
+ // reached from. Without this a Nest/DI refusal that lives one class away (`this.service.x()`) is
45
+ // dropped at the merge and its route reads as a false critical — the first-run / admin-setup and
46
+ // API-key families. These signals can only DOWNGRADE a critical to advisory, never prove.
47
+ if (add.ownerAsserted) into.ownerAsserted = true;
48
+ if (add.credentialSignals) {
49
+ into.credentialSignals ??= {
50
+ verifyCall: false,
51
+ denies4xxOrThrows: false,
52
+ redirects: false,
53
+ };
54
+ if (add.credentialSignals.verifyCall) into.credentialSignals.verifyCall = true;
55
+ if (add.credentialSignals.denies4xxOrThrows)
56
+ into.credentialSignals.denies4xxOrThrows = true;
57
+ if (add.credentialSignals.redirects) into.credentialSignals.redirects = true;
58
+ }
42
59
  }
43
60
 
44
61
  export const relOf = (cwd, abs) => path.relative(cwd, abs).split(path.sep).join('/');
@@ -204,6 +204,22 @@ function translateRoute(
204
204
  for (const cn of chainNodes) {
205
205
  if (cn.scan) attachBody(graph, cn.id, cn.scan, helperByName, scanCache, expanded);
206
206
  }
207
+
208
+ // G1 (O7/BOLA only): if any step on this route asserts caller-ownership at a call site
209
+ // (`getXOrThrow({ workspaceId: workspace.id })`), the route scopes its objects to the
210
+ // caller — even when the assertion lives in an imported helper SPARDA doesn't expand.
211
+ // Advisory-only signal: it silences the false BOLA, never touches a hard rule.
212
+ if (chainNodes.some((cn) => cn.scan?.ownerAsserted))
213
+ graph.nodes.get(epId).meta.ownerAsserted = true;
214
+
215
+ // G2 (guard taxonomy): credential-check signals from the route's own bodies. Advisory-only —
216
+ // they can downgrade an UNGUARDED critical to an advisory, never verify a guard.
217
+ if (chainNodes.some((cn) => cn.scan?.credentialSignals?.verifyCall))
218
+ graph.nodes.get(epId).meta.credentialVerify = true;
219
+ if (chainNodes.some((cn) => cn.scan?.credentialSignals?.denies4xxOrThrows))
220
+ graph.nodes.get(epId).meta.credentialGates = true;
221
+ if (chainNodes.some((cn) => cn.scan?.credentialSignals?.redirects))
222
+ graph.nodes.get(epId).meta.credentialRedirects = true;
207
223
  }
208
224
 
209
225
  // A chain step (middleware or handler) becomes a guard or logic node.
@@ -258,6 +274,19 @@ function attachBody(graph, ownerId, scan, helperByName, scanCache, expanded) {
258
274
  expanded.add(ownerId);
259
275
  const owner = graph.nodes.get(ownerId);
260
276
 
277
+ // G2 propagation (advisory-only): a body that itself refuses on a credential check — throw/4xx,
278
+ // a verify call, or an OAuth redirect — carries that refusal AT ITS OWN node. This is why the
279
+ // first-run and API-key gates read as false criticals: their refusal lives in an imported/DI
280
+ // service method that has no effect of its own, so the signal never rode the entrypoint's direct
281
+ // middleware chain. Tagging the reached body lets the prover see the mechanism through the call
282
+ // graph. Reading this tag can only DOWNGRADE a critical to advisory — never prove, never silence,
283
+ // never fabricate a guard. Tagged once, on first expansion; the node is route-independent.
284
+ if (owner) {
285
+ if (scan.credentialSignals?.denies4xxOrThrows) owner.meta.bodyDenies = true;
286
+ if (scan.credentialSignals?.verifyCall) owner.meta.bodyVerifies = true;
287
+ if (scan.credentialSignals?.redirects) owner.meta.bodyRedirects = true;
288
+ }
289
+
261
290
  let order = 0;
262
291
  let ordinal = 0;
263
292
  const created = []; // effects of THIS body — compensation pairs live here