sparda-mcp 0.63.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.63.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) {
@@ -45,8 +71,12 @@ export async function runApocalypse(opts) {
45
71
  const findings = [...staticFindings, ...diffFindings];
46
72
  // the honesty companion: where does the proof stop? (see `sparda blindspots`)
47
73
  const blind = surveyBlindspots(canonical, report);
48
- // coverage feeds the verdict: a clean app that resolved almost nothing is SURFACE, not PROVEN
49
- const verdict = verdictOf(findings, canonical, { coverage: blind.coverage.ratio });
74
+ // coverage feeds the verdict: a clean app that resolved almost nothing is SURFACE, not PROVEN;
75
+ // and any high-risk blind spot pulls a bare PROVEN down to PARTIAL (E-047, the giant-test rung)
76
+ const verdict = verdictOf(findings, canonical, {
77
+ coverage: blind.coverage.ratio,
78
+ blindHigh: blind.byRisk.critical + blind.byRisk.high,
79
+ });
50
80
 
51
81
  if (opts.sarif) {
52
82
  const sarifPath = path.join(opts.cwd, '.sparda', 'apocalypse.sarif');
@@ -57,6 +87,21 @@ export async function runApocalypse(opts) {
57
87
  );
58
88
  }
59
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
+
60
105
  if (opts.json) {
61
106
  console.log(
62
107
  JSON.stringify({ verdict, obligations, findings, blindspots: blind }, null, 2),
@@ -16,7 +16,10 @@ export async function runBadge(opts) {
16
16
  const canonical = canonicalizeGraph(graph);
17
17
  const { findings } = checkGraph(canonical);
18
18
  const blind = surveyBlindspots(canonical, report);
19
- const verdict = verdictOf(findings, canonical, { coverage: blind.coverage.ratio });
19
+ const verdict = verdictOf(findings, canonical, {
20
+ coverage: blind.coverage.ratio,
21
+ blindHigh: blind.byRisk.critical + blind.byRisk.high,
22
+ });
20
23
  const cov = Math.round(blind.coverage.ratio * 100);
21
24
  const { state, message, color } = badgeFor(verdict, { coverage: blind.coverage.ratio });
22
25
 
@@ -21,7 +21,10 @@ export async function runDossier(opts) {
21
21
  const { findings, polarity } = checkGraph(canonical);
22
22
  const capsule = buildCapsule(canonical);
23
23
  const blindspots = surveyBlindspots(canonical, compiled.report);
24
- const verdict = verdictOf(findings, canonical, { coverage: blindspots.coverage.ratio });
24
+ const verdict = verdictOf(findings, canonical, {
25
+ coverage: blindspots.coverage.ratio,
26
+ blindHigh: blindspots.byRisk.critical + blindspots.byRisk.high,
27
+ });
25
28
 
26
29
  const data = {
27
30
  app: path.basename(path.resolve(opts.cwd)) || 'app',
@@ -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
+ }
@@ -48,6 +48,17 @@ export async function runInit(opts) {
48
48
  s.stop(
49
49
  `Stack detected: ${c.cyan('Next.js (App Router)')} — app dir: ${stack.entryFile}/, port: ${stack.port}`,
50
50
  );
51
+ } else {
52
+ s.stop(
53
+ `Stack detected: ${c.cyan(stack.framework)} — but AST parsing is not supported yet.`,
54
+ );
55
+ throw Object.assign(
56
+ new Error(`AST extraction for ${stack.framework} is not yet supported.`),
57
+ {
58
+ code: 'USER',
59
+ hint: `SPARDA detected ${stack.framework}, but currently only supports Express, FastAPI, and Next.js for AST parsing. NestJS and others are on the roadmap!`,
60
+ },
61
+ );
51
62
  }
52
63
 
53
64
  // Opt-in runtime probe (Brief #3): static is the floor, probe only ADDS routes
@@ -19,7 +19,10 @@ export async function runProve(opts) {
19
19
 
20
20
  const { findings, obligations } = checkGraph(canonical);
21
21
  const blind = surveyBlindspots(canonical, report);
22
- const verdict = verdictOf(findings, canonical, { coverage: blind.coverage.ratio });
22
+ const verdict = verdictOf(findings, canonical, {
23
+ coverage: blind.coverage.ratio,
24
+ blindHigh: blind.byRisk.critical + blind.byRisk.high,
25
+ });
23
26
  const capsule = buildCapsule(canonical);
24
27
  const prints = fingerprintGraph(canonical);
25
28
  // one app-level seal: a content address over every route's behavior hash — the same
@@ -62,11 +62,15 @@ export function reviewGraphs(baseGraph, candidateGraph) {
62
62
  // how much of the changed app SPARDA could actually see — and whether this PR moved
63
63
  // that boundary. A green review over a graph the PR made 20% blinder is not the same
64
64
  // as a green review at full sight; the reviewer must be able to tell them apart.
65
- const candCov = surveyBlindspots(candidateGraph).coverage.ratio;
65
+ const candBlind = surveyBlindspots(candidateGraph);
66
+ const candCov = candBlind.coverage.ratio;
66
67
  const baseCov = surveyBlindspots(baseGraph).coverage.ratio;
67
68
 
68
69
  return {
69
- verdict: verdictOf(findings, candidateGraph, { coverage: candCov }),
70
+ verdict: verdictOf(findings, candidateGraph, {
71
+ coverage: candCov,
72
+ blindHigh: candBlind.byRisk.critical + candBlind.byRisk.high,
73
+ }),
70
74
  obligations,
71
75
  findings,
72
76
  endpoints: { added, removed: removedEps },
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)
@@ -1186,7 +1186,10 @@ export function proveApp(cwd, { route } = {}) {
1186
1186
  const blind = surveyBlindspots(canonical, report);
1187
1187
  // The verdict word always reflects the WHOLE app (a route filter narrows the finding list,
1188
1188
  // never the safety claim — an AI must never read "PROVEN" because it hid the rest).
1189
- const verdict = verdictOf(all, canonical, { coverage: blind.coverage.ratio });
1189
+ const verdict = verdictOf(all, canonical, {
1190
+ coverage: blind.coverage.ratio,
1191
+ blindHigh: blind.byRisk.critical + blind.byRisk.high,
1192
+ });
1190
1193
  return {
1191
1194
  verdict: verdictState(verdict),
1192
1195
  provable: verdict.provable,
@@ -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();
@@ -488,7 +644,7 @@ const COVERAGE_FLOOR = 0.05;
488
644
  // finding, never changes the CI gate; a PARTIAL app is still clean, just qualified.
489
645
  const COVERAGE_COMPLETE = 0.6;
490
646
 
491
- export function verdictOf(findings, graph, { coverage } = {}) {
647
+ export function verdictOf(findings, graph, { coverage, blindHigh = 0 } = {}) {
492
648
  const counts = { critical: 0, high: 0, medium: 0, info: 0 };
493
649
  for (const f of findings) counts[f.severity]++;
494
650
  // Advisory findings (BOLA/IDOR, ADR-058) are absence-based and FP-prone: they point a
@@ -528,9 +684,20 @@ export function verdictOf(findings, graph, { coverage } = {}) {
528
684
  // A clean whole-app proof below the completeness bar is PARTIAL — proved, but not over
529
685
  // the whole surface. Only meaningful when coverage was measured (whole-app run); a partial
530
686
  // graph (heal delta, coverage undefined) is never labelled partial.
687
+ //
688
+ // E-047 (the blind-spot rung, from the cal.com giant test): coverage is a RATIO, so on a
689
+ // huge app it can clear the bar (cal.com/api/v2: 71%) while the ABSOLUTE count of high-risk
690
+ // blind spots is large (46 guarded mutations whose writes never resolved). A bare "PROVEN"
691
+ // over 46 unproven high-risk mutations over-claims. So a clean app is also PARTIAL when any
692
+ // high-severity blind spot remains — the label carries the uncertainty (metrology's error
693
+ // bars), independent of the ratio. Sound: only ever SOFTENS PROVEN→PARTIAL, never masks a
694
+ // finding, never touches the CI gate (`safe`). blindHigh defaults to 0, so a caller that
695
+ // did not survey blind spots (heal delta) is unaffected.
531
696
  const clean = provable && !surfaceOnly && hardCount === 0;
532
697
  const partial =
533
- clean && coverage != null && entrypoints > 0 && coverage < COVERAGE_COMPLETE;
698
+ clean &&
699
+ entrypoints > 0 &&
700
+ ((coverage != null && coverage < COVERAGE_COMPLETE) || blindHigh > 0);
534
701
  // `safe` is the CI gate (block a risky deploy): a surface-only app has no
535
702
  // critical/high findings and is NOT risky, so it does not fail the gate — it just
536
703
  // isn't a positive proof. `clean` is the strong claim (PROVEN) and DOES require
@@ -140,6 +140,8 @@ const moduleCache = new Map(); // absFile -> module facts (parse once per compil
140
140
  export function clearModuleCache() {
141
141
  moduleCache.clear();
142
142
  tsconfigCache.clear();
143
+ workspaceCache.clear();
144
+ workspaceRootOf.clear();
143
145
  }
144
146
 
145
147
  // absFile → { ast, functions: Map<name,{node,line,exported}>, imports: Map<local,abs>, error }
@@ -357,7 +359,159 @@ export function resolveRelImport(fromFile, spec) {
357
359
  // cross-module hop (controller → service → repository) dead-ends and effects behind
358
360
  // DI are invisible. A bare npm package (`kysely`, `@nestjs/common`) simply resolves
359
361
  // to nothing here (no matching file under the project), which is correct.
360
- return resolveAliasedImport(fromFile, clean(spec));
362
+ //
363
+ // The WORKSPACE fallback (the mycorrhizal network): a monorepo app's real mutation
364
+ // logic lives in shared workspace packages it imports by NAME, not by path — cal.com's
365
+ // `this.svc.updateEventType()` delegates to `@calcom/platform-libraries` → `@calcom/trpc`
366
+ // → `prisma.update()`, three packages away and entirely outside the analyzed app dir. A
367
+ // tsconfig alias can't reach them (they're resolved via the workspace, not `paths`). So
368
+ // when the alias miss, map the `@scope/pkg[/subpath]` specifier to the package's real
369
+ // directory under the workspace and resolve into it. Trees share nutrients across the
370
+ // fungal network, not just their own root ball — the schema/effect code is a shared
371
+ // nutrient drawn from the workspace, not the app's own folder.
372
+ return (
373
+ resolveAliasedImport(fromFile, clean(spec)) ??
374
+ resolveWorkspaceImport(fromFile, clean(spec))
375
+ );
376
+ }
377
+
378
+ // name -> absolute package dir, for every package in the workspace that owns `fromFile`.
379
+ // Cached per workspace root (built once, ~100 package.json reads on a giant monorepo).
380
+ const workspaceCache = new Map(); // rootDir -> Map(name -> dir)
381
+ const workspaceRootOf = new Map(); // dir -> rootDir | null
382
+
383
+ // walk up to the monorepo root: the nearest ancestor whose package.json declares
384
+ // `workspaces`, or that carries a pnpm-workspace.yaml. `null` = not a workspace.
385
+ function findWorkspaceRoot(fromFile) {
386
+ let dir = path.dirname(fromFile);
387
+ const chain = [];
388
+ for (let i = 0; i < 40; i++) {
389
+ if (workspaceRootOf.has(dir)) {
390
+ const v = workspaceRootOf.get(dir);
391
+ for (const d of chain) workspaceRootOf.set(d, v);
392
+ return v;
393
+ }
394
+ chain.push(dir);
395
+ let root = null;
396
+ try {
397
+ const pj = path.join(dir, 'package.json');
398
+ if (fs.existsSync(pj)) {
399
+ const pkg = JSON.parse(fs.readFileSync(pj, 'utf8'));
400
+ if (pkg.workspaces) root = dir;
401
+ }
402
+ if (!root && fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) root = dir;
403
+ } catch {
404
+ // unreadable manifest — keep walking up
405
+ }
406
+ if (root) {
407
+ for (const d of chain) workspaceRootOf.set(d, root);
408
+ return root;
409
+ }
410
+ const parent = path.dirname(dir);
411
+ if (parent === dir) break;
412
+ dir = parent;
413
+ }
414
+ for (const d of chain) workspaceRootOf.set(d, null);
415
+ return null;
416
+ }
417
+
418
+ // the workspace's package globs, from package.json `workspaces` (array or {packages})
419
+ // or a minimal pnpm-workspace.yaml parse (the `- '...'` list under `packages:`).
420
+ function workspaceGlobs(root) {
421
+ const globs = [];
422
+ try {
423
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
424
+ const ws = pkg.workspaces;
425
+ if (Array.isArray(ws)) globs.push(...ws);
426
+ else if (ws && Array.isArray(ws.packages)) globs.push(...ws.packages);
427
+ } catch {
428
+ // no/invalid root package.json — fall through to pnpm
429
+ }
430
+ try {
431
+ const yml = fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8');
432
+ for (const m of yml.matchAll(/^\s*-\s*['"]?([^'"\n]+?)['"]?\s*$/gm)) globs.push(m[1]);
433
+ } catch {
434
+ // no pnpm-workspace.yaml
435
+ }
436
+ return globs;
437
+ }
438
+
439
+ // expand one workspace glob to concrete package dirs. Supports the two forms real
440
+ // workspaces use: an exact path (`packages/app-store`) and a trailing `/*` (one level of
441
+ // subdirs, e.g. `packages/*`, `packages/platform/*`). `**` is treated as a single level —
442
+ // good enough for every workspace layout in the wild, and never unbounded.
443
+ function expandGlob(root, glob) {
444
+ const g = glob.replace(/\/\*\*$/, '/*');
445
+ const star = g.indexOf('*');
446
+ if (star === -1) return [path.resolve(root, g)];
447
+ const baseRel = g.slice(0, star).replace(/\/$/, '');
448
+ const baseDir = path.resolve(root, baseRel);
449
+ try {
450
+ return fs
451
+ .readdirSync(baseDir, { withFileTypes: true })
452
+ .filter((e) => e.isDirectory())
453
+ .map((e) => path.join(baseDir, e.name));
454
+ } catch {
455
+ return [];
456
+ }
457
+ }
458
+
459
+ export function workspacePackages(fromFile) {
460
+ const root = findWorkspaceRoot(fromFile);
461
+ if (!root) return null;
462
+ if (workspaceCache.has(root)) return workspaceCache.get(root);
463
+ const map = new Map();
464
+ for (const glob of workspaceGlobs(root)) {
465
+ for (const dir of expandGlob(root, glob)) {
466
+ try {
467
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
468
+ if (typeof pkg.name === 'string' && !map.has(pkg.name)) map.set(pkg.name, dir);
469
+ } catch {
470
+ // not a package (no/invalid package.json) — skip
471
+ }
472
+ }
473
+ }
474
+ workspaceCache.set(root, map);
475
+ return map;
476
+ }
477
+
478
+ // the entry file of a package with no subpath import (`@calcom/trpc`): its declared
479
+ // source/main/module, else the conventional src/index or index.
480
+ function packageEntry(dir) {
481
+ const fields = [];
482
+ try {
483
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
484
+ for (const f of [pkg.source, pkg.module, pkg.main])
485
+ if (typeof f === 'string') fields.push(f);
486
+ } catch {
487
+ // no package.json fields — conventions below still apply
488
+ }
489
+ for (const f of [...fields, 'src/index', 'index']) {
490
+ const hit = firstModuleFile(path.resolve(dir, f.replace(/\.(m?[jt]s|cjs)$/, '')));
491
+ if (hit) return hit;
492
+ }
493
+ return null;
494
+ }
495
+
496
+ // resolve `@scope/pkg/subpath` (or `pkg/subpath`) to a real source file under the
497
+ // workspace. Longest package-name match wins (`@calcom/platform-libraries` beats a
498
+ // hypothetical `@calcom/platform`), then the subpath resolves against the package dir.
499
+ function resolveWorkspaceImport(fromFile, spec) {
500
+ if (spec.startsWith('.') || spec.startsWith('/')) return null;
501
+ const map = workspacePackages(fromFile);
502
+ if (!map) return null;
503
+ let best = null;
504
+ for (const name of map.keys()) {
505
+ if (
506
+ (spec === name || spec.startsWith(name + '/')) &&
507
+ name.length > (best?.length ?? -1)
508
+ )
509
+ best = name;
510
+ }
511
+ if (!best) return null;
512
+ const dir = map.get(best);
513
+ const sub = spec.length > best.length ? spec.slice(best.length + 1) : '';
514
+ return sub ? firstModuleFile(path.resolve(dir, sub)) : packageEntry(dir);
361
515
  }
362
516
 
363
517
  // probe the standard TS/JS extensions + index files for a resolved base path.
@@ -677,6 +831,14 @@ export function scanFunction(fnNode, env = {}) {
677
831
  calls: [],
678
832
  guardSignals: { deniesWithStatus: false },
679
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 },
680
842
  async: Boolean(fnNode?.async),
681
843
  };
682
844
  if (!fnNode) return result;
@@ -772,6 +934,64 @@ function whereOwnerScoped(whereNode) {
772
934
  return scoped;
773
935
  }
774
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
+
775
995
  // does this `where` target a specific object by a bare `id` key (the BOLA shape)?
776
996
  function whereHasIdKey(whereNode) {
777
997
  if (whereNode?.type !== 'ObjectExpression') return false;
@@ -867,6 +1087,11 @@ function visit(node, out, ctx) {
867
1087
  return;
868
1088
  }
869
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
+
870
1095
  // try/catch — try-effects are compensable by catch-effects (SBIR §2.2)
871
1096
  if (node.type === 'TryStatement') {
872
1097
  const tryLine = node.loc?.start.line ?? 0;
@@ -969,9 +1194,42 @@ function inspectCall(node, out, ctx) {
969
1194
  if (out.returnShapes.length < MAX_RETURN_SHAPES && jsonShape !== null)
970
1195
  out.returnShapes.push({ line, shape: jsonShape });
971
1196
  if (deniedStatusOf(node)) out.guardSignals.deniesWithStatus = true;
1197
+ if (statusIn4xx(node)) out.credentialSignals.denies4xxOrThrows = true;
972
1198
  return;
973
1199
  }
974
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;
975
1233
 
976
1234
  // ---- local calls: bare identifier calls → call-graph edges later
977
1235
  if (callee.type === 'Identifier') {
@@ -1330,6 +1588,45 @@ function isDenyOptions(arg) {
1330
1588
  return false;
1331
1589
  }
1332
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
+
1333
1630
  function deniedStatusOf(node) {
1334
1631
  const isDeny = (v) => v === 401 || v === 403;
1335
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) {
package/src/ubg/prisma.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { cmp } from './schema.js';
13
+ import { workspacePackages } from './extract.js';
13
14
 
14
15
  const SCHEMA_CANDIDATES = ['prisma/schema.prisma', 'schema.prisma', 'db/schema.prisma'];
15
16
  // Prisma's `prismaSchemaFolder` layout (stable since 6.x): the schema is SPLIT across many
@@ -55,6 +56,35 @@ function collectSchemaFiles(cwd, fileCandidates, dirCandidates) {
55
56
  return [];
56
57
  }
57
58
 
59
+ // P4 (E-048, the mycorrhizal network for STATE): a monorepo app usually declares NO schema of
60
+ // its own — it depends on a shared `@scope/prisma`-style workspace package that owns the whole
61
+ // schema (cal.com: apps/web has 0 tables locally; `@calcom/prisma` has 100 models). Without
62
+ // this the app's entire state layer is invisible and every mutation reasons against nothing.
63
+ // When the app dir yields no schema, look through its workspace DEPENDENCIES for the package
64
+ // that owns one — the same name→dir map the module resolver already builds. Schema-ish deps are
65
+ // tried first (a `prisma`/`db` package is the DB package), then any remaining workspace dep.
66
+ function workspaceSchemaFiles(cwd, fileCandidates, dirCandidates) {
67
+ let deps;
68
+ try {
69
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
70
+ deps = { ...pkg.dependencies, ...pkg.devDependencies };
71
+ } catch {
72
+ return [];
73
+ }
74
+ const map = workspacePackages(path.join(cwd, 'package.json'));
75
+ if (!map) return [];
76
+ const inWorkspace = Object.keys(deps).filter((n) => map.has(n));
77
+ const ordered = [
78
+ ...inWorkspace.filter((n) => /prisma|schema|(^|[-/])db($|[-/])|database/i.test(n)),
79
+ ...inWorkspace.filter((n) => !/prisma|schema|(^|[-/])db($|[-/])|database/i.test(n)),
80
+ ];
81
+ for (const name of ordered) {
82
+ const files = collectSchemaFiles(map.get(name), fileCandidates, dirCandidates);
83
+ if (files.length) return files;
84
+ }
85
+ return [];
86
+ }
87
+
58
88
  const TYPE_MAP = {
59
89
  string: 'string',
60
90
  int: 'number',
@@ -78,7 +108,11 @@ export function parsePrismaSchemas(cwd) {
78
108
  } catch {
79
109
  // no package.json / unparsable — the default candidates stand
80
110
  }
81
- const files = collectSchemaFiles(cwd, candidates, SCHEMA_DIR_CANDIDATES);
111
+ // local first; then the workspace dependency that owns the shared schema (P4)
112
+ const local = collectSchemaFiles(cwd, candidates, SCHEMA_DIR_CANDIDATES);
113
+ const files = local.length
114
+ ? local
115
+ : workspaceSchemaFiles(cwd, candidates, SCHEMA_DIR_CANDIDATES);
82
116
  if (!files.length) return { tables: [], skipped, sourceFile: null };
83
117
 
84
118
  // Read + strip comments once per file; keep the relative path for correct per-table locs.
@@ -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