sparda-mcp 0.66.0 → 0.66.2

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
@@ -25,7 +25,7 @@
25
25
 
26
26
  ## 60-second proof
27
27
 
28
- From your Express, FastAPI, Next.js, NestJS or Medusa app — nothing to configure:
28
+ From your Express, FastAPI, Flask, Next.js, NestJS or Medusa app — nothing to configure:
29
29
 
30
30
  ```bash
31
31
  npx sparda-mcp apocalypse # prove the tree is safe to deploy — exit 1 on any real risk
@@ -53,7 +53,7 @@ Under the hood it compiles your backend into one language-agnostic graph — the
53
53
  | **`heal`** | _Self-heal, **proven**_ — the gate Copilot Autofix doesn't have: a fix ships **only if** replay matches, `verify` still passes, and `apocalypse` finds no new risk / no dropped guard. Whoever wrote the fix, the machine judges it. |
54
54
  | **`badge`** | _The shareable artifact_ — a self-contained SVG badge + README snippet (verdict · coverage · routes) |
55
55
  | **`dossier`** | _The public report_ — one self-contained HTML page: verdict, risks, and SPARDA's own blind spots |
56
- | **`ubg`** | Compile the codebase to its behavior graph (Express · FastAPI · Next.js · NestJS · Medusa natively; **any** stack via OpenAPI) |
56
+ | **`ubg`** | Compile the codebase to its behavior graph (Express · FastAPI · Flask · Next.js · NestJS · Medusa natively; **any** stack via OpenAPI) |
57
57
  | **`timeless`** | _Time-travel_ — record a production request, replay it byte-identically, export the bug as a test |
58
58
  | **`mirror`** | _Execute the graph_ — serve the compiled behavior over HTTP with no framework and no source |
59
59
  | **`init` / `dev`** | _Runtime, optional_ — expose the graph to AI clients as a live MCP server (+ Twin, Immune, Evolution) |
@@ -213,7 +213,7 @@ The gate is honest in both directions: an unfixed bug, or a "fix" that silently
213
213
 
214
214
  ## Any Backend On Earth: OpenAPI Lowering
215
215
 
216
- SPARDA parses Express, FastAPI and Next.js natively — and **every other stack through the format the industry already agreed on**. Go, Java, Rails, Laravel, .NET: if it has an OpenAPI spec, it compiles.
216
+ SPARDA parses Express, FastAPI, Flask and Next.js natively — and **every other stack through the format the industry already agreed on**. Go, Java, Rails, Laravel, .NET: if it has an OpenAPI spec, it compiles.
217
217
 
218
218
  ```bash
219
219
  npx sparda-mcp ubg --openapi openapi.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparda-mcp",
3
- "version": "0.66.0",
3
+ "version": "0.66.2",
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",
@@ -13,7 +13,7 @@
13
13
  "url": "https://github.com/zyx77550/sparda/issues"
14
14
  },
15
15
  "bin": {
16
- "sparda": "./src/index.js"
16
+ "sparda": "src/index.js"
17
17
  },
18
18
  "scripts": {
19
19
  "test": "vitest run",
@@ -44,6 +44,7 @@
44
44
  },
45
45
  "keywords": [
46
46
  "mcp",
47
+ "mcp-server",
47
48
  "model-context-protocol",
48
49
  "claude",
49
50
  "claude-code",
@@ -60,19 +61,19 @@
60
61
  "author": "Residual Labs (residual-labs.fr)",
61
62
  "license": "BUSL-1.1",
62
63
  "dependencies": {
63
- "@babel/parser": "7.26.5",
64
- "@babel/traverse": "7.26.5",
65
- "@clack/prompts": "0.9.1",
66
64
  "@modelcontextprotocol/sdk": "1.29.0"
67
65
  },
68
66
  "devDependencies": {
69
- "@eslint/js": "^9.39.4",
70
- "@vitest/coverage-v8": "^3.2.6",
71
- "eslint": "^9.39.4",
72
- "eslint-config-prettier": "^9.1.2",
73
- "express": "^4.21.0",
74
- "globals": "^15.15.0",
67
+ "@babel/parser": "^8.0.4",
68
+ "@babel/traverse": "^8.0.4",
69
+ "@clack/prompts": "^1.7.0",
70
+ "@eslint/js": "^10.0.1",
71
+ "@vitest/coverage-v8": "^4.1.10",
72
+ "eslint": "^10.7.0",
73
+ "eslint-config-prettier": "^10.1.8",
74
+ "express": "^5.2.1",
75
+ "globals": "^17.7.0",
75
76
  "prettier": "^3.8.4",
76
- "vitest": "^3.2.6"
77
+ "vitest": "^4.1.10"
77
78
  }
78
79
  }
@@ -82,7 +82,7 @@ export async function runEvolve(opts) {
82
82
  },
83
83
  );
84
84
  }
85
- let exemplars = {};
85
+ let exemplars;
86
86
  try {
87
87
  exemplars = JSON.parse(fs.readFileSync(twinPath, 'utf8')).exemplars ?? {};
88
88
  } catch {
@@ -3,11 +3,29 @@
3
3
  // edit gate must never scream about pre-existing state (that noise is fatal to
4
4
  // adoption — see docs/TWO-FALSE-POSITIVE-CLASSES-2026-07-19.md), so everything
5
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)
6
+ //
7
+ // The load-bearing subtlety (E-GATE-1): a PostToolUse hook fires after EVERY
8
+ // edit, including the mid-sequence ones that leave a file temporarily
9
+ // unparseable. When a file fails to parse its routes vanish from the graph, so a
10
+ // naive diff reads that as ENTRYPOINT_REMOVED / GUARD_REMOVED and cries a false
11
+ // regression on the agent's own in-progress work. That false alarm, fired
12
+ // constantly, is what gets the gate uninstalled in five minutes. So: a regression
13
+ // whose baseline entrypoint lives in a now-unparseable file is NOT a regression —
14
+ // it is "unproven, come back when it parses". The gate ABSTAINS, never claims
15
+ // clean (it proved nothing) and never claims safe. An unparseable tree has no
16
+ // defined behavior to regress against — abstention is the sound call.
17
+ //
18
+ // Note on PostToolUse semantics: the edit has already landed, so exit 2 does not
19
+ // *prevent* it — it feeds the regression back to the agent as stderr, which the
20
+ // agent reads and corrects in the same session (self-heal in the loop). That is
21
+ // the honest claim: immediate feedback, not a hard block. (A hard pre-commit
22
+ // block is the pre-commit hook / CI Action's job — same command, `apocalypse`.)
23
+ // sparda gate report the edit's behavior delta vs baseline
7
24
  // sparda gate --arm freeze the current graph as the accepted baseline
8
25
  // 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.
26
+ // report on stderr + exit 2 (feedback to the agent) on a
27
+ // real regression; abstains (exit 0) while a file is
28
+ // mid-edit; arms itself on first run — zero config.
11
29
  import fs from 'node:fs';
12
30
  import path from 'node:path';
13
31
  import { compileUBG } from '../ubg/compile.js';
@@ -18,22 +36,49 @@ import { atomicWriteFileSync as atomicWrite } from '../server/persistence.js';
18
36
  // severities that block the edit; medium/info regressions are reported, never fatal
19
37
  const BLOCKING = new Set(['critical', 'high']);
20
38
  const findingKey = (f) => `${f.rule}|${f.entrypoint}`;
39
+ // diff rules that mean "this route/protection disappeared" — the ones a vanished
40
+ // (unparseable) file can forge. Introduced-risk rules can never be forged by a
41
+ // missing file, so they are never suppressed.
42
+ const REMOVAL_RULES = new Set(['ENTRYPOINT_REMOVED', 'GUARD_REMOVED']);
43
+
44
+ // files the compiler could not read/parse this run — reason is prefixed by the
45
+ // module error (`parse error: …` / `unreadable: …`) in every extractor's skipped list.
46
+ function unparsedFiles(report) {
47
+ const out = new Set();
48
+ for (const s of report?.skipped ?? []) {
49
+ if (s.file && /(^|\b)(parse error|unreadable|Unexpected|SyntaxError)/i.test(s.reason))
50
+ out.add(s.file);
51
+ }
52
+ return out;
53
+ }
21
54
 
22
55
  // The gate's whole judgment, pure over two canonical graphs (testable without a repo):
23
56
  // diff regressions (guard/route/invariant lost, blast radius grown) + static findings
24
57
  // 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) {
58
+ // out (ADR-030), pointed at the baseline instead of a git ref. `unparsed` (the set of
59
+ // files that failed to parse in the candidate) splits the diff removals into real
60
+ // regressions vs in-progress edits whose baseline route lives in a now-broken file.
61
+ export function gateDelta(baseline, candidate, unparsed = new Set()) {
62
+ const baseLoc = new Map((baseline.nodes ?? []).map((n) => [n.id, n.loc?.file]));
27
63
  const diff = diffGraphs(baseline, candidate).findings;
28
64
  const before = new Set(checkGraph(baseline).findings.map(findingKey));
29
65
  const introduced = checkGraph(candidate).findings.filter(
30
66
  (f) => !before.has(findingKey(f)),
31
67
  );
32
68
  const seen = new Set(diff.map(findingKey));
33
- const findings = [...diff, ...introduced.filter((f) => !seen.has(findingKey(f)))];
69
+ const all = [...diff, ...introduced.filter((f) => !seen.has(findingKey(f)))];
70
+
71
+ const findings = [];
72
+ const abstained = [];
73
+ for (const f of all) {
74
+ if (REMOVAL_RULES.has(f.rule) && unparsed.has(baseLoc.get(f.entrypoint)))
75
+ abstained.push(f);
76
+ else findings.push(f);
77
+ }
34
78
  return {
35
79
  findings,
36
80
  blocking: findings.filter((f) => BLOCKING.has(f.severity)),
81
+ abstained,
37
82
  };
38
83
  }
39
84
 
@@ -48,10 +93,25 @@ export async function runGate(opts) {
48
93
  // in hook mode stdout belongs to nobody and stderr is what the agent reads
49
94
  const say = hook ? (s) => console.error(s) : (s) => console.log(s);
50
95
 
51
- const { graph } = compileUBG(opts.cwd, { write: false });
52
- const candidate = canonicalizeGraph(graph);
53
96
  const baselinePath = path.join(opts.cwd, '.sparda', 'ubg.baseline.json');
54
97
 
98
+ // A tree that does not compile at all (broken entry / detection throw) has no
99
+ // behavior to judge. Abstain instead of crashing the hook — the agent is mid-edit.
100
+ let graph, report;
101
+ try {
102
+ ({ graph, report } = compileUBG(opts.cwd, { write: false }));
103
+ } catch (err) {
104
+ if (opts.json)
105
+ console.log(JSON.stringify({ ok: true, abstained: err.message }, null, 2));
106
+ else if (!hook)
107
+ say(
108
+ `⏳ GATE ABSTAINED — the tree does not compile yet (${err.message}). Fix it, then re-check.`,
109
+ );
110
+ return { armed: false, findings: [], abstained: true };
111
+ }
112
+ const candidate = canonicalizeGraph(graph);
113
+ const unparsed = unparsedFiles(report);
114
+
55
115
  const arm = () => {
56
116
  fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
57
117
  atomicWrite(baselinePath, JSON.stringify(candidate, null, 2) + '\n');
@@ -75,13 +135,18 @@ export async function runGate(opts) {
75
135
  }
76
136
 
77
137
  const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
78
- const { findings, blocking } = gateDelta(baseline, candidate);
138
+ const { findings, blocking, abstained } = gateDelta(baseline, candidate, unparsed);
79
139
  const ms = Number(process.hrtime.bigint() - t0) / 1e6;
80
140
 
81
141
  if (opts.json) {
82
142
  console.log(
83
143
  JSON.stringify(
84
- { ok: blocking.length === 0, ms: Math.round(ms), findings },
144
+ {
145
+ ok: blocking.length === 0,
146
+ ms: Math.round(ms),
147
+ findings,
148
+ abstained: abstained.map((f) => f.entrypoint),
149
+ },
85
150
  null,
86
151
  2,
87
152
  ),
@@ -90,8 +155,17 @@ export async function runGate(opts) {
90
155
  return { armed: false, findings };
91
156
  }
92
157
 
158
+ // Abstention notice: a route vanished only because its file doesn't parse yet.
159
+ // Never blocking — this is the normal mid-edit state, not a regression.
160
+ if (abstained.length) {
161
+ const files = [...new Set([...unparsed])].join(', ');
162
+ say(
163
+ `⏳ GATE HELD — ${abstained.length} route(s) can't be proven while ${files} doesn't parse (mid-edit?). Not a regression; I'll re-check once it parses.`,
164
+ );
165
+ }
166
+
93
167
  if (findings.length === 0) {
94
- if (!hook)
168
+ if (!hook && !abstained.length)
95
169
  say(
96
170
  `✓ GATE CLEAN — this edit lost no guard, dropped no route, grew no blast radius (proven vs baseline in ${Math.round(ms)} ms).`,
97
171
  );
@@ -100,7 +100,7 @@ async function gate(opts, id, flight, healDir) {
100
100
  // 3. apocalypse: no new critical/high, nothing protected got removed
101
101
  const fixedGraph = canonicalizeGraph(compileUBG(opts.cwd, { write: false }).graph);
102
102
  const staticFindings = checkGraph(fixedGraph).findings;
103
- let regressions = [];
103
+ let regressions;
104
104
  const baselinePath = path.join(healDir, 'baseline.json');
105
105
  if (fs.existsSync(baselinePath)) {
106
106
  const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
@@ -277,7 +277,7 @@ export async function runSeed(opts, args) {
277
277
  if (opts.germinate) {
278
278
  const { buildGrammar } = await import('./grammar.js');
279
279
  const { twinFilePath } = await import('./twin.js');
280
- let exemplars = null;
280
+ let exemplars;
281
281
  try {
282
282
  exemplars =
283
283
  JSON.parse(fs.readFileSync(twinFilePath(opts.cwd), 'utf8')).exemplars ?? null;
package/src/detect.js CHANGED
@@ -92,7 +92,7 @@ export function detectStack(cwd) {
92
92
  const known = ['fastify', 'koa'].find((d) => deps[d]);
93
93
  if (known)
94
94
  throw err(
95
- `${known} detected — not supported yet. Express, NestJS & FastAPI in v0.`,
95
+ `${known} detected — not supported yet. Express, NestJS, FastAPI & Flask in v0.`,
96
96
  '+1 the framework vote: github.com/zyx77550/sparda/issues/1',
97
97
  );
98
98
  }
@@ -108,6 +108,18 @@ export function detectStack(cwd) {
108
108
  return { framework: 'fastapi', entryFile, port, pythonCmd };
109
109
  }
110
110
  }
111
+ // Flask — checked AFTER FastAPI (a FastAPI project may pull flask transitively; FastAPI
112
+ // wins). The same Python extractor handles it; only the entry-file marker differs.
113
+ for (const f of ['requirements.txt', 'pyproject.toml']) {
114
+ const p = path.join(cwd, f);
115
+ if (
116
+ fs.existsSync(p) &&
117
+ /(^|[^a-z])flask([^a-z]|$)/i.test(fs.readFileSync(p, 'utf8'))
118
+ ) {
119
+ const entryFile = findFlaskEntry(cwd);
120
+ return { framework: 'flask', entryFile, port: 5000, pythonCmd: detectPython() };
121
+ }
122
+ }
111
123
 
112
124
  // Last resort before giving up — monorepo app dirs whose framework config lives
113
125
  // ELSEWHERE than the pointed dir. Purely structural, so it never mis-fires on a
@@ -130,7 +142,7 @@ export function detectStack(cwd) {
130
142
 
131
143
  const suggestions = suggestAppDirs(cwd);
132
144
  throw err(
133
- 'No supported framework found (Express, Next.js, NestJS, Medusa, FastAPI).',
145
+ 'No supported framework found (Express, Next.js, NestJS, Medusa, FastAPI, Flask).',
134
146
  suggestions.length
135
147
  ? `This looks like a monorepo — the analyzable app is in a sub-directory. Try:\n` +
136
148
  suggestions
@@ -401,7 +413,28 @@ function findFastAPIEntry(cwd) {
401
413
  );
402
414
  }
403
415
 
404
- function searchPyFiles(dir, root, countRef = { val: 0 }) {
416
+ // Flask's entry is the file that calls `Flask(__name__)` — conventionally app.py /
417
+ // wsgi.py / run.py / main.py, else the first .py declaring it. Same shape as the
418
+ // FastAPI finder; the Python extractor handles both frameworks from that entry.
419
+ function findFlaskEntry(cwd) {
420
+ const candidates = ['app.py', 'wsgi.py', 'run.py', 'main.py', 'src/app.py'];
421
+ for (const rel of candidates) {
422
+ const abs = path.resolve(cwd, rel);
423
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
424
+ if (fs.readFileSync(abs, 'utf8').includes('Flask(')) {
425
+ return path.relative(cwd, abs).split(path.sep).join('/');
426
+ }
427
+ }
428
+ }
429
+ const entry = searchPyFiles(cwd, cwd, { val: 0 }, 'Flask(');
430
+ if (entry) return path.relative(cwd, entry).split(path.sep).join('/');
431
+ throw err(
432
+ 'Could not locate your Flask entry file (the one calling Flask()).',
433
+ 'Specify it manually, or make sure Flask() is declared in one of your python files.',
434
+ );
435
+ }
436
+
437
+ function searchPyFiles(dir, root, countRef = { val: 0 }, marker = 'FastAPI(') {
405
438
  const EXCLUDE = new Set([
406
439
  'node_modules',
407
440
  '.git',
@@ -434,7 +467,7 @@ function searchPyFiles(dir, root, countRef = { val: 0 }) {
434
467
  countRef.val++;
435
468
  if (countRef.val > 200) return null;
436
469
  const src = fs.readFileSync(abs, 'utf8');
437
- if (src.includes('FastAPI(')) {
470
+ if (src.includes(marker)) {
438
471
  return abs;
439
472
  }
440
473
  }
@@ -27,7 +27,7 @@ export function evaluateHealing(flight, replay, expectation = null) {
27
27
  reasons.push('response is byte-identical to the recorded bug — nothing changed');
28
28
  }
29
29
 
30
- let expectationMet = null;
30
+ let expectationMet;
31
31
  if (expectation) {
32
32
  expectationMet = true;
33
33
  if (expectation.status !== undefined && replay.actual.status !== expectation.status) {
@@ -80,7 +80,7 @@ export function generateExpress({ cwd, entryFile, moduleType, port, routes }) {
80
80
  const isESM = moduleType === 'esm';
81
81
  const isTS = ext === '.ts';
82
82
 
83
- let importLine = '';
83
+ let importLine;
84
84
  let reqType = '';
85
85
  let resType = '';
86
86
  let nextType = '';
@@ -42,7 +42,7 @@ export function ensureSpardaKey(cwd, prevManifest = null) {
42
42
  // state (opt-in flags + observed circuits), and the `sparding` security
43
43
  // memory survive as long as the tool's method+path are unchanged.
44
44
  export function carryOverManifest(cwd, tools) {
45
- let prev = null;
45
+ let prev;
46
46
  try {
47
47
  prev = JSON.parse(fs.readFileSync(path.join(cwd, 'sparda.json'), 'utf8'));
48
48
  } catch {
@@ -30,6 +30,7 @@ export function parseFastAPIProject(cwd, entryFile, pythonCmd = 'python') {
30
30
  } catch (err) {
31
31
  throw new Error(
32
32
  `Failed to parse Python extractor output: ${err.message}. Raw output: ${res.stdout}`,
33
+ { cause: err },
33
34
  );
34
35
  }
35
36
  }
@@ -58,11 +58,14 @@ export function buildProofObjects(graph) {
58
58
  .map((id) => g.nodes.get(id))
59
59
  .filter(Boolean);
60
60
  const guards = reached.filter((n) => n.kind === 'guard');
61
- const writes = [];
61
+ const allWrites = [];
62
62
  for (const n of reached)
63
63
  if (n.kind === 'effect')
64
64
  for (const e of g.mutOut.get(n.id) ?? [])
65
- writes.push({ effect: n, stateId: e.to });
65
+ allWrites.push({ effect: n, stateId: e.to });
66
+ // guard-dominance (C2): a write that runs BEFORE the route's guard is NOT discharged by it —
67
+ // never claim it in a proof (scan-level truth, authoritative over the graph's hoisted gate edge).
68
+ const writes = allWrites.filter((w) => !w.effect.meta?.bypassesGuard);
66
69
  // a proof object is emitted ONLY for a genuinely DISCHARGED obligation: a mutating route
67
70
  // (O1 applies) that actually reaches a guard. A guardless mutation is a FINDING, not a proof.
68
71
  if (!writes.length || !guards.length) continue;
@@ -153,7 +156,28 @@ export function checkGraph(graph) {
153
156
 
154
157
  // O1 — every mutation path must pass a guard
155
158
  obligations++;
156
- if (writes.length) vec.auth = guards.length ? 1 : -1;
159
+ // Guard-DOMINANCE (kills the C2 false PROVEN): a guard on the route does NOT cover a mutation
160
+ // that runs BEFORE it — an early-return branch that mutates then checks auth after, or a write in
161
+ // a branch a sibling guards. `bypassesGuard` is the SCAN-LEVEL truth (set only when a guard
162
+ // demonstrably follows the write in the SAME body), and it is authoritative: the graph hoists an
163
+ // in-body guard into a chain node with a gate edge, so a gate edge cannot be trusted to mean
164
+ // "runs before the whole body". Under-approximated by construction, so it can only ever turn a
165
+ // PROVEN into NOT_PROVEN — never fabricate a guard, never a false PROVEN.
166
+ const bypassWrites =
167
+ guards.length > 0 ? writes.filter((w) => w.effect.meta.bypassesGuard) : [];
168
+ if (writes.length) vec.auth = guards.length === 0 || bypassWrites.length ? -1 : 1;
169
+ if (bypassWrites.length) {
170
+ // A bypassed guard is worse than no guard (it reads as protected but isn't) — a hard critical,
171
+ // never softened by the credential/public-by-design taxonomy below.
172
+ findings.push({
173
+ rule: 'UNGUARDED_MUTATION',
174
+ severity: 'critical',
175
+ guardBypass: true,
176
+ entrypoint: ep.id,
177
+ message: `${ep.label} mutates ${fmtStates(bypassWrites)} BEFORE its guard runs — an early-return / pre-check branch reaches the write without passing the route's auth check, so the guard does not cover it`,
178
+ evidence: bypassWrites.map((w) => `${w.effect.id} (${locOf(w.effect)})`),
179
+ });
180
+ }
157
181
  if (writes.length && guards.length === 0) {
158
182
  // taint enrichment (ADR-P1): does request data provably flow into one of these
159
183
  // writes? A tag on an ALREADY-emitted finding — never a finding of its own — so it
@@ -34,6 +34,9 @@ export function compileUBG(
34
34
  medusa: () => extractMedusa(cwd, stack.entryFile),
35
35
  nextjs: () => extractNext(cwd, stack.entryFile),
36
36
  fastapi: () => extractFastAPI(cwd, stack.entryFile, stack.pythonCmd),
37
+ // Flask reuses the FastAPI (Python) extractor — same stdlib-ast walk, Flask route
38
+ // discovery folded in (Flask()/Blueprint/@app.route/register_blueprint/@login_required).
39
+ flask: () => extractFastAPI(cwd, stack.entryFile, stack.pythonCmd),
37
40
  openapi: () => extractOpenAPI(cwd, stack.entryFile),
38
41
  };
39
42
  if (!extractors[stack.framework]) {
@@ -839,6 +839,8 @@ export function scanFunction(fnNode, env = {}) {
839
839
  // an advisory (never prove a guard, never silence a finding), so widening them can
840
840
  // never fabricate a gate (E-042) or a false PROVEN.
841
841
  credentialSignals: { verifyCall: false, denies4xxOrThrows: false, redirects: false },
842
+ // guard-dominance (C2): set true once any in-body guard barrier is seen while walking the body.
843
+ hasInBodyGuard: false,
842
844
  async: Boolean(fnNode?.async),
843
845
  };
844
846
  if (!fnNode) return result;
@@ -853,6 +855,14 @@ export function scanFunction(fnNode, env = {}) {
853
855
  // `this.knex(this.collection)` inside a service resolve to `:collection`.
854
856
  thisSymbols: env.thisSymbols ?? null,
855
857
  });
858
+ // guard-dominance: a mutation on an unguarded path is a real bypass ONLY when THIS body also holds
859
+ // a guard (so a guard is genuinely being circumvented). A body with no guard of its own is guarded
860
+ // elsewhere (positional middleware / a caller) — leave it to the route model, untouched. Strip the
861
+ // temporary marker either way so it never leaks into the graph.
862
+ for (const e of result.effects) {
863
+ if (result.hasInBodyGuard && e._unguardedPath) e.bypassesGuard = true;
864
+ delete e._unguardedPath;
865
+ }
856
866
  return result;
857
867
  }
858
868
 
@@ -1080,6 +1090,91 @@ function collectReqDerived(fnNode) {
1080
1090
  // SBIR §2.2 — transaction wrappers whose function arguments open a scope
1081
1091
  const TX_WRAPPERS = new Set(['transaction', '$transaction', 'withTransaction']);
1082
1092
 
1093
+ // Mutating effect kinds — the ones a guard is supposed to dominate (O1). A read is never a bypass.
1094
+ const MUTATING_EFFECTS = new Set(['db_write', 'http_call', 'fs_write']);
1095
+
1096
+ // Guard-DOMINANCE (kills the C2 false PROVEN): a guard on a route's chain does not make EVERY
1097
+ // write on the route safe — the guard must run BEFORE the write. A handler that mutates in an
1098
+ // early-return branch and only checks auth afterwards (`if (preview) { charge.create(); return }
1099
+ // … await requireAuth(req)`) bypasses its own guard. We compute, per body, which mutations
1100
+ // execute on a path that has not yet passed a guard, AND where a guard follows on the same spine
1101
+ // (so there genuinely IS a guard being bypassed — a body whose guard lives cross-procedurally is
1102
+ // left to the route-level model, unchanged). This can only ever SUBTRACT guard credit — it never
1103
+ // invents a guard — so a heuristic barrier is sound: at worst it misses a bypass, never fabricates.
1104
+ //
1105
+ // A spine statement sets the barrier when everything sequentially after it has passed the check:
1106
+ // (a) an early-deny gate — `if (test) throw | return <4xx>` (fall-through ⇒ the check passed), or
1107
+ // (b) a guard CALL on the spine — `await requireAuth(req)` / `const s = await getSession()`.
1108
+ function spineCall(stmt) {
1109
+ let e = null;
1110
+ if (stmt.type === 'ExpressionStatement') e = stmt.expression;
1111
+ else if (stmt.type === 'VariableDeclaration' && stmt.declarations[0])
1112
+ e = stmt.declarations[0].init;
1113
+ if (e?.type === 'AwaitExpression') e = e.argument;
1114
+ return e?.type === 'CallExpression' ? e : null;
1115
+ }
1116
+
1117
+ // An AUTHORIZATION gate, tightly — NOT the broad GUARD_NAME (which matches business calls like
1118
+ // `hasAdmin`/`getRole`/`verifyEmail` and would flood false bypasses). Only names that unambiguously
1119
+ // gate access: `require/ensure/assert{Auth,Session,User,Owner,Permission,Role,Admin,Login,Access}`,
1120
+ // `authenticate`/`authorize`, `canActivate`, `get(Server)Session`, `check/verify{Auth,Session,…}`.
1121
+ const AUTH_GATE_CALL =
1122
+ /(^|[^a-z])(require|ensure|assert)[_]?(auth|session|user|owner|permission|role|admin|login|access)|authenticate|authoriz(e|ation)|can[_]?activate|get[_]?server[_]?session|get[_]?session|check[_]?(auth|permission|access|session)|verify[_]?(auth|session|permission)|protect[_]?route/i;
1123
+
1124
+ function isGuardCall(call) {
1125
+ const callee = call.callee;
1126
+ const name =
1127
+ callee?.type === 'Identifier'
1128
+ ? callee.name
1129
+ : callee?.type === 'MemberExpression' && callee.property?.type === 'Identifier'
1130
+ ? callee.property.name
1131
+ : '';
1132
+ return AUTH_GATE_CALL.test(name);
1133
+ }
1134
+
1135
+ // Does this subtree AUTH-deny — refuse with 401/403 or an auth exception? (Deliberately NOT any
1136
+ // throw or any 4xx: a validation `throw new BadRequestException()` inside a service is not an auth
1137
+ // gate, and treating it as one is what produced false bypasses.)
1138
+ function subtreeAuthDenies(node) {
1139
+ let found = false;
1140
+ const walk = (n) => {
1141
+ if (found || !n || typeof n !== 'object') return;
1142
+ if (Array.isArray(n)) {
1143
+ for (const x of n) walk(x);
1144
+ return;
1145
+ }
1146
+ if (n.type === 'CallExpression' && deniedStatusOf(n)) return void (found = true);
1147
+ if (n.type === 'NewExpression') {
1148
+ const nm = n.callee?.name;
1149
+ if (
1150
+ /^(Unauthorized|Forbidden)(Exception|Error)$/.test(nm ?? '') ||
1151
+ (Array.isArray(n.arguments) && n.arguments.some(isDenyOptions))
1152
+ )
1153
+ return void (found = true);
1154
+ }
1155
+ for (const k of Object.keys(n)) {
1156
+ if (
1157
+ k === 'loc' ||
1158
+ k === 'range' ||
1159
+ k === 'leadingComments' ||
1160
+ k === 'trailingComments'
1161
+ )
1162
+ continue;
1163
+ const v = n[k];
1164
+ if (v && typeof v === 'object') walk(v);
1165
+ }
1166
+ };
1167
+ walk(node);
1168
+ return found;
1169
+ }
1170
+
1171
+ function statementSetsBarrier(stmt) {
1172
+ if (!stmt) return false;
1173
+ if (stmt.type === 'IfStatement') return subtreeAuthDenies(stmt.consequent);
1174
+ const call = spineCall(stmt);
1175
+ return call ? isGuardCall(call) : false;
1176
+ }
1177
+
1083
1178
  function visit(node, out, ctx) {
1084
1179
  if (!node || typeof node !== 'object') return;
1085
1180
  if (Array.isArray(node)) {
@@ -1087,6 +1182,24 @@ function visit(node, out, ctx) {
1087
1182
  return;
1088
1183
  }
1089
1184
 
1185
+ // Ordered spine walk of a block, tracking whether a guard has executed ON THE CURRENT PATH. A
1186
+ // spine guard covers everything sequentially after it AND everything nested inside those later
1187
+ // statements; a guard INSIDE a branch covers only that branch, never a sibling. Each mutation
1188
+ // visited while the path is still unguarded is tagged `_unguardedPath` — a write reachable without
1189
+ // passing a guard. (scanFunction promotes those to `bypassesGuard` only when THIS body actually
1190
+ // has an in-body guard, so a body guarded cross-procedurally is left to the route model, unchanged.)
1191
+ if (node.type === 'BlockStatement') {
1192
+ let guarded = ctx.guarded === true;
1193
+ for (const stmt of node.body) {
1194
+ visit(stmt, out, { ...ctx, guarded });
1195
+ if (statementSetsBarrier(stmt)) {
1196
+ guarded = true;
1197
+ out.hasInBodyGuard = true;
1198
+ }
1199
+ }
1200
+ return;
1201
+ }
1202
+
1090
1203
  // G2 credential signal (advisory-only): the body can refuse — any throw, or any 4xx
1091
1204
  // response. Deliberately BROADER than guardSignals.deniesWithStatus (401/403) because it
1092
1205
  // can only ever downgrade a critical to an advisory, never verify a guard.
@@ -1491,6 +1604,10 @@ function pushEffect(out, ctx, effect) {
1491
1604
  }
1492
1605
  if (ctx?.tryId != null) effect.tryId = ctx.tryId;
1493
1606
  if (ctx?.catchOf != null) effect.catchOf = ctx.catchOf;
1607
+ // guard-dominance (C2): a mutation reached while no guard has run on this path. Temporary — promoted
1608
+ // to `bypassesGuard` in scanFunction only if this body also HAS a guard (else it is left unchanged).
1609
+ if (ctx?.guarded !== true && MUTATING_EFFECTS.has(effect.effectType))
1610
+ effect._unguardedPath = true;
1494
1611
  out.effects.push(effect);
1495
1612
  }
1496
1613
 
@@ -38,6 +38,7 @@ export function extractFastAPI(cwd, entryFile, pythonCmd = 'python') {
38
38
  } catch (err) {
39
39
  throw new Error(
40
40
  `FastAPI UBG extractor returned invalid JSON: ${err.message}. Raw: ${res.stdout.slice(0, 200)}`,
41
+ { cause: err },
41
42
  );
42
43
  }
43
44
  if (payload.error) throw new Error(payload.error);
@@ -305,12 +305,42 @@ def scan_function(fn):
305
305
  "validatesInput": False,
306
306
  "async": isinstance(fn, ast.AsyncFunctionDef),
307
307
  }
308
- ctx = {"tx": None, "iso": "default", "tryId": None, "catchOf": None}
308
+ # var -> model table, from `u = User(...)` so `db.session.add(u)` (the dominant
309
+ # Flask-SQLAlchemy write) resolves to a real table instead of an untable-able blind write.
310
+ var_models = {}
311
+ for n in ast.walk(fn):
312
+ if (isinstance(n, ast.Assign) and len(n.targets) == 1
313
+ and isinstance(n.targets[0], ast.Name)
314
+ and isinstance(n.value, ast.Call)
315
+ and isinstance(n.value.func, ast.Name)
316
+ and n.value.func.id[:1].isupper()):
317
+ var_models[n.targets[0].id] = n.value.func.id.lower()
318
+ ctx = {"tx": None, "iso": "default", "tryId": None, "catchOf": None,
319
+ "var_models": var_models}
309
320
  for stmt in fn.body:
310
321
  _visit(stmt, out, ctx)
311
322
  return out
312
323
 
313
324
 
325
+ def query_model_of(node):
326
+ """Flask-SQLAlchemy Query API: `Model.query.filter_by(...).delete()` /
327
+ `User.query.get(id)` → 'model' — the capitalized Name that `.query` hangs off,
328
+ however deep the chain (filter/filter_by/order_by/… are all calls)."""
329
+ cur = node
330
+ while cur is not None:
331
+ if (isinstance(cur, ast.Attribute) and cur.attr == "query"
332
+ and isinstance(cur.value, ast.Name)
333
+ and cur.value.id[:1].isupper()):
334
+ return cur.value.id.lower()
335
+ if isinstance(cur, ast.Attribute):
336
+ cur = cur.value
337
+ elif isinstance(cur, ast.Call):
338
+ cur = cur.func.value if isinstance(cur.func, ast.Attribute) else None
339
+ else:
340
+ cur = None
341
+ return None
342
+
343
+
314
344
  def _visit(node, out, ctx):
315
345
  if isinstance(node, ast.Try):
316
346
  try_line = node.lineno
@@ -434,6 +464,16 @@ def inspect_call(node, out, ctx):
434
464
  # The receiver is matched on the full dotted chain, so `self.db.add(…)`
435
465
  # inside a service class reads like `db.add(…)` at module level.
436
466
  recv = dotted_name(func.value)
467
+ # Flask-SQLAlchemy Query API: Model.query.filter_by(...).delete() / .update() — a write on
468
+ # the model the chain roots at. (A bare Model.query.get()/all() is a read, handled as select.)
469
+ if method in ("delete", "update"):
470
+ qm = query_model_of(func.value)
471
+ if qm:
472
+ push_effect(out, ctx, {
473
+ "effectType": "db_write", "op": method, "table": qm,
474
+ "line": line, "driver": root or "unknown",
475
+ })
476
+ return
437
477
  if method == "query" and node.args and isinstance(node.args[0], ast.Name):
438
478
  push_effect(out, ctx, {
439
479
  "effectType": "db_read", "op": "select",
@@ -443,8 +483,18 @@ def inspect_call(node, out, ctx):
443
483
  return
444
484
  if (method in ("add", "add_all", "merge") and recv
445
485
  and re.search(r"session|db", recv, re.I)):
486
+ # resolve the model: db.session.add(u) where `u = User(...)`, or the inline
487
+ # db.session.add(User(...)). An unresolved add stays table-less (still a write).
488
+ table = None
489
+ if node.args:
490
+ a = node.args[0]
491
+ if isinstance(a, ast.Name):
492
+ table = ctx.get("var_models", {}).get(a.id)
493
+ elif (isinstance(a, ast.Call) and isinstance(a.func, ast.Name)
494
+ and a.func.id[:1].isupper()):
495
+ table = a.func.id.lower()
446
496
  push_effect(out, ctx, {
447
- "effectType": "db_write", "op": "insert", "table": None,
497
+ "effectType": "db_write", "op": "insert", "table": table,
448
498
  "line": line, "driver": root,
449
499
  })
450
500
  return
@@ -873,6 +923,10 @@ class UbgExtractor:
873
923
  for t in self.dep_targets_of(kw.value):
874
924
  self.global_middlewares.append(
875
925
  {"target": t, "file": abs_file})
926
+ elif cname == "Flask":
927
+ # Flask app: `app = Flask(__name__)`. Routes hang off @app.route /
928
+ # @app.get/@app.post — same decorator-on-function shape as FastAPI.
929
+ app_vars.add(var)
876
930
  elif cname == "APIRouter":
877
931
  router_vars.add(var)
878
932
  router_prefixes[var] = ""
@@ -882,6 +936,16 @@ class UbgExtractor:
882
936
  router_prefixes[var] = lit(kw.value)
883
937
  if kw.arg == "dependencies":
884
938
  router_deps[var] = self.dep_targets_of(kw.value)
939
+ elif cname == "Blueprint":
940
+ # Flask Blueprint: `bp = Blueprint('x', __name__, url_prefix='/y')`.
941
+ # A blueprint is FastAPI's APIRouter by another name — the same
942
+ # prefix + register-later model, so it reuses the router machinery.
943
+ router_vars.add(var)
944
+ router_prefixes[var] = ""
945
+ router_deps[var] = []
946
+ for kw in call.keywords:
947
+ if kw.arg == "url_prefix" and isinstance(lit(kw.value), str):
948
+ router_prefixes[var] = lit(kw.value)
885
949
  elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
886
950
  functions[node.name] = node
887
951
 
@@ -926,18 +990,20 @@ class UbgExtractor:
926
990
  if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
927
991
  self.collect_mount(node.value, abs_file, prefix, import_map,
928
992
  router_vars, router_prefixes, depth)
993
+ self.collect_cbv(node.value, abs_file, prefix, modctx, rel_file,
994
+ router_vars, router_prefixes)
929
995
  continue
930
996
  if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
931
997
  continue
932
998
  found = self.route_decorator_of(node, app_vars, router_vars)
933
999
  if not found:
934
1000
  continue
935
- dec, obj_name, method = found
1001
+ dec, obj_name, methods = found
936
1002
  raw_path = lit(dec.args[0]) if dec.args else None
937
1003
  if not isinstance(raw_path, str):
938
1004
  self.skipped.append({
939
1005
  "reason": "dynamic path on %s (non-literal first arg)"
940
- % method.upper(),
1006
+ % "/".join(m.upper() for m in methods),
941
1007
  "file": rel_file, "line": node.lineno,
942
1008
  })
943
1009
  continue
@@ -954,6 +1020,28 @@ class UbgExtractor:
954
1020
  continue
955
1021
 
956
1022
  chain = []
1023
+ # Flask auth decorators — @login_required / @jwt_required() / @requires_auth —
1024
+ # gate the handler exactly like a resolved FastAPI dependency that denies. A
1025
+ # decorator whose NAME is an auth gate is a VERIFIED guard (the framework enforces
1026
+ # the 401), so a mutation behind it is not a false UNGUARDED_MUTATION.
1027
+ for d in node.decorator_list:
1028
+ dname = (d.func.attr if isinstance(d, ast.Call)
1029
+ and isinstance(d.func, ast.Attribute)
1030
+ else d.func.id if isinstance(d, ast.Call)
1031
+ and isinstance(d.func, ast.Name)
1032
+ else d.attr if isinstance(d, ast.Attribute)
1033
+ else d.id if isinstance(d, ast.Name) else "")
1034
+ if re.search(r'login_required|jwt_required|requires?_auth'
1035
+ r'|auth_required|require_login|token_required'
1036
+ r'|permission_required', dname or "", re.I):
1037
+ chain.append({
1038
+ "name": dname, "role": "middleware", "sourceFile": rel_file,
1039
+ "sourceLine": node.lineno,
1040
+ "scan": {"effects": [], "returnShapes": [], "calls": [],
1041
+ "async": False, "validatesInput": False,
1042
+ "guardSignals": {"deniesWithStatus": True}},
1043
+ })
1044
+ break
957
1045
  dep_names = list(router_deps.get(obj_name, []))
958
1046
  for kw in dec.keywords:
959
1047
  if kw.arg == "dependencies":
@@ -971,7 +1059,9 @@ class UbgExtractor:
971
1059
  else:
972
1060
  self.skipped.append({
973
1061
  "reason": "dependency '%s' not resolved on %s %s"
974
- % (dep_name, method.upper(), full_path),
1062
+ % (dep_name,
1063
+ "/".join(m.upper() for m in methods),
1064
+ full_path),
975
1065
  "file": rel_file, "line": node.lineno,
976
1066
  })
977
1067
  # DI bindings: `svc: UserService = Depends(get_user_service)` /
@@ -1003,31 +1093,172 @@ class UbgExtractor:
1003
1093
  "sourceLine": node.lineno, "scan": handler_scan,
1004
1094
  })
1005
1095
 
1006
- self.routes.append({
1007
- "method": method,
1008
- "path": full_path,
1009
- "sourceFile": rel_file,
1010
- "sourceLine": node.lineno,
1011
- "params": params,
1012
- "chain": chain,
1013
- "description": (ast.get_docstring(node) or "")[:400],
1014
- })
1096
+ # one entrypoint per HTTP method (a Flask `@app.route(methods=[…])` can
1097
+ # declare several; FastAPI/@app.get yields exactly one)
1098
+ for method in methods:
1099
+ self.routes.append({
1100
+ "method": method,
1101
+ "path": full_path,
1102
+ "sourceFile": rel_file,
1103
+ "sourceLine": node.lineno,
1104
+ "params": params,
1105
+ "chain": chain,
1106
+ "description": (ast.get_docstring(node) or "")[:400],
1107
+ })
1015
1108
 
1016
1109
  def route_decorator_of(self, fn, app_vars, router_vars):
1110
+ # Returns (decorator, owner_var, [methods]). Matches BOTH:
1111
+ # @app.get / @router.post / @app.delete (FastAPI + Flask 2.0 shorthand)
1112
+ # @app.route("/x", methods=["GET","POST"]) (Flask — methods in a kwarg, default GET)
1017
1113
  for dec in fn.decorator_list:
1018
- if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute) \
1019
- and dec.func.attr in HTTP_VERBS \
1020
- and isinstance(dec.func.value, ast.Name) \
1114
+ if not (isinstance(dec, ast.Call)
1115
+ and isinstance(dec.func, ast.Attribute)
1116
+ and isinstance(dec.func.value, ast.Name)
1021
1117
  and (dec.func.value.id in app_vars
1022
- or dec.func.value.id in router_vars):
1023
- return dec, dec.func.value.id, dec.func.attr
1118
+ or dec.func.value.id in router_vars)):
1119
+ continue
1120
+ attr = dec.func.attr
1121
+ if attr in HTTP_VERBS:
1122
+ return dec, dec.func.value.id, [attr]
1123
+ if attr == "route":
1124
+ methods = ["get"]
1125
+ for kw in dec.keywords:
1126
+ if kw.arg == "methods" and isinstance(kw.value, (ast.List, ast.Tuple)):
1127
+ got = [lit(e).lower() for e in kw.value.elts
1128
+ if isinstance(lit(e), str)
1129
+ and lit(e).lower() in HTTP_VERBS]
1130
+ if got:
1131
+ methods = got
1132
+ return dec, dec.func.value.id, methods
1024
1133
  return None
1025
1134
 
1135
+ def collect_cbv(self, call, abs_file, prefix, modctx, rel_file,
1136
+ router_vars, router_prefixes):
1137
+ """Flask class-based views: a MethodView registered via
1138
+ `app.add_url_rule('/x', view_func=Cls.as_view('n'), methods=[…])`, or a
1139
+ Flask-RESTful `api.add_resource(Cls, '/x')`. Each HTTP-verb method on the
1140
+ class (or up its bases) becomes a route, scanned like any handler — so the
1141
+ DELETE/POST inside a MethodView is no longer an invisible mutation surface."""
1142
+ if not (isinstance(call.func, ast.Attribute) and call.args):
1143
+ return
1144
+ attr = call.func.attr
1145
+ if attr not in ("add_url_rule", "add_resource"):
1146
+ return
1147
+ owner = call.func.value
1148
+ owner_name = owner.id if isinstance(owner, ast.Name) else None
1149
+ local_prefix = (router_prefixes.get(owner_name, "")
1150
+ if owner_name in router_vars else "")
1151
+ cbv_name, raw_path, restrict = None, None, None
1152
+ if attr == "add_url_rule":
1153
+ raw_path = lit(call.args[0]) if call.args else None
1154
+ for kw in call.keywords:
1155
+ if (kw.arg == "view_func" and isinstance(kw.value, ast.Call)
1156
+ and isinstance(kw.value.func, ast.Attribute)
1157
+ and kw.value.func.attr == "as_view"
1158
+ and isinstance(kw.value.func.value, ast.Name)):
1159
+ cbv_name = kw.value.func.value.id
1160
+ elif kw.arg == "methods" and isinstance(kw.value, (ast.List, ast.Tuple)):
1161
+ restrict = {lit(e).lower() for e in kw.value.elts
1162
+ if isinstance(lit(e), str)}
1163
+ else: # add_resource(Cls, "/path")
1164
+ if isinstance(call.args[0], ast.Name):
1165
+ cbv_name = call.args[0].id
1166
+ raw_path = lit(call.args[1]) if len(call.args) > 1 else None
1167
+ if not cbv_name or not isinstance(raw_path, str):
1168
+ return
1169
+ hit = self.resolve_class(modctx, cbv_name)
1170
+ if not hit:
1171
+ return
1172
+ cls, cmod = hit
1173
+ full_path = (prefix + local_prefix + raw_path).replace("//", "/")
1174
+ if not full_path.startswith("/"):
1175
+ full_path = "/" + full_path
1176
+ # class-level guards: MethodView `decorators = [...]` / Flask-RESTful
1177
+ # `method_decorators = [...]` apply to every verb method.
1178
+ class_guard = self.cbv_class_guard(cls, rel_file)
1179
+ for verb in HTTP_VERBS:
1180
+ if restrict is not None and verb not in restrict:
1181
+ continue
1182
+ m = self.method_in_class_chain(cls, cmod, verb)
1183
+ if not m:
1184
+ continue
1185
+ fn, fmod = m
1186
+ mrel = self.rel(fmod["file"])
1187
+ chain = []
1188
+ for g in (class_guard + self.auth_guard_steps(fn, mrel)):
1189
+ chain.append(g)
1190
+ chain.append({
1191
+ "name": "%s.%s" % (cbv_name, verb), "role": "handler",
1192
+ "sourceFile": mrel, "sourceLine": fn.lineno,
1193
+ "scan": self.deep_scan(fn, fmod),
1194
+ })
1195
+ self.routes.append({
1196
+ "method": verb, "path": full_path,
1197
+ "sourceFile": mrel, "sourceLine": fn.lineno,
1198
+ "params": self.params_of(fn, full_path),
1199
+ "chain": chain,
1200
+ "description": (ast.get_docstring(fn) or "")[:400],
1201
+ })
1202
+
1203
+ def auth_guard_steps(self, fn, rel_file):
1204
+ """@login_required / @jwt_required / @requires_auth on a handler → a verified guard step."""
1205
+ steps = []
1206
+ for d in getattr(fn, "decorator_list", []):
1207
+ dname = (d.func.attr if isinstance(d, ast.Call)
1208
+ and isinstance(d.func, ast.Attribute)
1209
+ else d.func.id if isinstance(d, ast.Call)
1210
+ and isinstance(d.func, ast.Name)
1211
+ else d.attr if isinstance(d, ast.Attribute)
1212
+ else d.id if isinstance(d, ast.Name) else "")
1213
+ if re.search(r'login_required|jwt_required|requires?_auth|auth_required'
1214
+ r'|require_login|token_required|permission_required',
1215
+ dname or "", re.I):
1216
+ steps.append(self._guard_step(dname, rel_file, fn.lineno))
1217
+ break
1218
+ return steps
1219
+
1220
+ def cbv_class_guard(self, cls, rel_file):
1221
+ """MethodView `decorators = [login_required]` / Flask-RESTful
1222
+ `method_decorators = [jwt_required]` — a class-level guard over every method."""
1223
+ for stmt in cls.body:
1224
+ if (isinstance(stmt, ast.Assign) and len(stmt.targets) == 1
1225
+ and isinstance(stmt.targets[0], ast.Name)
1226
+ and stmt.targets[0].id in ("decorators", "method_decorators")
1227
+ and isinstance(stmt.value, (ast.List, ast.Tuple))):
1228
+ for e in stmt.value.elts:
1229
+ nm = (e.func.id if isinstance(e, ast.Call)
1230
+ and isinstance(e.func, ast.Name)
1231
+ else e.func.attr if isinstance(e, ast.Call)
1232
+ and isinstance(e.func, ast.Attribute)
1233
+ else e.id if isinstance(e, ast.Name)
1234
+ else e.attr if isinstance(e, ast.Attribute) else "")
1235
+ if re.search(r'login_required|jwt_required|requires?_auth'
1236
+ r'|auth_required|require_login|token_required'
1237
+ r'|permission_required', nm or "", re.I):
1238
+ return [self._guard_step(nm, rel_file, cls.lineno)]
1239
+ return []
1240
+
1241
+ @staticmethod
1242
+ def _guard_step(name, rel_file, line):
1243
+ return {
1244
+ "name": name, "role": "middleware", "sourceFile": rel_file,
1245
+ "sourceLine": line,
1246
+ "scan": {"effects": [], "returnShapes": [], "calls": [],
1247
+ "async": False, "validatesInput": False,
1248
+ "guardSignals": {"deniesWithStatus": True}},
1249
+ }
1250
+
1026
1251
  def collect_mount(self, call, abs_file, prefix, import_map, router_vars,
1027
1252
  router_prefixes, depth=0):
1253
+ # FastAPI `app.include_router(r, prefix=…)` and Flask
1254
+ # `app.register_blueprint(bp, url_prefix=…)` are the same act — mount a
1255
+ # sub-router under a prefix. `register_blueprint` names its prefix `url_prefix`.
1028
1256
  if not (isinstance(call.func, ast.Attribute)
1029
- and call.func.attr == "include_router" and call.args):
1257
+ and call.func.attr in ("include_router", "register_blueprint")
1258
+ and call.args):
1030
1259
  return
1260
+ prefix_kw = "url_prefix" if call.func.attr == "register_blueprint" \
1261
+ else "prefix"
1031
1262
  arg0 = call.args[0]
1032
1263
  router_name = None
1033
1264
  if isinstance(arg0, ast.Name):
@@ -1038,7 +1269,7 @@ class UbgExtractor:
1038
1269
  return
1039
1270
  mount_prefix = ""
1040
1271
  for kw in call.keywords:
1041
- if kw.arg == "prefix" and isinstance(lit(kw.value), str):
1272
+ if kw.arg == prefix_kw and isinstance(lit(kw.value), str):
1042
1273
  mount_prefix = lit(kw.value)
1043
1274
  resolved = import_map.get(router_name)
1044
1275
  cum = (prefix + mount_prefix).replace("//", "/")
package/src/ubg/genome.js CHANGED
@@ -134,7 +134,7 @@ export function verifyAntibody(ab) {
134
134
  const id = `${ANTIBODY_VERSION}_${sha256(body).slice(0, 32)}`;
135
135
  if (id !== ab.id) return { ok: false, reason: 'content-address' };
136
136
  // signature: Ed25519 over the same claim bytes the id commits to.
137
- let sigOk = false;
137
+ let sigOk;
138
138
  try {
139
139
  sigOk = crypto.verify(
140
140
  null,
package/src/ubg/nextjs.js CHANGED
@@ -9,6 +9,77 @@ import { parseModule, resolveExportedFunction, scanFunction } from './extract.js
9
9
  import { walkCalls } from './resolve.js';
10
10
 
11
11
  const VERBS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
12
+
13
+ // Read `export const config = { matcher: "…" | ["…", …] }` from a middleware module.
14
+ // Returns { patterns: string[]|null, unresolved: boolean }. patterns=null means no
15
+ // matcher (Next runs the middleware on every path). unresolved=true means a matcher
16
+ // exists but isn't a static string/array we can evaluate — the SOUND response is to
17
+ // NOT credit the guard (never a false PROVEN), handled in middlewareCovers().
18
+ export function readMatcher(mod) {
19
+ if (!mod?.ast) return { patterns: null, unresolved: false };
20
+ for (const node of mod.ast.program.body) {
21
+ const decl = node.type === 'ExportNamedDeclaration' ? node.declaration : node;
22
+ if (decl?.type !== 'VariableDeclaration') continue;
23
+ for (const d of decl.declarations) {
24
+ if (d.id?.name !== 'config' || d.init?.type !== 'ObjectExpression') continue;
25
+ const m = d.init.properties.find(
26
+ (p) => (p.key?.name ?? p.key?.value) === 'matcher',
27
+ );
28
+ if (!m) return { patterns: null, unresolved: false }; // config without matcher = all paths
29
+ const lit = (n) => (n?.type === 'StringLiteral' ? n.value : null);
30
+ if (lit(m.value) !== null) return { patterns: [lit(m.value)], unresolved: false };
31
+ if (m.value?.type === 'ArrayExpression') {
32
+ const out = m.value.elements.map(lit);
33
+ return out.every((s) => s !== null)
34
+ ? { patterns: out, unresolved: false }
35
+ : { patterns: null, unresolved: true };
36
+ }
37
+ return { patterns: null, unresolved: true }; // computed matcher — can't decide
38
+ }
39
+ }
40
+ return { patterns: null, unresolved: false };
41
+ }
42
+
43
+ // Does a middleware with these matcher patterns run on `routePath`? Returns
44
+ // true | false | null(=unknown). Handles the two dominant vibe-coded forms:
45
+ // positive path globs (`/dashboard/:path*`) and the negative-lookahead exclude
46
+ // (`/((?!api/|_next/).*)`). Anything else → null, and the caller treats unknown
47
+ // as "do not attribute the guard" so an unresolved matcher never fabricates a proof.
48
+ export function matcherCovers(patterns, routePath) {
49
+ let anyUnknown = false;
50
+ for (const p of patterns) {
51
+ const r = onePattern(p, routePath);
52
+ if (r === true) return true;
53
+ if (r === null) anyUnknown = true;
54
+ }
55
+ return anyUnknown ? null : false;
56
+ }
57
+
58
+ function onePattern(pattern, routePath) {
59
+ if (typeof pattern !== 'string' || pattern[0] !== '/') return null;
60
+ // negative lookahead: "/((?!api/|_next/|favicon.ico).*)" → covers everything but the excludes
61
+ const neg = pattern.match(/^\/\(\(\?!(.+?)\)\.\*\)\/?$/);
62
+ if (neg) {
63
+ const excludes = neg[1].split('|').map((s) => '/' + s.replace(/\/$/, ''));
64
+ return !excludes.some((ex) => routePath === ex || routePath.startsWith(ex + '/'));
65
+ }
66
+ // any other regex-ish pattern → not statically decidable here
67
+ if (/[()?!+^$|\\]/.test(pattern)) return null;
68
+ // positive path glob: `:seg` and `*` are wildcards, everything else literal.
69
+ // `/:path*` (slash + star modifier) matches zero-or-more trailing segments, so
70
+ // `/dashboard/:path*` covers `/dashboard` itself as well as `/dashboard/x`.
71
+ const rx = new RegExp(
72
+ '^' +
73
+ pattern
74
+ .replace(/[.]/g, '\\$&')
75
+ .replace(/\/:[A-Za-z0-9_]+\*/g, '(?:/.*)?')
76
+ .replace(/:[A-Za-z0-9_]+\*/g, '.*')
77
+ .replace(/:[A-Za-z0-9_]+/g, '[^/]+')
78
+ .replace(/\*/g, '.*') +
79
+ '$',
80
+ );
81
+ return rx.test(routePath);
82
+ }
12
83
  const ROUTE_FILES = new Set([
13
84
  'route.js',
14
85
  'route.ts',
@@ -34,12 +105,19 @@ export function extractNext(cwd, appDir) {
34
105
  if (mod.error) continue;
35
106
  const fn = mod.functions.get('middleware') ?? mod.functions.get('default');
36
107
  if (fn) {
108
+ // `export const config = { matcher: [...] }` scopes which paths the middleware
109
+ // runs on. Ignoring it is a FALSE-PROVEN generator: a middleware that denies
110
+ // for /dashboard would otherwise be credited as a guard on /api too. Carry the
111
+ // matcher so the translator only attributes the guard to paths it truly covers.
112
+ const { patterns, unresolved } = readMatcher(mod);
37
113
  globalMiddlewares.push({
38
114
  name: 'middleware',
39
115
  role: 'middleware',
40
116
  sourceFile: rel(abs),
41
117
  sourceLine: fn.line,
42
118
  fn: fn.node,
119
+ matcherPatterns: patterns, // null = no matcher = runs on every path (Next default)
120
+ matcherUnresolved: unresolved, // matcher present but not statically decidable
43
121
  });
44
122
  scannedFiles.push(rel(abs));
45
123
  }
@@ -91,10 +169,74 @@ export function extractNext(cwd, appDir) {
91
169
  walk(abs, [...segments, m ? `:${m[1]}` : name]);
92
170
  } else if (item.isFile() && ROUTE_FILES.has(item.name)) {
93
171
  parseRouteFile(abs, '/' + segments.join('/') || '/');
172
+ } else if (item.isFile() && /\.(t|j)sx?$/.test(item.name)) {
173
+ // C3: a Next server action (`'use server'`) is a remotely-invocable entrypoint just like a
174
+ // route — a client form can call it with any args. An unguarded mutating action is a real
175
+ // hole, but SPARDA saw only `route.ts` files, so it was INVISIBLE and coverage read a false
176
+ // 100%. Extract exported server actions so they get the same O1 guard analysis as routes.
177
+ parseServerActions(abs);
94
178
  }
95
179
  }
96
180
  }
97
181
 
182
+ // A server action = an exported async function in a `'use server'` MODULE, or any async function
183
+ // with a function-level `'use server'` directive. Registered as a POST entrypoint (actions mutate
184
+ // via a POST-like RPC); its body scan gets the normal in-body guard + guard-dominance treatment.
185
+ function parseServerActions(absFile) {
186
+ let src;
187
+ try {
188
+ src = fs.readFileSync(absFile, 'utf8');
189
+ } catch {
190
+ return;
191
+ }
192
+ if (!src.includes('use server')) return; // cheap pre-filter — never parse an ordinary component
193
+ const mod = parseModule(absFile);
194
+ if (mod.error || !mod.ast) return;
195
+ const relFile = rel(absFile);
196
+ const relNoExt = relFile.replace(/\.(t|j)sx?$/, '');
197
+ const moduleLevel = (mod.ast.program.directives ?? []).some(
198
+ (d) => d.value?.value === 'use server',
199
+ );
200
+ let found = false;
201
+ for (const { name, fn, line } of exportedAsyncFns(mod.ast)) {
202
+ if (!moduleLevel && !fnDeclaresUseServer(fn)) continue;
203
+ found = true;
204
+ // in-body auth verifier → a guard step, exactly as a plain route handler gets (nextjs.js's
205
+ // bodyGuardScan is tight: a verifier-shaped NAME that PROVABLY denies). No wrapper on actions.
206
+ const guardSteps = [];
207
+ const bg = bodyGuardScan(fn, mod);
208
+ if (bg)
209
+ guardSteps.push({
210
+ name: bg,
211
+ role: 'middleware',
212
+ sourceFile: relFile,
213
+ sourceLine: line,
214
+ fn: null,
215
+ scan: {
216
+ effects: [],
217
+ returnShapes: [],
218
+ calls: [],
219
+ async: true,
220
+ validatesInput: false,
221
+ guardSignals: { deniesWithStatus: true },
222
+ },
223
+ });
224
+ routes.push({
225
+ method: 'post',
226
+ path: `(action) ${relNoExt}#${name}`,
227
+ sourceFile: relFile,
228
+ sourceLine: line,
229
+ params: [],
230
+ chain: [
231
+ ...guardSteps,
232
+ { name, role: 'handler', sourceFile: relFile, sourceLine: line, fn },
233
+ ],
234
+ description: 'server action',
235
+ });
236
+ }
237
+ if (found && !scannedFiles.includes(relFile)) scannedFiles.push(relFile);
238
+ }
239
+
98
240
  function parseRouteFile(absFile, urlPath) {
99
241
  const mod = parseModule(absFile);
100
242
  const relFile = rel(absFile);
@@ -239,6 +381,53 @@ function verbHandlers(mod) {
239
381
  return out;
240
382
  }
241
383
 
384
+ // Exported async functions of a module — `export async function f(){}`,
385
+ // `export const f = async () => {}`, `export default async function(){}`. Server actions are
386
+ // always async; a non-async export in a `'use server'` file is a re-exported constant, not an action.
387
+ function exportedAsyncFns(ast) {
388
+ const out = [];
389
+ const consider = (name, fn, line) => {
390
+ if (fn && fn.async) out.push({ name, fn, line: line ?? fn.loc?.start.line ?? 0 });
391
+ };
392
+ for (const node of ast.program.body) {
393
+ if (node.type === 'ExportNamedDeclaration' && node.declaration) {
394
+ const d = node.declaration;
395
+ if (d.type === 'FunctionDeclaration' && d.id)
396
+ consider(d.id.name, d, d.loc?.start.line);
397
+ else if (d.type === 'VariableDeclaration')
398
+ for (const decl of d.declarations)
399
+ if (
400
+ decl.id?.type === 'Identifier' &&
401
+ (decl.init?.type === 'ArrowFunctionExpression' ||
402
+ decl.init?.type === 'FunctionExpression')
403
+ )
404
+ consider(decl.id.name, decl.init, decl.loc?.start.line);
405
+ } else if (node.type === 'ExportDefaultDeclaration') {
406
+ const d = node.declaration;
407
+ if (
408
+ d?.type === 'FunctionDeclaration' ||
409
+ d?.type === 'ArrowFunctionExpression' ||
410
+ d?.type === 'FunctionExpression'
411
+ )
412
+ consider(d.id?.name ?? 'default', d, d.loc?.start.line);
413
+ }
414
+ }
415
+ return out;
416
+ }
417
+
418
+ // A function-level `'use server'` directive — `async function f(){ 'use server'; … }`.
419
+ function fnDeclaresUseServer(fn) {
420
+ const b = fn?.body;
421
+ if (b?.type !== 'BlockStatement') return false;
422
+ if ((b.directives ?? []).some((d) => d.value?.value === 'use server')) return true;
423
+ const first = b.body?.[0];
424
+ return (
425
+ first?.type === 'ExpressionStatement' &&
426
+ first.expression?.type === 'StringLiteral' &&
427
+ first.expression.value === 'use server'
428
+ );
429
+ }
430
+
242
431
  // resolve an `export const VERB = <init>` right-hand side to a scannable function node
243
432
  // (or null — route still registered, body blind) PLUS the names of any HOC wrappers the
244
433
  // handler is nested inside. Handles inline fns, local aliases, and wrapper calls whose
@@ -33,6 +33,13 @@ export const MAX_RESOLVE_DEPTH = 6;
33
33
  // merge one scan's findings into another — the single copy of the contract
34
34
  // every follower shares.
35
35
  export function mergeScan(into, add) {
36
+ // Guard-dominance (C2) is a property of the ROUTE HANDLER's own body: a mutation that runs before
37
+ // the handler's auth check. A `bypassesGuard` computed inside a delegated/transitively-called body
38
+ // is that body's INTERNAL ordering (a service that mutates then runs a permission check) — not the
39
+ // route's auth gate, exactly as "effects merge; guards do not" (below). Strip it at the merge so a
40
+ // service's internal shape never flags the route. The handler's own effects live in `into` from
41
+ // the start and are never merged, so they keep their flag.
42
+ for (const e of add.effects) if (e.bypassesGuard) delete e.bypassesGuard;
36
43
  into.effects.push(...add.effects);
37
44
  into.returnShapes = [...(into.returnShapes ?? []), ...(add.returnShapes ?? [])];
38
45
  into.calls = [...(into.calls ?? []), ...(add.calls ?? [])];
@@ -24,6 +24,18 @@ import {
24
24
  stateId,
25
25
  } from './schema.js';
26
26
  import { isGuardLike, isNoOpGuard, scanFunction } from './extract.js';
27
+ import { matcherCovers } from './nextjs.js';
28
+
29
+ // A global middleware only guards a route whose path its Next `config.matcher`
30
+ // actually covers. No matcher → every path (Next default). A matcher present but
31
+ // unresolved, or one that resolves to "not covered", → do NOT attribute (an
32
+ // unproven guard is never fabricated — SOUNDNESS.md, no false PROVEN).
33
+ function middlewareAppliesTo(mw, routePath) {
34
+ if (mw.role !== 'middleware' || mw.matcherPatterns == null) {
35
+ return !mw.matcherUnresolved; // no matcher = all paths; unresolved matcher = abstain
36
+ }
37
+ return matcherCovers(mw.matcherPatterns, routePath) === true;
38
+ }
27
39
 
28
40
  // Is this chain step / helper a REAL guard? Named or deny-bodied like a guard, and
29
41
  // NOT a visible no-op pass-through (a disabled `(req,res,next)=>next()` guards nothing).
@@ -149,8 +161,12 @@ function translateRoute(
149
161
  ),
150
162
  );
151
163
 
152
- // global middlewares run before route-level ones — same chain, lower order
153
- const fullChain = [...globalMiddlewares, ...route.chain];
164
+ // global middlewares run before route-level ones — same chain, lower order
165
+ // but only those whose matcher actually covers this route's path (E-NEXT-MW)
166
+ const applicable = globalMiddlewares.filter((mw) =>
167
+ middlewareAppliesTo(mw, route.path),
168
+ );
169
+ const fullChain = [...applicable, ...route.chain];
154
170
  let prevId = epId;
155
171
  let order = 0;
156
172
  const chainNodes = [];
@@ -325,6 +341,9 @@ function attachBody(graph, ownerId, scan, helperByName, scanCache, expanded) {
325
341
  // Advisory provenance — it enriches an UNGUARDED_MUTATION, never a finding of
326
342
  // its own (a per-function under-approximation can't see service-layer validation).
327
343
  ...(eff.tainted ? { tainted: true } : {}),
344
+ // guard-dominance (kills the C2 false PROVEN): this mutation runs BEFORE a guard that
345
+ // follows it on the same body spine — i.e. it executes without having passed that check.
346
+ ...(eff.bypassesGuard ? { bypassesGuard: true } : {}),
328
347
  // object-scope provenance (ADR-058 B): the query targets a bare `id`, and whether
329
348
  // it is scoped to the caller. A route with an idScoped access and NO ownerScoped
330
349
  // access anywhere on its resolved path is a BOLA candidate (advisory).