@vantageos/backend-doctor 0.3.0 → 0.4.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.
@@ -0,0 +1,491 @@
1
+ // R-54 — a door the fleet service account can reach acts only inside an
2
+ // organisation the transport verified for this call; a call that carries no
3
+ // verified claim is refused, never served as master.
4
+ //
5
+ // Standard bacc54a §4 R-54 and its Detector spec, read literally:
6
+ // red (A) a public door that resolves its caller through the organisation-scope
7
+ // resolver (so the service account resolves to master) and declares no
8
+ // claim argument (verifiedActor / verifiedOrg / verifiedPerson);
9
+ // red (B) a door that declares a claim argument but has no refusal for the
10
+ // claimless service-account call: its claim is optional, and the absence
11
+ // of the claim leaves the call master;
12
+ // green a claim argument together with a refusal for the claimless
13
+ // service-account call, or one of three listed exits:
14
+ // (a) service-account-only by purpose (requireServiceAccount, or
15
+ // requireResolvedCaller with masterOnly AND mcpBoundOnly);
16
+ // (b) not reachable by the service account: the callers resolve only
17
+ // through a verified session (requireOrgAdmin and its siblings, or
18
+ // a bare getUserIdentity read) and the door never reaches the
19
+ // organisation-scope resolver;
20
+ // (c) `fleet-wide: <reason of eight characters or more>` in the door's
21
+ // source, stating why a whole-fleet reach is its purpose;
22
+ // could-not-judge an action whose scope arrives through a run call, a
23
+ // resolver-shaped callee defined outside the scanned tree, a claim
24
+ // argument spread from a validator the scan cannot find, and a door
25
+ // registered through a wrapper whose context is not read here. Never a
26
+ // readable door.
27
+ //
28
+ // WHAT A REFUSAL IS. A `throw` (or a `return` of an object that says it refuses)
29
+ // whose PATH CONDITION holds both the service-account state and the claim's
30
+ // absence: every enclosing `if` of the throw contributes its condition (its
31
+ // negation for an `else`), split on `&&` (on `||` for a negation). The atoms are
32
+ // read by shape, not by spelling:
33
+ // service-account state `scope.isMaster`, `isMcpBoundMaster(scope)`,
34
+ // `scope.masterSource === "service-account"`, a
35
+ // `serviceAccount`-named value;
36
+ // claim absence `X === undefined`, `X == null`, `!X`, `typeof X ===
37
+ // "undefined"` — or the negation of `X !== undefined`,
38
+ // `X != null`, `X` — where X is CLAIM-DERIVED: it names
39
+ // verifiedActor / verifiedOrg / verifiedPerson, a
40
+ // parameter called `claim` or `verified*`, or a local
41
+ // initialised from a claim-derived value (to a fixpoint).
42
+ // A path that also requires the caller NOT to be the service account (`!isMaster`)
43
+ // refuses the other population, so it is not a claimless-service-account refusal.
44
+ // A claim that is mandatory for every caller (absence alone refuses) is bound.
45
+ //
46
+ // WHAT IS NOT READ. R-54 clause 2 (the claim confines every row the door reads or
47
+ // writes) is not decided here: which rows a handler touches, and against which
48
+ // tenant, is R-6 / R-52 / R-53 clause 2 (the target check). This detector decides
49
+ // that a verified claim exists to confine to, and that its absence is refused.
50
+ import * as ts from "typescript";
51
+ import { calledNames, indexFunctions, lineOf, parseFile, registrations, unwrap, walk, } from "./_ast.js";
52
+ import { decide, emptyScan, isJudgeable, } from "./_scan.js";
53
+ export const R54_ID = "R-54 a door the service account can reach acts inside a transport-verified organisation; a claimless call is refused, never served as master";
54
+ const R54_CHECK = "a door resolving its caller through the organisation-scope resolver declares a verified-claim argument and refuses the call that is the service account and carries no claim, or is service-account-only, session-only, or declared fleet-wide";
55
+ export const R54_STANDARD_PIN = "vantagepeers/backend-standard/backend-standard.md @ bacc54a53d5f6f9f20e5254908e2b46807902faa §4 R-54";
56
+ const CLAIMS = ["verifiedActor", "verifiedOrg", "verifiedPerson"];
57
+ const PUBLIC_BUILDERS = new Set([
58
+ "query",
59
+ "mutation",
60
+ "action",
61
+ ]);
62
+ const SCOPE_RESOLVER = "withOrgScope";
63
+ /** `fleet-wide: <reason>` — the reason must be a real sentence, not a token. */
64
+ export const FLEET_WIDE_MARKER = /fleet-wide:\s*(\S[^\n]{7,})/;
65
+ /** A callee name that looks like it resolves a caller / scope. */
66
+ const RESOLVER_SHAPED = /^(require|resolve|get|with|assert|check)[A-Za-z]*(Scope|Caller|Principal|Identity|Reader|Actor|Auth)[A-Za-z]*$/;
67
+ /** Session-only gates: the caller is a verified session, never the service account. */
68
+ const SESSION_GATES = new Set([
69
+ "requireOrgAdmin",
70
+ "requireOperatorAdminToCreateOrg",
71
+ "resolveOperatorAdmin",
72
+ ]);
73
+ const SERVICE_STATE = /\bisMaster\b|\bisMcpBoundMaster\b|masterSource\s*===?\s*["']service-account["']|\bisServiceAccount\b|serviceAccount/i;
74
+ const REFUSAL_RETURN = /refus|denied|unauthori[sz]ed|RBAC/i;
75
+ // ---- args ------------------------------------------------------------------
76
+ /** Top-level `const NAME = { … }` / `v.object({ … })` initialiser text, tree-wide. */
77
+ function topLevelConsts(ps) {
78
+ const m = new Map();
79
+ for (const p of ps)
80
+ for (const st of p.sf.statements)
81
+ if (ts.isVariableStatement(st))
82
+ for (const d of st.declarationList.declarations)
83
+ if (ts.isIdentifier(d.name) && d.initializer)
84
+ m.set(d.name.text, d.initializer.getText(p.sf));
85
+ return m;
86
+ }
87
+ /** The registration's config object literal (`{ args, handler, … }`). */
88
+ function configOf(r) {
89
+ for (let x = r.handler.parent; x; x = x.parent)
90
+ if (ts.isObjectLiteralExpression(x))
91
+ return x;
92
+ return undefined;
93
+ }
94
+ function readArgs(r, p, consts) {
95
+ const out = { claims: [], unresolved: [] };
96
+ const cfg = configOf(r);
97
+ const prop = cfg?.properties.find((x) => x.name && ts.isIdentifier(x.name) && x.name.text === "args");
98
+ if (!prop || !ts.isPropertyAssignment(prop))
99
+ return out;
100
+ const seen = new Set();
101
+ const visit = (text, depth) => {
102
+ for (const c of CLAIMS)
103
+ if (new RegExp(`\\b${c}\\s*:`).test(text) && !out.claims.includes(c))
104
+ out.claims.push(c);
105
+ for (const m of text.matchAll(/\.\.\.\s*([A-Za-z_$][\w$]*)/g)) {
106
+ const n = m[1];
107
+ if (seen.has(n))
108
+ continue;
109
+ seen.add(n);
110
+ const body = consts.get(n);
111
+ if (body !== undefined && depth < 4)
112
+ visit(body, depth + 1);
113
+ else
114
+ out.unresolved.push(n);
115
+ }
116
+ };
117
+ const init = unwrap(prop.initializer);
118
+ if (ts.isIdentifier(init)) {
119
+ const body = consts.get(init.text);
120
+ if (body === undefined)
121
+ out.unresolved.push(init.text);
122
+ else
123
+ visit(body, 1);
124
+ }
125
+ else
126
+ visit(init.getText(p.sf), 0);
127
+ return out;
128
+ }
129
+ function closureOf(handler, idx) {
130
+ const nodes = [handler];
131
+ const calls = new Set();
132
+ const undefinedCalls = new Set();
133
+ const seen = new Set();
134
+ const queue = [handler];
135
+ while (queue.length) {
136
+ const n = queue.pop();
137
+ for (const c of calledNames(n)) {
138
+ calls.add(c);
139
+ if (seen.has(c))
140
+ continue;
141
+ seen.add(c);
142
+ const defs = idx.get(c);
143
+ if (defs === undefined) {
144
+ undefinedCalls.add(c);
145
+ continue;
146
+ }
147
+ for (const d of defs) {
148
+ nodes.push(d.fn);
149
+ queue.push(d.fn);
150
+ }
151
+ }
152
+ }
153
+ return { nodes, calls, undefinedCalls };
154
+ }
155
+ // ---- claim refusal -----------------------------------------------------------
156
+ const isClaimName = (n) => /^verified(Actor|Org|Person)$/.test(n) || /^(claim|verified\w*)$/i.test(n);
157
+ /** A callee that turns a claim into a value which is undefined exactly when the
158
+ * claim is absent (`resolveVerifiedOrg`, `resolveDoorVerifiedOrg`). A resolver
159
+ * that returns a SCOPE whatever the claim (`resolveVerifiedPerson` hands back the
160
+ * transport scope when no person is carried) is not one: its result is never
161
+ * undefined, so testing it says nothing about the claim. */
162
+ const CLAIM_RESOLVER = /^resolve(Door)?Verified(Org|Actor)$|Claim$/;
163
+ /** Is `e` the claim itself, or a value that is undefined exactly when the claim
164
+ * is absent? The claim; a property / element chain on it; a ternary, `??`, `||`
165
+ * or `&&` with a claim-derived operand; a claim resolver called on it. A value
166
+ * merely COMPUTED FROM the claim (a row read by its agent ID) is not: its absence
167
+ * is not the claim's absence. */
168
+ function isClaimValue(e, tainted) {
169
+ const x = unwrap(e);
170
+ if (ts.isIdentifier(x))
171
+ return tainted.has(x.text) || isClaimName(x.text);
172
+ if (ts.isPropertyAccessExpression(x))
173
+ return (/^verified(Actor|Org|Person)$/.test(x.name.text) ||
174
+ isClaimValue(x.expression, tainted));
175
+ if (ts.isElementAccessExpression(x))
176
+ return isClaimValue(x.expression, tainted);
177
+ if (ts.isConditionalExpression(x))
178
+ return (isClaimValue(x.whenTrue, tainted) || isClaimValue(x.whenFalse, tainted));
179
+ if (ts.isBinaryExpression(x)) {
180
+ const op = x.operatorToken.kind;
181
+ if (op === ts.SyntaxKind.QuestionQuestionToken ||
182
+ op === ts.SyntaxKind.BarBarToken ||
183
+ op === ts.SyntaxKind.AmpersandAmpersandToken)
184
+ return isClaimValue(x.left, tainted) || isClaimValue(x.right, tainted);
185
+ return false;
186
+ }
187
+ if (ts.isCallExpression(x)) {
188
+ const callee = unwrap(x.expression);
189
+ const name = ts.isIdentifier(callee)
190
+ ? callee.text
191
+ : ts.isPropertyAccessExpression(callee)
192
+ ? callee.name.text
193
+ : undefined;
194
+ return (name !== undefined &&
195
+ CLAIM_RESOLVER.test(name) &&
196
+ x.arguments.some((a) => isClaimValue(a, tainted)));
197
+ }
198
+ return false;
199
+ }
200
+ function claimDerivedLocals(fn) {
201
+ const tainted = new Set();
202
+ if (ts.isFunctionLike(fn) &&
203
+ "parameters" in fn &&
204
+ Array.isArray(fn.parameters))
205
+ for (const prm of fn.parameters)
206
+ if (ts.isIdentifier(prm.name) && isClaimName(prm.name.text))
207
+ tainted.add(prm.name.text);
208
+ for (let pass = 0; pass < 4; pass++) {
209
+ let grew = false;
210
+ walk(fn, (x) => {
211
+ if (!ts.isVariableDeclaration(x) || !x.initializer)
212
+ return;
213
+ const names = [];
214
+ if (ts.isIdentifier(x.name))
215
+ names.push(x.name.text);
216
+ else if (ts.isObjectBindingPattern(x.name))
217
+ for (const e of x.name.elements)
218
+ if (ts.isIdentifier(e.name))
219
+ names.push(e.name.text);
220
+ const fromDestructure = ts.isObjectBindingPattern(x.name) &&
221
+ x.name.elements.some((e) => ts.isIdentifier(e.name) &&
222
+ /^verified(Actor|Org|Person)$/.test(e.name.text));
223
+ if (fromDestructure || isClaimValue(x.initializer, tainted))
224
+ for (const n of names)
225
+ if (!tainted.has(n)) {
226
+ tainted.add(n);
227
+ grew = true;
228
+ }
229
+ });
230
+ if (!grew)
231
+ break;
232
+ }
233
+ return tainted;
234
+ }
235
+ /** Classify ONE atom that holds with truth value `holds` in the path. */
236
+ function classify(atom, holds, claimDerived) {
237
+ const a = unwrap(atom);
238
+ if (ts.isPrefixUnaryExpression(a) &&
239
+ a.operator === ts.SyntaxKind.ExclamationToken)
240
+ return classify(a.operand, !holds, claimDerived);
241
+ if (ts.isBinaryExpression(a)) {
242
+ const op = a.operatorToken.kind;
243
+ const eq = op === ts.SyntaxKind.EqualsEqualsEqualsToken ||
244
+ op === ts.SyntaxKind.EqualsEqualsToken;
245
+ const ne = op === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
246
+ op === ts.SyntaxKind.ExclamationEqualsToken;
247
+ if (eq || ne) {
248
+ const l = unwrap(a.left);
249
+ const r = unwrap(a.right);
250
+ const nullish = (e) => (ts.isIdentifier(e) && e.text === "undefined") ||
251
+ e.kind === ts.SyntaxKind.NullKeyword;
252
+ const bool = (e) => e.kind === ts.SyntaxKind.TrueKeyword
253
+ ? true
254
+ : e.kind === ts.SyntaxKind.FalseKeyword
255
+ ? false
256
+ : undefined;
257
+ if (bool(r) !== undefined || bool(l) !== undefined) {
258
+ const lit = (bool(r) ?? bool(l));
259
+ const subject = bool(r) !== undefined ? l : r;
260
+ return classify(subject, (eq ? holds : !holds) === lit, claimDerived);
261
+ }
262
+ if (nullish(r) || nullish(l)) {
263
+ const subject = nullish(r) ? l : r;
264
+ if (claimDerived(subject)) {
265
+ // X === undefined holds => absent; X !== undefined holds => present
266
+ const absent = eq ? holds : !holds;
267
+ return absent ? "claim-absent" : "claim-present";
268
+ }
269
+ }
270
+ if (ts.isPropertyAccessExpression(l) &&
271
+ l.name.text === "masterSource" &&
272
+ ts.isStringLiteralLike(r) &&
273
+ r.text === "service-account") {
274
+ const is = eq ? holds : !holds;
275
+ return is ? "service" : "not-service";
276
+ }
277
+ }
278
+ return "other";
279
+ }
280
+ const text = a.getText();
281
+ if (claimDerived(a))
282
+ return holds ? "claim-present" : "claim-absent";
283
+ if (SERVICE_STATE.test(text))
284
+ return holds ? "service" : "not-service";
285
+ return "other";
286
+ }
287
+ /** Split `e` into the atoms that ALL hold when `e` is true (&&) or when it is
288
+ * false (||); anything else is one opaque atom. */
289
+ function atomsOf(e, holds) {
290
+ const x = unwrap(e);
291
+ if (ts.isBinaryExpression(x)) {
292
+ const op = x.operatorToken.kind;
293
+ if (holds && op === ts.SyntaxKind.AmpersandAmpersandToken)
294
+ return [...atomsOf(x.left, true), ...atomsOf(x.right, true)];
295
+ if (!holds && op === ts.SyntaxKind.BarBarToken)
296
+ return [...atomsOf(x.left, false), ...atomsOf(x.right, false)];
297
+ }
298
+ if (ts.isPrefixUnaryExpression(x) &&
299
+ x.operator === ts.SyntaxKind.ExclamationToken)
300
+ return atomsOf(x.operand, !holds);
301
+ return [{ atom: x, holds }];
302
+ }
303
+ function pathFacts(t, fn, claimDerived) {
304
+ const facts = new Set();
305
+ for (let child = t, up = t.parent; up && child !== fn; child = up, up = up.parent) {
306
+ if (ts.isIfStatement(up)) {
307
+ const inThen = up.thenStatement === child;
308
+ const inElse = up.elseStatement === child;
309
+ if (!inThen && !inElse)
310
+ continue;
311
+ for (const { atom, holds } of atomsOf(up.expression, inThen))
312
+ facts.add(classify(atom, holds, claimDerived));
313
+ }
314
+ }
315
+ return facts;
316
+ }
317
+ function refusesClaimless(closure) {
318
+ for (const fn of closure.nodes) {
319
+ const tainted = claimDerivedLocals(fn);
320
+ const claimDerived = (e) => isClaimValue(e, tainted);
321
+ let found = false;
322
+ walk(fn, (x) => {
323
+ if (found)
324
+ return;
325
+ const refusal = ts.isThrowStatement(x) ||
326
+ (ts.isReturnStatement(x) &&
327
+ x.expression !== undefined &&
328
+ REFUSAL_RETURN.test(x.expression.getText()));
329
+ if (!refusal)
330
+ return;
331
+ const facts = pathFacts(x, fn, claimDerived);
332
+ // The path must say "no claim", optionally "and the caller is the service
333
+ // account", and nothing else: a refusal that also needs some other
334
+ // condition (a flag, a name, a present claim) refuses a narrower call.
335
+ if (!facts.has("claim-absent"))
336
+ return;
337
+ for (const f of facts)
338
+ if (f !== "claim-absent" && f !== "service")
339
+ return;
340
+ found = true;
341
+ });
342
+ if (found)
343
+ return true;
344
+ }
345
+ return false;
346
+ }
347
+ const isConvexFile = (f) => /(^|\/)convex\//.test(f.rel);
348
+ function statementOf(n) {
349
+ let x = n;
350
+ while (x.parent && !ts.isSourceFile(x.parent))
351
+ x = x.parent;
352
+ return x;
353
+ }
354
+ export function scanR54(files) {
355
+ const scan = emptyScan();
356
+ scan.notApplicable = [];
357
+ scan.doors = 0;
358
+ const ps = files
359
+ .filter((f) => isJudgeable(f) && isConvexFile(f))
360
+ .map(parseFile);
361
+ const idx = indexFunctions(ps);
362
+ const consts = topLevelConsts(ps);
363
+ for (const p of ps) {
364
+ for (const r of registrations(p)) {
365
+ if (!PUBLIC_BUILDERS.has(r.builder))
366
+ continue;
367
+ scan.doors++;
368
+ const site = (note) => ({
369
+ rel: r.rel,
370
+ n: r.line,
371
+ note: `${r.name}: ${note}`,
372
+ });
373
+ const stmt = statementOf(r.handler);
374
+ const fullText = p.sf.text.slice(stmt.getFullStart(), stmt.getEnd());
375
+ const closure = closureOf(r.handler, idx);
376
+ const args = readArgs(r, p, consts);
377
+ const marker = FLEET_WIDE_MARKER.exec(fullText);
378
+ if (marker) {
379
+ scan.conform.push(site(`exit (c): declared fleet-wide — ${marker[1].trim().slice(0, 160)}`));
380
+ continue;
381
+ }
382
+ const serviceOnly = closure.calls.has("requireServiceAccount") ||
383
+ closureHasMasterOnlyResolved(closure);
384
+ if (serviceOnly) {
385
+ scan.conform.push(site("exit (a): service-account-only by purpose (requireServiceAccount / requireResolvedCaller masterOnly+mcpBoundOnly)"));
386
+ continue;
387
+ }
388
+ const reaches = closure.calls.has(SCOPE_RESOLVER);
389
+ if (!reaches) {
390
+ const session = [...closure.calls].some((c) => SESSION_GATES.has(c)) ||
391
+ closureReadsIdentity(closure);
392
+ if (session) {
393
+ scan.conform.push(site("exit (b): callers resolve only through a verified session; the door never reaches the organisation-scope resolver"));
394
+ continue;
395
+ }
396
+ if (r.builder === "action" && closureCallsRun(closure)) {
397
+ scan.cnj.push(site("caller scope arrives through a run call (ctx.runQuery / ctx.runMutation); its resolution is not read here"));
398
+ continue;
399
+ }
400
+ const foreign = [...closure.undefinedCalls].filter((c) => RESOLVER_SHAPED.test(c));
401
+ if (foreign.length) {
402
+ scan.cnj.push(site(`caller resolution delegated to ${foreign.join(", ")}, defined outside the scanned tree`));
403
+ continue;
404
+ }
405
+ scan.notApplicable.push(`${r.rel}:${r.name}`);
406
+ continue;
407
+ }
408
+ // The door reaches the scope resolver: the service account is master here.
409
+ if (args.claims.length === 0) {
410
+ if (args.unresolved.length) {
411
+ scan.cnj.push(site(`args spread from ${args.unresolved.join(", ")}, which the scan cannot find; a claim argument may be among them`));
412
+ continue;
413
+ }
414
+ scan.violate.push(site("(A) resolves its caller through the organisation-scope resolver (the service account is master here) and declares no claim argument (verifiedActor / verifiedOrg / verifiedPerson)"));
415
+ continue;
416
+ }
417
+ if (refusesClaimless(closure)) {
418
+ scan.conform.push(site(`claim-bound: accepts ${args.claims.join(", ")} and refuses the claimless service-account call`));
419
+ continue;
420
+ }
421
+ scan.violate.push(site(`(B) the claim is optional: accepts ${args.claims.join(", ")} but no branch refuses the claimless service-account call, so the absence of a claim leaves the call master`));
422
+ }
423
+ // A door registered through a wrapper is not read: say so, never skip it.
424
+ for (const st of p.sf.statements) {
425
+ if (!ts.isVariableStatement(st))
426
+ continue;
427
+ for (const d of st.declarationList.declarations) {
428
+ if (!d.initializer || !ts.isIdentifier(d.name))
429
+ continue;
430
+ const init = unwrap(d.initializer);
431
+ if (!ts.isCallExpression(init))
432
+ continue;
433
+ const callee = init.expression;
434
+ if (!ts.isIdentifier(callee))
435
+ continue;
436
+ if (/^(query|mutation|action|internal\w+|httpAction)$/.test(callee.text))
437
+ continue;
438
+ const cfg = init.arguments[0];
439
+ if (!cfg || !ts.isObjectLiteralExpression(cfg))
440
+ continue;
441
+ const hasHandler = cfg.properties.some((x) => x.name && ts.isIdentifier(x.name) && x.name.text === "handler");
442
+ if (!hasHandler)
443
+ continue;
444
+ scan.cnj.push({
445
+ rel: p.rel,
446
+ n: lineOf(d),
447
+ note: `${d.name.text}: registered through the wrapper ${callee.text}; the caller resolution lives in the wrapper's context, which is not read here`,
448
+ });
449
+ }
450
+ }
451
+ }
452
+ return scan;
453
+ }
454
+ /** `ctx.runQuery(...)` / `ctx.runMutation(...)` are method calls, which
455
+ * calledNames (plain identifiers only) does not see. */
456
+ function closureCallsRun(c) {
457
+ return c.nodes.some((n) => /\.run(Query|Mutation)\s*\(/.test(n.getText()));
458
+ }
459
+ /** `ctx.auth.getUserIdentity()` is a method call; calledNames sees plain
460
+ * identifiers only, so read the closure's text for the identity read. */
461
+ function closureReadsIdentity(c) {
462
+ return c.nodes.some((n) => /\.getUserIdentity\s*\(/.test(n.getText()));
463
+ }
464
+ function closureHasMasterOnlyResolved(c) {
465
+ for (const n of c.nodes) {
466
+ let hit = false;
467
+ walk(n, (x) => {
468
+ if (ts.isCallExpression(x) &&
469
+ ts.isIdentifier(unwrap(x.expression)) &&
470
+ unwrap(x.expression).text === "requireResolvedCaller") {
471
+ const t = x.arguments.map((a) => a.getText()).join(",");
472
+ if (/masterOnly/.test(t) && /mcpBoundOnly/.test(t))
473
+ hit = true;
474
+ }
475
+ });
476
+ if (hit)
477
+ return true;
478
+ }
479
+ return false;
480
+ }
481
+ export const R54 = {
482
+ id: R54_ID,
483
+ run: (tree) => {
484
+ const s = scanR54(tree.files);
485
+ const empty = `R-54: no applicable site — ${s.doors} public door(s) read in ${tree.files.filter((f) => isJudgeable(f) && isConvexFile(f)).length} convex file(s); ${s.notApplicable.length} resolve no caller at all (CR-1 / R-6 govern them) and none reaches the organisation-scope resolver or an exit. NOT a clean bill.`;
486
+ return decide("R-54", R54_ID, R54_CHECK, s, empty);
487
+ },
488
+ };
489
+ export const SOURCE_SERVICE_ACCOUNT_CLAIM_DETECTORS = [
490
+ { detector: R54, ruleId: "R-54", standardPin: R54_STANDARD_PIN },
491
+ ];
@@ -1,6 +1,6 @@
1
1
  import type { Detector, DetectorResult } from "../../engine-types.js";
2
2
  /** The vendored-fixture basename used as the anchor file for every doc site. */
3
- export declare const STANDARD_FILE = "backend-standard-2825122.md";
3
+ export declare const STANDARD_FILE = "backend-standard-bacc54a.md";
4
4
  export declare const VENDORED_STANDARD_PATH: string;
5
5
  /** The standard all four are pinned against (short hash a9d7877b). */
6
6
  export declare const STANDARD_DOC_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) \u00A714/\u00A711/\u00A710/\u00A73";
@@ -2,7 +2,7 @@
2
2
  // detectors that read THE STANDARD ITSELF as their material — R-42, R-15, R-32,
3
3
  // R-39. Unlike the source-tree detectors (which read convex/ via `tree.files`)
4
4
  // and the oracle detectors (which read the CSV), these read the VENDORED standard
5
- // fixture `fixtures/backend-standard-2825122.md` — the material at the pin. The
5
+ // fixture `fixtures/backend-standard-bacc54a.md` — the material at the pin. The
6
6
  // engine's contract types are DERIVED from engine-types.ts, never re-spelled.
7
7
  //
8
8
  // THE LAW (the one that caught R-5): every predicate is derived from the ACTUAL
@@ -14,7 +14,7 @@ import { dirname, join } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { itemCount, withCount } from "../../violation-count.js";
16
16
  /** The vendored-fixture basename used as the anchor file for every doc site. */
17
- export const STANDARD_FILE = "backend-standard-2825122.md";
17
+ export const STANDARD_FILE = "backend-standard-bacc54a.md";
18
18
  /** The vendored standard at the pin — DATA on the same footing as the oracle CSV.
19
19
  * Resolved locally (not via coverage.ts) so this module carries no import cycle
20
20
  * with registry.ts. Same file coverage.ts's VENDORED_STANDARD_PATH points at. */
@@ -28,6 +28,7 @@ import { STANDARD_FILE } from "./detectors/standard/_doc.js";
28
28
  */
29
29
  export const NOT_MECHANICALLY_CHECKABLE = {
30
30
  "R-48": "R-48 authority-totality detector pending — task k17chpr4kzp399j3s7r7edpm4s8cscbz; text pinned at 4bcd605 so coverage cannot pretend 47 is complete. No mechanical control yet; R-48 is enumerated and visible, never silently dropped.",
31
+ "R-57": "R-57 no local identity primitive is MEASURED, under the doctor-owned id CR-2 (src/detectors/source/local-identity.ts, PR #61): the standard at bacc54a numbers as R-57 the rule CR-2 already enforces. Its count is reported once, under the key CR-2, because the vantage-peers deploy-gate baseline reads CR-2; reporting it under both ids would count one defect as two rules. Renaming CR-2 to R-57 in the evidence output is a follow-up that must move that baseline in the same delivery. R-57 is enumerated and visible here, never silently dropped, and never reported present.",
31
32
  };
32
33
  /**
33
34
  * One `could-not-judge` DetectorResult per NOT_MECHANICALLY_CHECKABLE rule, so
@@ -1,6 +1,6 @@
1
1
  import type { Detector } from "./engine-types.js";
2
2
  /** The standard both pins the whole wave against. */
3
- export declare const STANDARD_PIN = "vantagepeers/backend-standard/backend-standard.md @ 2825122a3ab1e4ddb4b955cfb136b7e8ab04f8f3 (elpiarthera/ElPi-Corp, merge of PR #228 on origin/main, after #226 at 9f7d789)";
3
+ export declare const STANDARD_PIN = "vantagepeers/backend-standard/backend-standard.md @ bacc54a53d5f6f9f20e5254908e2b46807902faa (elpiarthera/ElPi-Corp main; the door-audit rules R-54..R-57, merged as ElPi-Corp #239 = 7d7df89 before the 2026-10-10 history rewrite, same blob 85d8530e)";
4
4
  export interface RegistryEntry {
5
5
  detector: Detector;
6
6
  ruleId: string;
package/dist/registry.js CHANGED
@@ -7,15 +7,18 @@ import { PER_ROW_PREDICATES } from "./detectors/predicates.js";
7
7
  import { makeOracleDetector } from "./detectors/shared.js";
8
8
  import { SOURCE_BACKFILL_DETECTORS } from "./detectors/source/backfill.js";
9
9
  import { SOURCE_CONTRACT_DETECTORS } from "./detectors/source/contract.js";
10
+ import { SOURCE_CREDENTIAL_HYGIENE_DETECTORS } from "./detectors/source/credential-hygiene.js";
10
11
  import { SOURCE_CROSS_TENANT_WRITE_DETECTORS } from "./detectors/source/cross-tenant-write.js";
11
12
  import { IDENTITY_DETECTORS } from "./detectors/source/identity.js";
12
13
  import { SOURCE_IDENTITY_BY_ID_DETECTORS } from "./detectors/source/identity-by-id.js";
13
14
  import { SOURCE_ISOLATION_DETECTORS } from "./detectors/source/isolation.js";
14
15
  import { SOURCE_JOBS_DETECTORS } from "./detectors/source/jobs.js";
16
+ import { SOURCE_NARROW_THEN_BOUND_DETECTORS } from "./detectors/source/narrow-then-bound.js";
15
17
  import { SOURCE_LIFECYCLE_DETECTORS } from "./detectors/source/lifecycle.js";
16
18
  import { SOURCE_READBUDGET_DETECTORS } from "./detectors/source/readbudget.js";
17
19
  import { SOURCE_REFUSAL_DETECTORS } from "./detectors/source/refusal.js";
18
20
  import { SOURCE_SECURITY_DETECTORS } from "./detectors/source/security.js";
21
+ import { SOURCE_SERVICE_ACCOUNT_CLAIM_DETECTORS } from "./detectors/source/service-account-claim.js";
19
22
  import { SOURCE_WRITE_ISOLATION_DETECTORS } from "./detectors/source/write-isolation.js";
20
23
  import { SOURCE_WRITESAFETY_DETECTORS } from "./detectors/source/writesafety.js";
21
24
  import { R41, SIBLING_PIN } from "./detectors/standard/r41-frontier.js";
@@ -25,7 +28,7 @@ import { makeR14, makeR38 } from "./detectors/structural.js";
25
28
  import { STATE_RANK } from "./state-rank.js";
26
29
  import { countOf, sumCounts, withCount } from "./violation-count.js";
27
30
  /** The standard both pins the whole wave against. */
28
- export const STANDARD_PIN = "vantagepeers/backend-standard/backend-standard.md @ 2825122a3ab1e4ddb4b955cfb136b7e8ab04f8f3 (elpiarthera/ElPi-Corp, merge of PR #228 on origin/main, after #226 at 9f7d789)";
31
+ export const STANDARD_PIN = "vantagepeers/backend-standard/backend-standard.md @ bacc54a53d5f6f9f20e5254908e2b46807902faa (elpiarthera/ElPi-Corp main; the door-audit rules R-54..R-57, merged as ElPi-Corp #239 = 7d7df89 before the 2026-10-10 history rewrite, same blob 85d8530e)";
29
32
  /**
30
33
  * Merge two detectors that judge halves of the SAME rule into one result: the
31
34
  * worst state wins (non-conforming > could-not-judge > absent > present, the
@@ -171,6 +174,31 @@ export const REGISTRY = [
171
174
  ...SOURCE_IDENTITY_BY_ID_DETECTORS,
172
175
  ...SOURCE_CONTRACT_DETECTORS,
173
176
  ...IDENTITY_DETECTORS,
177
+ // R-54 — a door the fleet service account can reach acts only inside an
178
+ // organisation the transport verified for this call. Reads the convex source:
179
+ // a public door that resolves its caller through the organisation-scope
180
+ // resolver and declares no claim argument (A), or declares one but never
181
+ // refuses the claimless service-account call (B), is red; a claim-bound door
182
+ // and the three listed exits are present; an unreadable caller resolution is
183
+ // could-not-judge. The door audit's cause 1 (standard bacc54a §4 R-54).
184
+ ...SOURCE_SERVICE_ACCOUNT_CLAIM_DETECTORS,
185
+ // R-55 — a read narrows by every predicate before it bounds the result, and an
186
+ // aggregate is computed over the narrowed set. Reads the convex source: a
187
+ // bounded read (.take / .paginate over a DB query, or a re-bounding .slice)
188
+ // whose rows are narrowed AFTER the bound by a caller-dependent predicate is
189
+ // red; a fill loop or a refusal on a full window between the bound and the
190
+ // narrowing is listed as covered. An unbounded .collect() is R-11 / R-47's.
191
+ // The door audit's cause 4 (standard bacc54a §4 R-55).
192
+ ...SOURCE_NARROW_THEN_BOUND_DETECTORS,
193
+ // R-56 — credentials at rest, refresh tokens, bearers and verification tokens.
194
+ // Reads the convex source: a credential-named column stored without a digest or
195
+ // as the digest of a chosen secret (1); a refresh-token table read by hash with
196
+ // no consuming door (2); a bearer table with no revocation door (3a) or a mint
197
+ // that neither revokes prior bearers nor caps them (3b); a request-supplied
198
+ // commit identifier matched by substring without a full-length check (4) or a
199
+ // response echoing stored content (4e). The door audit's cause 5
200
+ // (standard bacc54a §4 R-56).
201
+ ...SOURCE_CREDENTIAL_HYGIENE_DETECTORS,
174
202
  // The four STANDARD-DOCUMENT detectors (R-42/R-15/R-32/R-39): each reads the
175
203
  // VENDORED standard itself, performs its mechanical/static half in full, and
176
204
  // returns could-not-judge naming the sites a human/gate verdict is owed on.
@@ -26,7 +26,7 @@ import { dirname, join } from "node:path";
26
26
  import { fileURLToPath } from "node:url";
27
27
  import { STANDARD_SHA, VENDORED_STANDARD_PATH } from "./coverage.js";
28
28
  const here = dirname(fileURLToPath(import.meta.url));
29
- export const RULE_CLASS_PATH = join(here, "..", "fixtures", "rule-classes-2825122.json");
29
+ export const RULE_CLASS_PATH = join(here, "..", "fixtures", "rule-classes-bacc54a.json");
30
30
  const GATE_CLASSES = new Set(["mechanical", "judgement"]);
31
31
  export function loadRuleClassFile(path = RULE_CLASS_PATH) {
32
32
  return JSON.parse(readFileSync(path, "utf8"));
@@ -51,7 +51,7 @@ export function parseStandardKinds(path = VENDORED_STANDARD_PATH) {
51
51
  out.set(m[1], m[2]);
52
52
  }
53
53
  }
54
- // Detector-spec `*kind:*` for rules outside the table (none at 2825122: the table lists all 52; kept for a future rule defined outside it).
54
+ // Detector-spec `*kind:*` for rules outside the table (none at bacc54a: the table lists all 56; kept for a future rule defined outside it).
55
55
  const lines = blob.split("\n");
56
56
  let current;
57
57
  for (const line of lines) {