@vantageos/backend-doctor 0.1.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.
Files changed (108) hide show
  1. package/README.md +88 -0
  2. package/dist/cli.d.ts +2 -0
  3. package/dist/cli.js +94 -0
  4. package/dist/contract.d.ts +18 -0
  5. package/dist/contract.js +29 -0
  6. package/dist/coverage-cli.d.ts +2 -0
  7. package/dist/coverage-cli.js +17 -0
  8. package/dist/coverage.d.ts +32 -0
  9. package/dist/coverage.js +77 -0
  10. package/dist/detectors/oracle-axis1.d.ts +86 -0
  11. package/dist/detectors/oracle-axis1.js +221 -0
  12. package/dist/detectors/oracle-axis2.d.ts +22 -0
  13. package/dist/detectors/oracle-axis2.js +149 -0
  14. package/dist/detectors/predicates.d.ts +19 -0
  15. package/dist/detectors/predicates.js +334 -0
  16. package/dist/detectors/shared.d.ts +56 -0
  17. package/dist/detectors/shared.js +103 -0
  18. package/dist/detectors/source/_scan.d.ts +43 -0
  19. package/dist/detectors/source/_scan.js +82 -0
  20. package/dist/detectors/source/backfill.d.ts +27 -0
  21. package/dist/detectors/source/backfill.js +211 -0
  22. package/dist/detectors/source/contract.d.ts +33 -0
  23. package/dist/detectors/source/contract.js +385 -0
  24. package/dist/detectors/source/identity.d.ts +47 -0
  25. package/dist/detectors/source/identity.js +410 -0
  26. package/dist/detectors/source/jobs.d.ts +10 -0
  27. package/dist/detectors/source/jobs.js +209 -0
  28. package/dist/detectors/source/lifecycle.d.ts +6 -0
  29. package/dist/detectors/source/lifecycle.js +147 -0
  30. package/dist/detectors/source/readbudget.d.ts +6 -0
  31. package/dist/detectors/source/readbudget.js +133 -0
  32. package/dist/detectors/source/refusal.d.ts +6 -0
  33. package/dist/detectors/source/refusal.js +125 -0
  34. package/dist/detectors/source/security.d.ts +37 -0
  35. package/dist/detectors/source/security.js +392 -0
  36. package/dist/detectors/source/writesafety.d.ts +14 -0
  37. package/dist/detectors/source/writesafety.js +345 -0
  38. package/dist/detectors/standard/_doc.d.ts +66 -0
  39. package/dist/detectors/standard/_doc.js +152 -0
  40. package/dist/detectors/standard/r41-frontier.d.ts +58 -0
  41. package/dist/detectors/standard/r41-frontier.js +176 -0
  42. package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
  43. package/dist/detectors/standard/r46-returns-validator.js +474 -0
  44. package/dist/detectors/standard/standard-doc.d.ts +12 -0
  45. package/dist/detectors/standard/standard-doc.js +271 -0
  46. package/dist/detectors/structural.d.ts +14 -0
  47. package/dist/detectors/structural.js +83 -0
  48. package/dist/engine-types.d.ts +10 -0
  49. package/dist/engine-types.js +1 -0
  50. package/dist/not-checkable.d.ts +22 -0
  51. package/dist/not-checkable.js +37 -0
  52. package/dist/oracle.d.ts +33 -0
  53. package/dist/oracle.js +133 -0
  54. package/dist/registry.d.ts +11 -0
  55. package/dist/registry.js +140 -0
  56. package/dist/report.d.ts +35 -0
  57. package/dist/report.js +138 -0
  58. package/dist/severity.d.ts +30 -0
  59. package/dist/severity.js +121 -0
  60. package/dist/tally.d.ts +57 -0
  61. package/dist/tally.js +152 -0
  62. package/dist/trigger.d.ts +47 -0
  63. package/dist/trigger.js +143 -0
  64. package/fixtures/backend-standard-51ec7af1.md +648 -0
  65. package/fixtures/backend-standard-a9d7877b.md +629 -0
  66. package/fixtures/mcp-app-standard-a74a042b.md +279 -0
  67. package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
  68. package/fixtures/mcp-server-standard-a74a042b.md +434 -0
  69. package/fixtures/mcp-standard-a74a042b.md +354 -0
  70. package/fixtures/vp-1200-green/.pin +1 -0
  71. package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
  72. package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
  73. package/fixtures/vp-1200-red/.pin +1 -0
  74. package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
  75. package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
  76. package/fixtures/vp-by-tool-day158.csv +190 -0
  77. package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
  78. package/fixtures/vp-convex/.pin +1 -0
  79. package/fixtures/vp-convex/bun.lock +783 -0
  80. package/fixtures/vp-convex/convex/auth.config.ts +8 -0
  81. package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
  82. package/fixtures/vp-convex/convex/convex.config.ts +14 -0
  83. package/fixtures/vp-convex/convex/credentials.ts +591 -0
  84. package/fixtures/vp-convex/convex/crons.ts +72 -0
  85. package/fixtures/vp-convex/convex/dashboard.ts +199 -0
  86. package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
  87. package/fixtures/vp-convex/convex/issues.ts +544 -0
  88. package/fixtures/vp-convex/convex/kb.ts +305 -0
  89. package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
  90. package/fixtures/vp-convex/convex/kbShared.ts +56 -0
  91. package/fixtures/vp-convex/convex/licenses.ts +443 -0
  92. package/fixtures/vp-convex/convex/memories.ts +406 -0
  93. package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
  94. package/fixtures/vp-convex/convex/messages.ts +971 -0
  95. package/fixtures/vp-convex/convex/migrations.ts +239 -0
  96. package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
  97. package/fixtures/vp-convex/convex/oauth.ts +1608 -0
  98. package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
  99. package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
  100. package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
  101. package/fixtures/vp-convex/convex/schema.ts +1229 -0
  102. package/fixtures/vp-convex/convex/tasks.ts +2782 -0
  103. package/fixtures/vp-r31-green/.pin +1 -0
  104. package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
  105. package/fixtures/vp-r31-red/.pin +1 -0
  106. package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
  107. package/fixtures/vr-by-tool-day159.csv +89 -0
  108. package/package.json +39 -0
@@ -0,0 +1,47 @@
1
+ import type { LiveDetector } from "@vantageos/mcp-doctor";
2
+ import type { Detector, DetectorResult, Tree } from "../../engine-types.js";
3
+ /** The standard both are pinned against (short hash a9d7877b), §15/§18. */
4
+ export declare const IDENTITY_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) \u00A715/\u00A718";
5
+ type SrcFile = Tree["files"][number];
6
+ /** A registrable entry — structurally the RegistryEntry the orchestrator spreads
7
+ * into REGISTRY. We do NOT import registry.ts; we mirror its shape so the
8
+ * orchestrator owns the integration (same discipline as security.ts). */
9
+ export interface IdentityDetectorEntry {
10
+ detector: Detector;
11
+ ruleId: string;
12
+ standardPin: string;
13
+ }
14
+ interface Site {
15
+ rel: string;
16
+ n: number;
17
+ note: string;
18
+ }
19
+ interface Scan {
20
+ conform: Site[];
21
+ violate: Site[];
22
+ cnj: Site[];
23
+ }
24
+ export declare function scanR6(files: SrcFile[]): Scan;
25
+ export declare function scanR16(files: SrcFile[]): Scan;
26
+ /** A live-probe outcome for R-6: what a boundary-less write call returned. */
27
+ export type LiveWriteOutcome = {
28
+ kind: "refused";
29
+ message: string;
30
+ } | {
31
+ kind: "accepted";
32
+ };
33
+ /** Classify an R-6 live probe: a boundary-less write MUST be refused fail-closed. */
34
+ export declare function classifyR6Live(id: string, tool: string, outcome: LiveWriteOutcome): DetectorResult;
35
+ /** Classify an R-16 live probe: a triggered error MUST carry {code,field,message}. */
36
+ export declare function classifyR16Live(id: string, payload: string): DetectorResult;
37
+ /** Pull tool names from a served `tools/list` payload of unknown shape. */
38
+ export declare function extractToolNames(payload: unknown): string[];
39
+ /** True when a served tool result signals a refusal rather than a success. */
40
+ export declare function looksLikeRefusal(res: unknown): boolean;
41
+ /** R-6 — static primary + live secondary (LiveDetector). */
42
+ export declare const R6: Detector & LiveDetector;
43
+ /** R-16 — static primary + live secondary (LiveDetector). */
44
+ export declare const R16: Detector & LiveDetector;
45
+ /** The two, in a form the orchestrator spreads into REGISTRY. */
46
+ export declare const IDENTITY_DETECTORS: IdentityDetectorEntry[];
47
+ export {};
@@ -0,0 +1,410 @@
1
+ /** The standard both are pinned against (short hash a9d7877b), §15/§18. */
2
+ export const IDENTITY_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15/§18";
3
+ function emptyScan() {
4
+ return { conform: [], violate: [], cnj: [] };
5
+ }
6
+ function base(rel) {
7
+ return (rel.split("/").pop() ?? rel).toLowerCase();
8
+ }
9
+ /** a source file we should judge — never a test or generated file. */
10
+ function isJudgeable(f) {
11
+ const b = base(f.rel);
12
+ return (!b.endsWith(".test.ts") &&
13
+ !f.rel.includes("__tests__") &&
14
+ !f.rel.includes("_generated/"));
15
+ }
16
+ /** an EXECUTING line (not a comment-only line). */
17
+ function code(l) {
18
+ return !l.comment;
19
+ }
20
+ /** convex source only — R-6/R-16 govern the data layer's write handlers. */
21
+ function isConvex(f) {
22
+ return /(^|\/)convex\//.test(f.rel);
23
+ }
24
+ /**
25
+ * Reduce a scan to ONE engine DetectorResult (identical precedence to the
26
+ * security.ts SOURCE kind): a concrete VIOLATION site is decisive; else any
27
+ * could-not-judge → could-not-judge WITH a reason; else any conform → present;
28
+ * else (zero applicable sites) → could-not-judge naming the empty scan. A found
29
+ * bad file:line is never a silent pass.
30
+ */
31
+ function decide(ruleId, id, check, scan, emptyReason) {
32
+ const nC = scan.conform.length;
33
+ const nV = scan.violate.length;
34
+ const nJ = scan.cnj.length;
35
+ const breakdown = `[conform=${nC} non-conforming=${nV} could-not-judge=${nJ}]`;
36
+ const fmt = (s) => s
37
+ .slice(0, 3)
38
+ .map((x) => `${x.rel}:${x.n} (${x.note})`)
39
+ .join("; ");
40
+ if (nV > 0) {
41
+ return {
42
+ id,
43
+ anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
44
+ state: "non-conforming",
45
+ verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`,
46
+ };
47
+ }
48
+ if (nJ > 0) {
49
+ const reason = `${ruleId}: ${nJ} site(s) could not be judged — ${fmt(scan.cnj)}. This is NOT a clean bill. ${breakdown}`;
50
+ return {
51
+ id,
52
+ anchor: `${scan.cnj[0].rel}:${scan.cnj[0].n}`,
53
+ state: "could-not-judge",
54
+ verdict: reason,
55
+ reason,
56
+ };
57
+ }
58
+ if (nC > 0) {
59
+ return {
60
+ id,
61
+ anchor: `${scan.conform[0].rel}:${scan.conform[0].n}`,
62
+ state: "present",
63
+ verdict: `${ruleId}: ${nC} applicable site(s) conform (${check}); 0 violations. ${breakdown} Conform: ${fmt(scan.conform)}.`,
64
+ };
65
+ }
66
+ return {
67
+ id,
68
+ anchor: "source:no-applicable-site",
69
+ state: "could-not-judge",
70
+ verdict: emptyReason,
71
+ reason: emptyReason,
72
+ };
73
+ }
74
+ /** Split a convex file into its top-level mutation/internalMutation handler
75
+ * blocks. A block runs from `export const <name> = mutation(` to the next
76
+ * top-level `export ` (or EOF). Queries/actions are not R-6 write handlers. */
77
+ function splitMutationHandlers(f) {
78
+ const out = [];
79
+ const open = /export\s+const\s+(\w+)\s*=\s*(?:internalMutation|mutation)\s*\(/;
80
+ const topExport = /^export\s+/;
81
+ let cur = null;
82
+ for (const l of f.lines) {
83
+ const m = code(l) ? open.exec(l.raw) : null;
84
+ if (m) {
85
+ if (cur)
86
+ out.push(cur);
87
+ cur = { name: m[1], startN: l.n, lines: [l] };
88
+ continue;
89
+ }
90
+ if (cur) {
91
+ // a new top-level export that is NOT this handler's opener ends the block.
92
+ if (code(l) && topExport.test(l.raw)) {
93
+ out.push(cur);
94
+ cur = null;
95
+ }
96
+ else {
97
+ cur.lines.push(l);
98
+ }
99
+ }
100
+ }
101
+ if (cur)
102
+ out.push(cur);
103
+ return out;
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // R-6 — a write derives its org/tenant boundary from a VERIFIED IDENTITY claim
107
+ // (or the TARGET resource) and refuses when absent — never from a caller-
108
+ // supplied argument. (standard §15 line 71 / §18 line 395)
109
+ //
110
+ // SOURCE READ (see file header): memoriesScoped.storeMemoryScoped (identity via
111
+ // resolveOrgId + assertNamespaceAllowed) and tasks.update/complete/start/
112
+ // deleteTask (resource via assertTaskCallerAuthorized) are conform; tasks.create
113
+ // and briefingNotes.create insert a boundary field taken from a caller arg with
114
+ // NO guard — the violation.
115
+ // ---------------------------------------------------------------------------
116
+ const R6_WRITE = /ctx\.db\.(?:insert|patch|replace|delete)\s*\(/;
117
+ const R6_IDENTITY_GUARD = /getUserIdentity|resolveOrgId|resolveOrgContext|assertNamespaceAllowed|expectedOrgIdForNamespace|tenantSlug|organizationId|organizationSlug|AUTH_NO_ORG|AUTH_NAMESPACE_DENIED/;
118
+ const R6_RESOURCE_GUARD = /assertTaskCallerAuthorized|RBAC_DENIED|\.createdBy\s*===|\.assignedTo\s*===|\.orchestratorId\s*===|callerOrchestrator\s*===\s*["']system["']|note\.createdBy/;
119
+ const R6_BOUNDARY_ARG = /\b(?:createdBy|orgId|orgSlug|namespace|orchestratorId|ownerId|tenantId)\b/;
120
+ export function scanR6(files) {
121
+ const scan = emptyScan();
122
+ for (const f of files) {
123
+ if (!isJudgeable(f) || !isConvex(f))
124
+ continue;
125
+ for (const h of splitMutationHandlers(f)) {
126
+ let writeLine = 0;
127
+ let guard = false;
128
+ let boundaryArg = false;
129
+ for (const l of h.lines) {
130
+ if (!code(l))
131
+ continue;
132
+ if (!writeLine && R6_WRITE.test(l.raw))
133
+ writeLine = l.n;
134
+ if (R6_IDENTITY_GUARD.test(l.raw) || R6_RESOURCE_GUARD.test(l.raw))
135
+ guard = true;
136
+ if (R6_BOUNDARY_ARG.test(l.raw))
137
+ boundaryArg = true;
138
+ }
139
+ if (!writeLine)
140
+ continue; // not a write handler — R-6 does not govern it.
141
+ if (guard) {
142
+ scan.conform.push({
143
+ rel: f.rel,
144
+ n: writeLine,
145
+ note: `${h.name}: boundary derived from a verified identity / target-resource guard`,
146
+ });
147
+ }
148
+ else if (boundaryArg) {
149
+ scan.violate.push({
150
+ rel: f.rel,
151
+ n: writeLine,
152
+ note: `${h.name}: writes a boundary field (org/createdBy/namespace) taken from a caller argument with no identity/resource guard — the boundary is the argument`,
153
+ });
154
+ }
155
+ else {
156
+ scan.cnj.push({
157
+ rel: f.rel,
158
+ n: writeLine,
159
+ note: `${h.name}: writes with no identity/resource guard and no clearly caller-supplied boundary field — deferred, not statically confirmed`,
160
+ });
161
+ }
162
+ }
163
+ }
164
+ return scan;
165
+ }
166
+ // ---------------------------------------------------------------------------
167
+ // R-16 — a refusal returns a structured error {code, field/scope, message} and
168
+ // defaults fail-closed. (standard §15 line 99 / §18 line 405)
169
+ //
170
+ // SOURCE READ: tasks.ts throws carry a stable UPPER_SNAKE_CASE code
171
+ // (RBAC_DENIED, TASK_NOT_FOUND, TASK_START_BLOCKED, SCAN_CAP_EXCEEDED,
172
+ // INVALID_RANGE, ...) plus interpolated `${JSON.stringify({...})}` context — the
173
+ // conform contract shape. briefingNotes.ts throws
174
+ // `new ConvexError("Unauthorized: ...")` — a refusal with NO structured code:
175
+ // the violation. A generic validation throw ("invalid status: ...") is neither a
176
+ // coded contract nor a security refusal — not counted here.
177
+ // ---------------------------------------------------------------------------
178
+ const R16_CODE_TOKEN = /\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/;
179
+ // authorization vocabulary — a REFUSAL, not a generic validation message. Kept
180
+ // narrow on purpose: "not allowed" alone matched a tasks.ts array-alias
181
+ // validation throw, which is not an R-16 refusal-contract site.
182
+ const R16_REFUSAL_KW = /unauthori|forbidden|\bdenied\b|not creator|may not|permission|is not (?:the |a )?(?:creator|owner|authorized)/i;
183
+ /** Collect every `new ConvexError(...)` / `new Error(...)` throw payload
184
+ * (multi-line, joined). A refusal thrown as a plain `Error` with no code is
185
+ * exactly the R-16 violation, so both throw kinds are read. */
186
+ function collectConvexErrorThrows(f) {
187
+ const out = [];
188
+ const lines = f.lines;
189
+ for (let i = 0; i < lines.length; i++) {
190
+ const l = lines[i];
191
+ if (!code(l) || !/new (?:Convex)?Error\s*\(/.test(l.raw))
192
+ continue;
193
+ let payload = l.raw;
194
+ // gather until the throw statement closes (`);`) or a short window ends.
195
+ for (let j = i + 1; j < lines.length && j <= i + 6 && !/\)\s*;/.test(payload); j++) {
196
+ payload += `\n${lines[j].raw}`;
197
+ }
198
+ out.push({ n: l.n, payload });
199
+ }
200
+ return out;
201
+ }
202
+ export function scanR16(files) {
203
+ const scan = emptyScan();
204
+ for (const f of files) {
205
+ if (!isJudgeable(f) || !isConvex(f))
206
+ continue;
207
+ for (const t of collectConvexErrorThrows(f)) {
208
+ if (R16_CODE_TOKEN.test(t.payload)) {
209
+ scan.conform.push({
210
+ rel: f.rel,
211
+ n: t.n,
212
+ note: "refusal carries a stable UPPER_SNAKE_CASE code + interpolated context",
213
+ });
214
+ }
215
+ else if (R16_REFUSAL_KW.test(t.payload)) {
216
+ scan.violate.push({
217
+ rel: f.rel,
218
+ n: t.n,
219
+ note: "refusal thrown with no structured UPPER_SNAKE_CASE code — an opaque refusal a caller cannot branch on",
220
+ });
221
+ }
222
+ // else: a generic validation throw — not a structured-refusal contract site.
223
+ }
224
+ }
225
+ return scan;
226
+ }
227
+ // ---- static detector wrappers ---------------------------------------------
228
+ const R6_ID = "R-6 write boundary from a verified identity, not a caller argument";
229
+ const R16_ID = "R-16 refusal returns {code, field/scope, message}, fail-closed";
230
+ const R6_CHECK = "a write derives its org/tenant boundary from a verified identity claim or the target resource, never a caller argument";
231
+ const R16_CHECK = "a refusal throws a ConvexError with a stable UPPER_SNAKE_CASE code + context, never an opaque message";
232
+ /** Classify an R-6 live probe: a boundary-less write MUST be refused fail-closed. */
233
+ export function classifyR6Live(id, tool, outcome) {
234
+ if (outcome.kind === "accepted") {
235
+ return {
236
+ id,
237
+ anchor: `live:${tool}`,
238
+ state: "non-conforming",
239
+ verdict: `R-6 (live): tool "${tool}" ACCEPTED a write with no verified identity/boundary — fail-open.`,
240
+ };
241
+ }
242
+ if (/RBAC|AUTH|ORG|unauthori|denied|required|forbidden/i.test(outcome.message)) {
243
+ return {
244
+ id,
245
+ anchor: `live:${tool}`,
246
+ state: "present",
247
+ verdict: `R-6 (live): tool "${tool}" refused a boundary-less write fail-closed (${outcome.message.slice(0, 80)}).`,
248
+ };
249
+ }
250
+ const reason = `R-6 (live): tool "${tool}" refused, but the refusal names no boundary/identity/auth cause — cannot confirm it is R-6 fail-closed vs incidental validation. NOT a clean bill.`;
251
+ return {
252
+ id,
253
+ anchor: `live:${tool}`,
254
+ state: "could-not-judge",
255
+ verdict: reason,
256
+ reason,
257
+ };
258
+ }
259
+ /** Classify an R-16 live probe: a triggered error MUST carry {code,field,message}. */
260
+ export function classifyR16Live(id, payload) {
261
+ const hasCode = R16_CODE_TOKEN.test(payload);
262
+ const hasMessage = payload.trim().length > 0;
263
+ if (hasCode && hasMessage) {
264
+ return {
265
+ id,
266
+ anchor: "live:error-contract",
267
+ state: "present",
268
+ verdict: `R-16 (live): a triggered refusal carried a structured UPPER_SNAKE_CASE code + message (${payload.slice(0, 80)}).`,
269
+ };
270
+ }
271
+ if (R16_REFUSAL_KW.test(payload)) {
272
+ return {
273
+ id,
274
+ anchor: "live:error-contract",
275
+ state: "non-conforming",
276
+ verdict: `R-16 (live): a triggered refusal carried NO structured code — opaque error contract (${payload.slice(0, 80)}).`,
277
+ };
278
+ }
279
+ const reason = `R-16 (live): could not trigger a classifiable refusal — the served response is neither a coded refusal nor a recognisable one. NOT a clean bill.`;
280
+ return {
281
+ id,
282
+ anchor: "live:error-contract",
283
+ state: "could-not-judge",
284
+ verdict: reason,
285
+ reason,
286
+ };
287
+ }
288
+ const R6_WRITE_TOOL = /create|update|delete|write|store|insert|complete|start|set_/i;
289
+ async function r6RunLive(session) {
290
+ const cnj = (why) => ({
291
+ id: R6_ID,
292
+ anchor: "live:unavailable",
293
+ state: "could-not-judge",
294
+ verdict: `R-6 (live): ${why}`,
295
+ reason: `R-6 (live): ${why}`,
296
+ });
297
+ let tools;
298
+ try {
299
+ tools = await session.listTools();
300
+ }
301
+ catch (e) {
302
+ return cnj(`could not launch/introspect a running MCP session (listTools failed: ${String(e)}). Static half stands alone.`);
303
+ }
304
+ const names = extractToolNames(tools);
305
+ const target = names.find((n) => R6_WRITE_TOOL.test(n));
306
+ if (!target)
307
+ return cnj("the running server advertised no write tool to probe boundary-less.");
308
+ try {
309
+ const res = await session.callTool(target, {});
310
+ if (looksLikeRefusal(res)) {
311
+ return classifyR6Live(R6_ID, target, {
312
+ kind: "refused",
313
+ message: stringifyResult(res),
314
+ });
315
+ }
316
+ return classifyR6Live(R6_ID, target, { kind: "accepted" });
317
+ }
318
+ catch (e) {
319
+ return classifyR6Live(R6_ID, target, {
320
+ kind: "refused",
321
+ message: String(e),
322
+ });
323
+ }
324
+ }
325
+ async function r16RunLive(session) {
326
+ const cnj = (why) => ({
327
+ id: R16_ID,
328
+ anchor: "live:unavailable",
329
+ state: "could-not-judge",
330
+ verdict: `R-16 (live): ${why}`,
331
+ reason: `R-16 (live): ${why}`,
332
+ });
333
+ let tools;
334
+ try {
335
+ tools = await session.listTools();
336
+ }
337
+ catch (e) {
338
+ return cnj(`could not launch/introspect a running MCP session (listTools failed: ${String(e)}). Static half stands alone.`);
339
+ }
340
+ const names = extractToolNames(tools);
341
+ if (names.length === 0)
342
+ return cnj("the running server advertised no tool to trigger an error on.");
343
+ try {
344
+ // deliberately invalid args to trigger the error contract.
345
+ const res = await session.callTool(names[0], {
346
+ __backend_doctor_invalid__: true,
347
+ });
348
+ return classifyR16Live(R16_ID, stringifyResult(res));
349
+ }
350
+ catch (e) {
351
+ return classifyR16Live(R16_ID, String(e));
352
+ }
353
+ }
354
+ // ---- small helpers for the live probes (pure, testable) -------------------
355
+ /** Pull tool names from a served `tools/list` payload of unknown shape. */
356
+ export function extractToolNames(payload) {
357
+ const arr = Array.isArray(payload)
358
+ ? payload
359
+ : payload &&
360
+ typeof payload === "object" &&
361
+ Array.isArray(payload.tools)
362
+ ? payload.tools
363
+ : [];
364
+ const names = [];
365
+ for (const t of arr) {
366
+ if (t &&
367
+ typeof t === "object" &&
368
+ typeof t.name === "string") {
369
+ names.push(t.name);
370
+ }
371
+ }
372
+ return names;
373
+ }
374
+ /** True when a served tool result signals a refusal rather than a success. */
375
+ export function looksLikeRefusal(res) {
376
+ if (!res || typeof res !== "object")
377
+ return false;
378
+ const r = res;
379
+ if (r.isError === true || r.error != null || typeof r.code === "string")
380
+ return true;
381
+ return (R16_REFUSAL_KW.test(stringifyResult(res)) ||
382
+ R16_CODE_TOKEN.test(stringifyResult(res)));
383
+ }
384
+ function stringifyResult(res) {
385
+ if (typeof res === "string")
386
+ return res;
387
+ try {
388
+ return JSON.stringify(res);
389
+ }
390
+ catch {
391
+ return String(res);
392
+ }
393
+ }
394
+ /** R-6 — static primary + live secondary (LiveDetector). */
395
+ export const R6 = {
396
+ id: R6_ID,
397
+ run: (tree) => decide("R-6", R6_ID, R6_CHECK, scanR6(tree.files), "R-6: zero convex write handlers matched in the scanned tree — cannot certify boundary derivation. NOT a clean bill."),
398
+ runLive: r6RunLive,
399
+ };
400
+ /** R-16 — static primary + live secondary (LiveDetector). */
401
+ export const R16 = {
402
+ id: R16_ID,
403
+ run: (tree) => decide("R-16", R16_ID, R16_CHECK, scanR16(tree.files), "R-16: zero ConvexError throw sites matched in the scanned convex tree — cannot certify the error contract. NOT a clean bill."),
404
+ runLive: r16RunLive,
405
+ };
406
+ /** The two, in a form the orchestrator spreads into REGISTRY. */
407
+ export const IDENTITY_DETECTORS = [
408
+ { detector: R6, ruleId: "R-6", standardPin: IDENTITY_STANDARD_PIN },
409
+ { detector: R16, ruleId: "R-16", standardPin: IDENTITY_STANDARD_PIN },
410
+ ];
@@ -0,0 +1,10 @@
1
+ import type { Detector } from "../../engine-types.js";
2
+ import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
3
+ export declare function scanR19(files: SrcFile[]): Scan;
4
+ export declare function scanR20(files: SrcFile[]): Scan;
5
+ export declare function scanR23(files: SrcFile[]): Scan;
6
+ export declare const R19: Detector;
7
+ export declare const R20: Detector;
8
+ export declare const R23: Detector;
9
+ /** The three, in a form the orchestrator spreads into REGISTRY. */
10
+ export declare const SOURCE_JOBS_DETECTORS: SourceDetectorEntry[];
@@ -0,0 +1,209 @@
1
+ import { base, code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
2
+ // ---------------------------------------------------------------------------
3
+ // R-19 — a table holding personal data declares a retention period AND exposes
4
+ // an executable subject-deletion path. (standard §15 line 117 / line 408)
5
+ //
6
+ // SOURCE READ: convex/schema.ts `licenses` (line 973) holds `customerEmail`
7
+ // (line 975) and declares `expiresAt` (line 981, "purchasedAt + 365d") — a stated
8
+ // retention duration in the same table block. The executable delete mechanism
9
+ // lives in code: convex/memories.ts `softDeleteMemory` (line 341) +
10
+ // `expireMemoriesByTtl` (line 376), convex/messages.ts `deleteMessage` (line 580).
11
+ // PATTERN, two parts: (a) a schema table block carrying a personal-data field
12
+ // (customerEmail / email / customerName) must declare a retention field
13
+ // (expiresAt / ttl / deletedAt) WITHIN the block window — else violation; and
14
+ // (b) a subject-deletion mechanism must exist SOMEWHERE in the scanned tree —
15
+ // a table that states retention but where no delete path is visible is a
16
+ // could-not-judge (retention alone is not erasure).
17
+ // ---------------------------------------------------------------------------
18
+ const PERSONAL_FIELD = /customeremail|customername|\bemail:\b|\bemail\s*:/i;
19
+ const RETENTION_FIELD = /expiresat|ttl:|deletedat/i;
20
+ const DELETE_MECHANISM = /softdelete|expire\w*byttl|deletemessage|ctx\.db\.delete\s*\(/i;
21
+ const BLOCK_WINDOW = 40;
22
+ function isSchema(f) {
23
+ return base(f.rel) === "schema.ts";
24
+ }
25
+ export function scanR19(files) {
26
+ const scan = emptyScan();
27
+ // Part (b): is a subject-deletion mechanism visible anywhere in the tree?
28
+ let deletePath = null;
29
+ for (const f of files) {
30
+ if (!isJudgeable(f))
31
+ continue;
32
+ for (const l of f.lines) {
33
+ if (code(l) && DELETE_MECHANISM.test(l.raw)) {
34
+ deletePath = { rel: f.rel, n: l.n };
35
+ break;
36
+ }
37
+ }
38
+ if (deletePath)
39
+ break;
40
+ }
41
+ for (const f of files) {
42
+ if (!isJudgeable(f) || !isSchema(f))
43
+ continue;
44
+ for (let i = 0; i < f.lines.length; i++) {
45
+ const l = f.lines[i];
46
+ if (!code(l) || !PERSONAL_FIELD.test(l.raw))
47
+ continue;
48
+ // look for a retention field within the surrounding table-block window.
49
+ let retention = 0;
50
+ for (let j = Math.max(0, i - BLOCK_WINDOW); j <= Math.min(f.lines.length - 1, i + BLOCK_WINDOW); j++) {
51
+ if (code(f.lines[j]) && RETENTION_FIELD.test(f.lines[j].raw)) {
52
+ retention = f.lines[j].n;
53
+ break;
54
+ }
55
+ }
56
+ if (!retention) {
57
+ scan.violate.push({
58
+ rel: f.rel,
59
+ n: l.n,
60
+ note: "table holds a personal-data field but declares no retention duration (expiresAt/ttl/deletedAt) in the block — storage-limitation gap",
61
+ });
62
+ }
63
+ else if (!deletePath) {
64
+ scan.cnj.push({
65
+ rel: f.rel,
66
+ n: l.n,
67
+ note: `retention declared (line ${retention}) but no subject-deletion mechanism (softDelete/expireByTtl/deleteMessage) visible in the scanned tree — retention alone is not erasure`,
68
+ });
69
+ }
70
+ else {
71
+ scan.conform.push({
72
+ rel: f.rel,
73
+ n: l.n,
74
+ note: `personal-data table declares retention (line ${retention}) and a delete path exists at ${deletePath.rel}:${deletePath.n}`,
75
+ });
76
+ }
77
+ }
78
+ }
79
+ return scan;
80
+ }
81
+ // ---------------------------------------------------------------------------
82
+ // R-20 — background/scheduled work is bounded per run and observable (logs each
83
+ // run's outcome). (standard §15 line 119 / line 409)
84
+ //
85
+ // SOURCE READ: convex/recurringTasks.ts (the cron target `processDueTasks`)
86
+ // bounds its scan with the TASK_LIST_SCAN_CAP wide-scan-cap pattern (line 128)
87
+ // and logs each run's outcome with `console.log`/`console.error` (lines 347-360).
88
+ // convex/crons.ts wires it on a numeric interval (`{ minutes: 15 }`, line 9).
89
+ // PATTERN: a background-job handler file (recurring / monitor / sweep / poll /
90
+ // resolver / cleanup) is conform iff it declares a numeric per-run bound (a
91
+ // `.take(` / a `_CAP` / a `limit`) AND names an outcome log (`console.`). A job
92
+ // file that collects unbounded or logs nothing is the violation.
93
+ // ---------------------------------------------------------------------------
94
+ const JOB_FILE = /recurring|monitor|sweep|poll|resolver|cleanup|digest|cron/i;
95
+ const RUN_BOUND = /\.take\s*\(|_cap\b|\blimit\b/i;
96
+ const OUTCOME_LOG = /console\.(log|error|warn|info)\s*\(/;
97
+ function isJobFile(f) {
98
+ return JOB_FILE.test(base(f.rel));
99
+ }
100
+ export function scanR20(files) {
101
+ const scan = emptyScan();
102
+ for (const f of files) {
103
+ if (!isJudgeable(f) || !isJobFile(f))
104
+ continue;
105
+ let boundLine = 0;
106
+ let logLine = 0;
107
+ for (const l of f.lines) {
108
+ if (!code(l))
109
+ continue;
110
+ if (!boundLine && RUN_BOUND.test(l.raw))
111
+ boundLine = l.n;
112
+ if (!logLine && OUTCOME_LOG.test(l.raw))
113
+ logLine = l.n;
114
+ }
115
+ if (boundLine && logLine) {
116
+ scan.conform.push({
117
+ rel: f.rel,
118
+ n: boundLine,
119
+ note: `background job bounds its run (line ${boundLine}) and logs its outcome (line ${logLine})`,
120
+ });
121
+ }
122
+ else if (!boundLine) {
123
+ scan.violate.push({
124
+ rel: f.rel,
125
+ n: logLine || 1,
126
+ note: "background job declares no per-run bound (.take/_CAP/limit) — an unbounded run does not ship",
127
+ });
128
+ }
129
+ else {
130
+ scan.cnj.push({
131
+ rel: f.rel,
132
+ n: boundLine,
133
+ note: "background job bounds its run but no outcome log (console.*) found — observability not statically confirmed",
134
+ });
135
+ }
136
+ }
137
+ return scan;
138
+ }
139
+ // ---------------------------------------------------------------------------
140
+ // R-23 — a public write / expensive read declares a numeric rate AND runtime
141
+ // bound, or a written exception. (standard §15 line 125 / line 412)
142
+ //
143
+ // SOURCE READ: convex/credentials.ts issues a Bearer under a rate limit of
144
+ // `maxPerWindow: 5` over `windowMs: 60_000` (lines 513-514), rejecting with a
145
+ // `429` (line 523) — numeric rate + numeric window. PATTERN: a file that
146
+ // references a rate-limit control (`rateLimit` / `maxPerWindow` / `windowMs`) is
147
+ // conform iff BOTH a numeric window and a numeric max are present; a rate-limit
148
+ // control whose bounds are only non-numeric placeholders (no numeric literal) is
149
+ // the violation (unless a written exception is on file).
150
+ // ---------------------------------------------------------------------------
151
+ const RATE_REF = /ratelimit|maxperwindow|windowms|per\s+(minute|second|hour)/i;
152
+ const NUMERIC_WINDOW = /windowms\s*:\s*\d|(\d[\d_]*)\s*(ms|_?000)\b|per\s+(minute|second|hour)/i;
153
+ const NUMERIC_MAX = /maxperwindow\s*:\s*\d|\b\d+\s*(req|requests|per)\b/i;
154
+ const WRITTEN_EXCEPTION = /allow-unbounded|written exception|rate-limit exception/i;
155
+ function referencesRate(f) {
156
+ return f.lines.some((l) => code(l) && RATE_REF.test(l.raw));
157
+ }
158
+ export function scanR23(files) {
159
+ const scan = emptyScan();
160
+ for (const f of files) {
161
+ if (!isJudgeable(f) || !referencesRate(f))
162
+ continue;
163
+ let windowLine = 0;
164
+ let maxLine = 0;
165
+ let exception = 0;
166
+ for (const l of f.lines) {
167
+ if (!code(l))
168
+ continue;
169
+ if (!windowLine && NUMERIC_WINDOW.test(l.raw))
170
+ windowLine = l.n;
171
+ if (!maxLine && NUMERIC_MAX.test(l.raw))
172
+ maxLine = l.n;
173
+ if (!exception && WRITTEN_EXCEPTION.test(l.raw))
174
+ exception = l.n;
175
+ }
176
+ if (windowLine && maxLine) {
177
+ scan.conform.push({
178
+ rel: f.rel,
179
+ n: maxLine,
180
+ note: `rate control declares a numeric max (line ${maxLine}) and window (line ${windowLine})`,
181
+ });
182
+ }
183
+ else if (exception) {
184
+ scan.conform.push({
185
+ rel: f.rel,
186
+ n: exception,
187
+ note: `rate bound absent but a written exception is on file (line ${exception})`,
188
+ });
189
+ }
190
+ else {
191
+ scan.violate.push({
192
+ rel: f.rel,
193
+ n: windowLine || maxLine || 1,
194
+ note: "rate-limit control present but its bound is not numeric (placeholder) and no written exception on file",
195
+ });
196
+ }
197
+ }
198
+ return scan;
199
+ }
200
+ // ---- detectors ------------------------------------------------------------
201
+ export const R19 = srcDetector("R-19", "R-19 personal data declares retention + a subject-deletion path", "a personal-data table declares a retention field and a delete mechanism exists in the tree", scanR19, "R-19: no personal-data table field matched in the scanned schema — cannot certify retention/erasure. NOT a clean bill.");
202
+ export const R20 = srcDetector("R-20", "R-20 background work is bounded and observable", "a background job declares a numeric per-run bound and logs its outcome", scanR20, "R-20: no background-job handler file matched in the scanned tree — cannot certify bounded/observable jobs. NOT a clean bill.");
203
+ export const R23 = srcDetector("R-23", "R-23 rate + runtime bound declared or written exception", "a rate-limit control declares a numeric max and window, or a written exception is on file", scanR23, "R-23: no rate-limit control referenced in the scanned tree — cannot certify the rate/runtime bound. NOT a clean bill.");
204
+ /** The three, in a form the orchestrator spreads into REGISTRY. */
205
+ export const SOURCE_JOBS_DETECTORS = [
206
+ { detector: R19, ruleId: "R-19", standardPin: SOURCE_STANDARD_PIN },
207
+ { detector: R20, ruleId: "R-20", standardPin: SOURCE_STANDARD_PIN },
208
+ { detector: R23, ruleId: "R-23", standardPin: SOURCE_STANDARD_PIN },
209
+ ];
@@ -0,0 +1,6 @@
1
+ import type { Detector } from "../../engine-types.js";
2
+ import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
3
+ export declare function scanR43(files: SrcFile[]): Scan;
4
+ export declare const R43: Detector;
5
+ /** In the form the orchestrator spreads into REGISTRY. */
6
+ export declare const SOURCE_LIFECYCLE_DETECTORS: SourceDetectorEntry[];