@vantageos/backend-doctor 0.2.0 → 0.3.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 (63) hide show
  1. package/README.md +48 -4
  2. package/dist/args.d.ts +1 -1
  3. package/dist/args.js +19 -1
  4. package/dist/build-stamp.json +2 -2
  5. package/dist/caller-resolution.js +11 -8
  6. package/dist/cli.js +164 -15
  7. package/dist/contract.d.ts +25 -7
  8. package/dist/contract.js +52 -7
  9. package/dist/coverage.d.ts +1 -1
  10. package/dist/coverage.js +2 -2
  11. package/dist/detectors/oracle-axis1.js +5 -4
  12. package/dist/detectors/oracle-axis2.d.ts +6 -0
  13. package/dist/detectors/oracle-axis2.js +32 -16
  14. package/dist/detectors/predicates.js +12 -3
  15. package/dist/detectors/shared.js +3 -2
  16. package/dist/detectors/source/_ast.d.ts +92 -0
  17. package/dist/detectors/source/_ast.js +353 -0
  18. package/dist/detectors/source/_scan.js +4 -3
  19. package/dist/detectors/source/backfill.js +62 -17
  20. package/dist/detectors/source/contract.js +21 -13
  21. package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
  22. package/dist/detectors/source/cross-tenant-write.js +1408 -0
  23. package/dist/detectors/source/guard-helper-flow.d.ts +22 -0
  24. package/dist/detectors/source/guard-helper-flow.js +49 -6
  25. package/dist/detectors/source/identity-by-id.d.ts +80 -0
  26. package/dist/detectors/source/identity-by-id.js +1783 -0
  27. package/dist/detectors/source/identity.d.ts +8 -1
  28. package/dist/detectors/source/identity.js +364 -35
  29. package/dist/detectors/source/jobs.js +13 -2
  30. package/dist/detectors/source/lifecycle.js +14 -2
  31. package/dist/detectors/source/local-identity.d.ts +26 -0
  32. package/dist/detectors/source/local-identity.js +661 -0
  33. package/dist/detectors/source/package-principal.d.ts +9 -0
  34. package/dist/detectors/source/package-principal.js +177 -0
  35. package/dist/detectors/source/r6-shapes.d.ts +20 -0
  36. package/dist/detectors/source/r6-shapes.js +285 -0
  37. package/dist/detectors/source/readbudget.js +291 -0
  38. package/dist/detectors/source/recipient-by-id.d.ts +40 -0
  39. package/dist/detectors/source/recipient-by-id.js +1108 -0
  40. package/dist/detectors/source/refusal.d.ts +14 -0
  41. package/dist/detectors/source/refusal.js +75 -2
  42. package/dist/detectors/source/security.js +455 -26
  43. package/dist/detectors/source/writesafety.js +27 -14
  44. package/dist/detectors/standard/_doc.d.ts +1 -1
  45. package/dist/detectors/standard/_doc.js +5 -4
  46. package/dist/detectors/standard/r41-frontier.js +3 -2
  47. package/dist/detectors/standard/r46-returns-validator.js +3 -2
  48. package/dist/detectors/structural.d.ts +6 -7
  49. package/dist/detectors/structural.js +75 -43
  50. package/dist/not-checkable.d.ts +1 -1
  51. package/dist/not-checkable.js +11 -0
  52. package/dist/registry.d.ts +1 -1
  53. package/dist/registry.js +27 -3
  54. package/dist/rule-class.d.ts +85 -0
  55. package/dist/rule-class.js +218 -0
  56. package/dist/tally.d.ts +13 -5
  57. package/dist/tally.js +14 -6
  58. package/dist/violation-count.d.ts +48 -0
  59. package/dist/violation-count.js +94 -0
  60. package/fixtures/backend-standard-2825122.md +722 -0
  61. package/fixtures/backend-standard-4dcd3e0.md +722 -0
  62. package/fixtures/rule-classes-2825122.json +228 -0
  63. package/package.json +1 -1
@@ -60,7 +60,14 @@ export interface LocalTaint {
60
60
  * RHS (`const t = orgSlug`) is NOT scope evidence on its own — the identifier's
61
61
  * own provenance is resolved one further, bounded, cycle-guarded hop (defect 3);
62
62
  * exhaustion is `cnj`, never a guess. */
63
- export declare function analyzeLocalTaint(h: Handler, local: string, isResolver?: (name: string) => boolean): LocalTaint;
63
+ export declare function analyzeLocalTaint(h: Handler, local: string, isResolver?: ResolverTest, principalOf?: PrincipalTest): LocalTaint;
64
+ /** Is `expr` the principal @vantageos/cloud-identity resolved and the door
65
+ * refused unless ok (package-principal.ts)? Returns the reason, else undefined. */
66
+ export type PrincipalTest = (expr: string) => string | undefined;
67
+ /** Is `name` an identity resolver? `call` is the whole `await name(…)` text,
68
+ * joined across the lines it spans, so a resolver anchored on one of its call
69
+ * arguments can read that argument. */
70
+ export type ResolverTest = (name: string, call: string) => boolean;
64
71
  export declare const R6_GUARD_HELPERS: Readonly<Record<string, GuardHelperKind>>;
65
72
  interface HelperDef {
66
73
  rel: string;
@@ -1,4 +1,9 @@
1
- import { conditionalBoundaries, findHelperDefinitions, judgePredicateFunction, judgeResolverFunction, readHandlerGuards, summarizeValidator, topLevelFunctions, topLevelHelperCalls, } from "./guard-helper-flow.js";
1
+ import { itemCount, siteCount, withCount } from "../../violation-count.js";
2
+ import { parseFile } from "./_ast.js";
3
+ import { conditionalBoundaries, findHelperDefinitions, judgePredicateFunction, judgeResolverFunction, readHandlerGuards, SCOPE_REFINERS, summarizeValidator, topLevelFunctions, topLevelHelperCalls, } from "./guard-helper-flow.js";
4
+ import { IDENTITY_PACKAGE } from "./identity-by-id.js";
5
+ import { packagePrincipals, } from "./package-principal.js";
6
+ import { masterOnlyWrapperCredit, possessionGatedCredit } from "./r6-shapes.js";
2
7
  /** The standard both are pinned against (short hash a9d7877b), §15/§18. */
3
8
  export const IDENTITY_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15/§18";
4
9
  function emptyScan() {
@@ -63,12 +68,12 @@ function decide(ruleId, id, check, scan, emptyReason) {
63
68
  .map((x) => `${x.rel}:${x.n} (${x.note})`)
64
69
  .join("; ");
65
70
  if (nV > 0) {
66
- return {
71
+ return withCount({
67
72
  id,
68
73
  anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
69
74
  state: "non-conforming",
70
75
  verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`,
71
- };
76
+ }, siteCount(scan.violate));
72
77
  }
73
78
  if (nJ > 0) {
74
79
  const reason = `${ruleId}: ${nJ} site(s) could not be judged — ${fmt(scan.cnj)}. This is NOT a clean bill. ${breakdown}`;
@@ -147,7 +152,6 @@ export function splitMutationHandlers(f) {
147
152
  const R6_WRITE = /ctx\.db\.(?:insert|patch|replace|delete)\s*\(/;
148
153
  const R6_IDENTITY_GUARD = /getUserIdentity|resolveOrgId|resolveOrgContext|assertNamespaceAllowed|expectedOrgIdForNamespace|tenantSlug|organizationId|organizationSlug|AUTH_NO_ORG|AUTH_NAMESPACE_DENIED/;
149
154
  const R6_RESOURCE_GUARD = /assertTaskCallerAuthorized|RBAC_DENIED|\.createdBy\s*===|\.assignedTo\s*===|\.orchestratorId\s*===|callerOrchestrator\s*===\s*["']system["']|note\.createdBy/;
150
- const R6_BOUNDARY_ARG = /\b(?:createdBy|orgId|orgSlug|namespace|orchestratorId|ownerId|tenantId)\b/;
151
155
  // ---- WRITE-SITE provenance (blind spot 2) ---------------------------------
152
156
  //
153
157
  // Guard-PRESENCE alone cannot distinguish the historical tenant-write defect
@@ -288,8 +292,21 @@ function destructuredRhs(raw, local) {
288
292
  * RHS (`const t = orgSlug`) is NOT scope evidence on its own — the identifier's
289
293
  * own provenance is resolved one further, bounded, cycle-guarded hop (defect 3);
290
294
  * exhaustion is `cnj`, never a guess. */
291
- export function analyzeLocalTaint(h, local, isResolver) {
292
- return analyzeLocalTaintInner({ ...h, lines: normalizeLines(h.lines) }, local, 0, new Set(), isResolver);
295
+ export function analyzeLocalTaint(h, local, isResolver, principalOf) {
296
+ return analyzeLocalTaintInner({ ...h, lines: normalizeLines(h.lines) }, local, 0, new Set(), isResolver, principalOf);
297
+ }
298
+ /** The RHS starting on `codeLines[i]`, extended over the following lines until
299
+ * an opening `await <name>(` is balanced (bounded; unbalanced -> the line alone).
300
+ * Used ONLY to recognise a resolver call that spans lines — never to widen the
301
+ * scope test of an ordinary RHS. */
302
+ function joinedAwaitedCall(rhs, codeLines, i) {
303
+ if (!/^\s*await\s+[A-Za-z_$][\w$]*\s*\(/.test(rhs))
304
+ return rhs;
305
+ let text = rhs;
306
+ const depthOf = (t) => (t.match(/\(/g) ?? []).length - (t.match(/\)/g) ?? []).length;
307
+ for (let j = i + 1; depthOf(text) > 0 && j < codeLines.length && j <= i + 40; j++)
308
+ text += `\n${codeLines[j].raw}`;
309
+ return depthOf(text) > 0 ? rhs : text;
293
310
  }
294
311
  /** `rhs` is exactly `await <name>(…)` closed on this line (nothing after the
295
312
  * call but `;`): the whole value is that function's return. */
@@ -310,7 +327,11 @@ function wholeAwaitedCall(rhs) {
310
327
  }
311
328
  return null;
312
329
  }
313
- function analyzeLocalTaintInner(h, local, depth, visited, isResolver) {
330
+ function analyzeLocalTaintInner(h, local, depth, visited, isResolver, principalOf) {
331
+ // `local` IS an alias of a checked package principal (`const { principal }
332
+ // = who` after `if (!who.ok) throw`): the verified identity under a name.
333
+ if (principalOf?.(local))
334
+ return { fromArgs: 0, fromScope: 1, matched: 1, cnj: 0, fromResolver: 1 };
314
335
  let fromArgs = 0;
315
336
  let fromScope = 0;
316
337
  let matched = 0;
@@ -319,7 +340,7 @@ function analyzeLocalTaintInner(h, local, depth, visited, isResolver) {
319
340
  const esc = local.replace(/[$]/g, "\\$&");
320
341
  const assign = new RegExp(`(?:^|[^\\w.$])${esc}\\s*(?::[^=\\n]*)?=(?!=)`);
321
342
  const decl = new RegExp(`\\b(?:const|let|var)\\s+${esc}\\b`);
322
- const classify = (rhs) => {
343
+ const classify = (rhs, call = rhs) => {
323
344
  // A BARE IDENTIFIER RHS carries no scope evidence on its own — its NAME
324
345
  // (even `orgSlug`) never counts as scope; the identifier's own provenance
325
346
  // decides, or the scan refuses (defect 3, Eta pole 2).
@@ -334,7 +355,7 @@ function analyzeLocalTaintInner(h, local, depth, visited, isResolver) {
334
355
  }
335
356
  const next = new Set(visited);
336
357
  next.add(local);
337
- const sub = analyzeLocalTaintInner(h, bare, depth + 1, next, isResolver);
358
+ const sub = analyzeLocalTaintInner(h, bare, depth + 1, next, isResolver, principalOf);
338
359
  if (sub.matched === 0)
339
360
  cnj++; // the referenced local has no resolvable assignment.
340
361
  else if (sub.fromScope > 0 && sub.fromArgs === 0)
@@ -348,8 +369,14 @@ function analyzeLocalTaintInner(h, local, depth, visited, isResolver) {
348
369
  // The whole value is the return of a function the tree shows to be an
349
370
  // identity RESOLVER (it returns the verified scope itself and refuses an
350
371
  // absent identity first): scope-derived, whatever arguments it was handed.
351
- const called = isResolver ? wholeAwaitedCall(rhs) : null;
352
- if (called && isResolver?.(called)) {
372
+ const called = isResolver ? wholeAwaitedCall(call) : null;
373
+ if (called && isResolver?.(called, call)) {
374
+ fromScope++;
375
+ fromResolver++;
376
+ return;
377
+ }
378
+ // A member read of a checked package principal (`who.principal.orgId`).
379
+ if (principalOf?.(rhs)) {
353
380
  fromScope++;
354
381
  fromResolver++;
355
382
  return;
@@ -367,10 +394,9 @@ function analyzeLocalTaintInner(h, local, depth, visited, isResolver) {
367
394
  else
368
395
  fromArgs++;
369
396
  };
370
- for (const l of h.lines) {
371
- if (!code(l))
372
- continue;
373
- const raw = l.raw;
397
+ const codeLines = h.lines.filter(code);
398
+ for (let i = 0; i < codeLines.length; i++) {
399
+ const raw = codeLines[i].raw;
374
400
  // The handler's own parameter destructured — the binding IS the argument.
375
401
  if (boundFromHandlerParam(raw, local)) {
376
402
  matched++;
@@ -390,7 +416,7 @@ function analyzeLocalTaintInner(h, local, depth, visited, isResolver) {
390
416
  continue;
391
417
  matched++;
392
418
  const rhs = isAssign ? raw.slice(raw.indexOf("=") + 1) : "";
393
- classify(rhs);
419
+ classify(rhs, joinedAwaitedCall(rhs, codeLines, i));
394
420
  }
395
421
  return fromResolver > 0
396
422
  ? { fromArgs, fromScope, matched, cnj, fromResolver }
@@ -459,6 +485,50 @@ export const R6_GUARD_HELPERS = {
459
485
  requireFleetMaster: "master-only",
460
486
  requireServiceAccount: "master-only",
461
487
  };
488
+ // ---- SCOPE REFINERS at the handler (a local assigned from one) ------------
489
+ //
490
+ // `const scope = await resolveVerifiedPerson(ctx, transportScope, args.verifiedPerson, …)`
491
+ // read as caller-arg-derived (fromScope=0) because an argument is handed to the
492
+ // call — the false R-6 accusation at vantage-peers 45e40c8 missions.ts:232. A
493
+ // SCOPE_REFINERS call (guard-helper-flow.ts) is scope-derived when its
494
+ // definition is READABLE in the scanned tree and the scope it is handed, at
495
+ // `argIndex`, is itself a scope-derived local. Its other arguments never taint
496
+ // its return. (A resolver such as `requireAuthenticatedCaller` stays judged on
497
+ // its BODY by judgeResolverFunction, which now reads a refiner of a pure scope
498
+ // as pure — recurringTasks.ts:216.)
499
+ /** Top-level comma-separated arguments of the first `name(…)` call in `call`. */
500
+ function callArguments(call, name) {
501
+ const at = call.search(new RegExp(`\\b${name}\\s*\\(`));
502
+ if (at === -1)
503
+ return null;
504
+ const open = call.indexOf("(", at);
505
+ const out = [];
506
+ let depth = 0;
507
+ let cur = "";
508
+ for (let i = open; i < call.length; i++) {
509
+ const ch = call[i];
510
+ if ("([{".includes(ch)) {
511
+ depth++;
512
+ if (depth === 1)
513
+ continue;
514
+ }
515
+ else if (")]}".includes(ch)) {
516
+ depth--;
517
+ if (depth === 0) {
518
+ if (cur.trim())
519
+ out.push(cur.trim());
520
+ return out;
521
+ }
522
+ }
523
+ else if (ch === "," && depth === 1) {
524
+ out.push(cur.trim());
525
+ cur = "";
526
+ continue;
527
+ }
528
+ cur += ch;
529
+ }
530
+ return null;
531
+ }
462
532
  const sourceOf = (f) => f.lines.map((l) => l.raw).join("\n");
463
533
  /** Index every definition of an allowlisted helper in the tree. */
464
534
  export function indexGuardHelpers(files) {
@@ -712,6 +782,176 @@ function boundaryMemberRhs(writes, loc) {
712
782
  }
713
783
  return out.length ? out : null;
714
784
  }
785
+ // ---- WHICH boundary field reaches a write from an ARGUMENT -----------------
786
+ //
787
+ // The last-resort accusation ("a boundary field taken from a caller argument
788
+ // with no identity/resource guard") used to fire on ANY handler line carrying a
789
+ // boundary token. vantage-peers 45e40c8 tasks.complete was accused for
790
+ // `createdBy: fixPatternCreatedBy` where `fixPatternCreatedBy = task.assignedTo`
791
+ // and `task = await ctx.db.get(args.taskId)` — a value read off the TARGET
792
+ // resource, server-side, never the caller's argument. The accusation now needs
793
+ // the boundary field's value, AT A WRITE, to come from the caller's arguments:
794
+ // - `field: args.x` directly, or a local whose assignments read `args` (one
795
+ // hop through renames, member reads and destructuring, bounded);
796
+ // - a local object handed to the write (`patch(id, obj)`) carrying the field
797
+ // in its literal or through `obj.field = …`;
798
+ // - `args` (or a rest/alias of it) spread or passed whole, when the args
799
+ // validator declares a boundary field the write does not override.
800
+ // NOT the argument: a value read from the database (`ctx.db.get`/`.query`),
801
+ // the return of an identity resolver, or anything with no `args` in its
802
+ // provenance (`lastActedBy`, a server-computed actor). A local whose assignment
803
+ // cannot be found stays an argument (the old, conservative reading).
804
+ const R6_BOUNDARY_FIELD = "(?:createdBy|orgId|orgSlug|namespace|orchestratorId|ownerId|tenantId)";
805
+ function boundaryFromArgument(h, writes, isResolver, principalOf) {
806
+ const codeLines = h.lines.filter(code);
807
+ const handlerAt = codeLines.findIndex((l) => /\bhandler\b/.test(l.raw));
808
+ const validator = codeLines
809
+ .slice(0, handlerAt === -1 ? 0 : handlerAt)
810
+ .map((l) => l.raw)
811
+ .join("\n");
812
+ const validatorFields = new Set([
813
+ ...validator.matchAll(new RegExp(`\\b(${R6_BOUNDARY_FIELD})\\s*:`, "g")),
814
+ ].map((m) => m[1]));
815
+ const body = codeLines.slice(Math.max(handlerAt, 0)).map((l) => l.raw);
816
+ const bodyText = body.join("\n");
817
+ const esc = (n) => n.replace(/[$]/g, "\\$&");
818
+ // assignments of `local` in the handler body: RHS texts (multi-line object
819
+ // literals and awaited calls joined), plus whether a handler param binds it.
820
+ const assignments = (local) => {
821
+ const out = [];
822
+ const assign = new RegExp(`(?:^|[^\\w.$])${esc(local)}\\s*(?::[^=\\n]*)?=(?!=)`);
823
+ for (let i = 0; i < body.length; i++) {
824
+ const raw = body[i];
825
+ if (boundFromHandlerParam(raw, local))
826
+ return "param";
827
+ const dr = destructuredRhs(raw, local);
828
+ if (dr !== null) {
829
+ out.push(dr);
830
+ continue;
831
+ }
832
+ if (!assign.test(raw))
833
+ continue;
834
+ let rhs = raw.slice(raw.indexOf("=") + 1);
835
+ const depth = (t) => (t.match(/[({[]/g) ?? []).length - (t.match(/[)}\]]/g) ?? []).length;
836
+ for (let j = i + 1; depth(rhs) > 0 && j < body.length && j <= i + 60; j++)
837
+ rhs += `\n${body[j]}`;
838
+ out.push(rhs);
839
+ }
840
+ return out;
841
+ };
842
+ const argFed = (local, seen) => {
843
+ if (local === "args")
844
+ return true;
845
+ if (principalOf?.(local))
846
+ return false; // an alias of a checked principal
847
+ if (seen.has(local) || seen.size > 6)
848
+ return false;
849
+ const next = new Set(seen).add(local);
850
+ const rhss = assignments(local);
851
+ if (rhss === "param")
852
+ return true;
853
+ if (rhss.length === 0)
854
+ return true; // not found: conservative, as before.
855
+ return rhss.some((rhs) => valueFromArgs(rhs, next));
856
+ };
857
+ const valueFromArgs = (expr, seen) => {
858
+ const e = expr
859
+ .trim()
860
+ .replace(/;+\s*$/, "")
861
+ .trim();
862
+ if (/^(?:await\s+)?ctx\.db\.(?:get|query)\b/.test(e))
863
+ return false; // a stored row
864
+ const called = wholeAwaitedCall(e);
865
+ if (called && isResolver(called, e))
866
+ return false; // the verified scope
867
+ if (principalOf?.(e))
868
+ return false; // a checked package principal
869
+ const root = /^([A-Za-z_$][\w$]*)(?:\s*(?:\.|\?\.|\[|$|\s+as\b|!))/.exec(e);
870
+ if (root &&
871
+ /^[A-Za-z_$][\w$]*(?:(?:\.|\?\.)[A-Za-z_$][\w$]*)*(?:\s+as\s+[\w<>[\]|. ]+|!)?$/.test(e))
872
+ return argFed(root[1], seen); // a local, or a member read of one
873
+ if (/\bargs\b/.test(e))
874
+ return true;
875
+ // any other expression: argument-fed only through a local it names.
876
+ for (const m of e.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*(?:\.|\?\.)/g))
877
+ if (m[1] !== "ctx" && argFed(m[1], seen))
878
+ return true;
879
+ return false;
880
+ };
881
+ const objectCarries = (text, seen) => {
882
+ // `field: <value>` up to the next top-level `,` / `}` / line end.
883
+ for (const m of text.matchAll(new RegExp(`(?:^|[{,\\s])(${R6_BOUNDARY_FIELD})\\s*:\\s*([^,}\\n]+)`, "gm")))
884
+ if (valueFromArgs(m[2], seen))
885
+ return true;
886
+ for (const m of text.matchAll(new RegExp(`(?:^|[{,])\\s*(${R6_BOUNDARY_FIELD})\\s*(?=[,}]|$)`, "gm")))
887
+ if (argFed(m[1], seen))
888
+ return true;
889
+ // spreads: `...args`, or `...x` with x an alias/rest of args.
890
+ for (const m of text.matchAll(/\.\.\.\s*([A-Za-z_$][\w$]*)/g)) {
891
+ const x = m[1];
892
+ if (x === "args" || restOfArgs(x)) {
893
+ const pulled = x === "args" ? new Set() : pulledFromArgs(x);
894
+ const overridden = new Set([
895
+ ...text.matchAll(new RegExp(`\\b(${R6_BOUNDARY_FIELD})\\s*[:,}]`, "g")),
896
+ ].map((k) => k[1]));
897
+ if ([...validatorFields].some((fld) => !pulled.has(fld) && !overridden.has(fld)))
898
+ return true;
899
+ }
900
+ else if (localObjectCarries(x, seen))
901
+ return true;
902
+ }
903
+ return false;
904
+ };
905
+ const restOfArgs = (x) => new RegExp(`\\.\\.\\.\\s*${esc(x)}\\s*,?\\s*\\}\\s*=\\s*args\\b`).test(bodyText) ||
906
+ new RegExp(`\\b(?:const|let|var)\\s+${esc(x)}\\s*=\\s*args\\s*;?\\s*$`, "m").test(bodyText);
907
+ const pulledFromArgs = (x) => {
908
+ const m = new RegExp(`\\{([^}]*)\\.\\.\\.\\s*${esc(x)}\\s*,?\\s*\\}\\s*=\\s*args\\b`).exec(bodyText);
909
+ const out = new Set();
910
+ if (m)
911
+ for (const part of m[1].split(",")) {
912
+ const key = part.split(":")[0].split("=")[0].trim();
913
+ if (key)
914
+ out.add(key);
915
+ }
916
+ return out;
917
+ };
918
+ // A local object handed to a write: its literal, and every `obj.field = v`.
919
+ const localObjectCarries = (obj, seen) => {
920
+ if (seen.has(`obj:${obj}`))
921
+ return false;
922
+ const next = new Set(seen).add(`obj:${obj}`);
923
+ const rhss = assignments(obj);
924
+ if (rhss !== "param")
925
+ for (const rhs of rhss)
926
+ if (rhs.trim().startsWith("{") && objectCarries(rhs, next))
927
+ return true;
928
+ const memberSet = new RegExp(`\\b${esc(obj)}\\s*(?:\\.\\s*(${R6_BOUNDARY_FIELD})|\\[\\s*["'\`](${R6_BOUNDARY_FIELD})["'\`]\\s*\\])\\s*=(?!=)\\s*([^;\\n]+)`, "g");
929
+ for (const m of bodyText.matchAll(memberSet))
930
+ if (valueFromArgs(m[3], next))
931
+ return true;
932
+ return false;
933
+ };
934
+ for (const w of writes) {
935
+ if (new RegExp(`\\b${R6_BOUNDARY_FIELD}\\s*:\\s*args\\b`).test(w))
936
+ return true;
937
+ if (objectCarries(w, new Set()))
938
+ return true;
939
+ // the object handed to the write: its last top-level argument.
940
+ const m = /ctx\.db\.(?:insert|patch|replace)\s*\(([\s\S]*)\)\s*;?\s*$/.exec(w.trim());
941
+ const last = m ? m[1].split(",").pop()?.trim() : undefined;
942
+ if (last === "args") {
943
+ if (validatorFields.size > 0)
944
+ return true;
945
+ }
946
+ else if (last && /^[A-Za-z_$][\w$]*$/.test(last)) {
947
+ if (restOfArgs(last) && validatorFields.size > 0)
948
+ return true;
949
+ if (localObjectCarries(last, new Set()))
950
+ return true;
951
+ }
952
+ }
953
+ return false;
954
+ }
715
955
  export function scanR6(files, rootIsConvex = false) {
716
956
  const scan = emptyScan();
717
957
  const helperIndex = indexGuardHelpers(files);
@@ -721,6 +961,11 @@ export function scanR6(files, rootIsConvex = false) {
721
961
  continue;
722
962
  // One shape for every argument read: `args["x"]` → `args.x`.
723
963
  const f = { ...raw, lines: normalizeLines(raw.lines) };
964
+ // Parsed once per file, and only when the file names the identity
965
+ // package: the checked-principal read (package-principal.ts) needs it.
966
+ const parsed = f.lines.some((l) => l.raw.includes(IDENTITY_PACKAGE))
967
+ ? parseFile(f)
968
+ : undefined;
724
969
  for (const h of splitMutationHandlers(f)) {
725
970
  // An internalMutation has NO client caller: its arguments come from
726
971
  // server code (a cron, a scheduler hop, another function), so "a
@@ -742,8 +987,6 @@ export function scanR6(files, rootIsConvex = false) {
742
987
  guard = true;
743
988
  if (R6_SCOPE_RESOLUTION.test(l.raw))
744
989
  resolvesScope = true;
745
- if (R6_BOUNDARY_ARG.test(l.raw))
746
- boundaryArg = true;
747
990
  }
748
991
  if (!writeLine)
749
992
  continue; // not a write handler — R-6 does not govern it.
@@ -766,12 +1009,71 @@ export function scanR6(files, rootIsConvex = false) {
766
1009
  return fileSrc;
767
1010
  };
768
1011
  const resolvedBy = new Map();
769
- const isResolver = (name) => {
1012
+ const isResolver = (name, call) => {
770
1013
  const d = fns.resolver(name, f.rel);
771
- if (d)
772
- resolvedBy.set(name, `${d.rel}:${d.f.line}`);
773
- return !!d;
1014
+ if (d) {
1015
+ resolvedBy.set(name, `identity resolver ${name}() (definition ${d.rel}:${d.f.line}), which returns the verified scope itself and refuses an absent identity on every path`);
1016
+ return true;
1017
+ }
1018
+ const spec = SCOPE_REFINERS[name];
1019
+ if (!spec)
1020
+ return false;
1021
+ const def = fns.lookup(name, f.rel);
1022
+ const arg = callArguments(call, name)?.[spec.argIndex];
1023
+ if (!def || !arg || !/^[A-Za-z_$][\w$]*$/.test(arg) || arg === "args")
1024
+ return false;
1025
+ const t = analyzeLocalTaint(h, arg, isResolver);
1026
+ if (!(t.fromScope > 0 && t.fromArgs === 0))
1027
+ return false;
1028
+ resolvedBy.set(name, `named scope refiner ${name}() (definition ${def.rel}:${def.f.line}) handed the scope-derived local "${arg}"`);
1029
+ return true;
774
1030
  };
1031
+ // The principal @vantageos/cloud-identity resolved, refused unless ok
1032
+ // before the write (package-principal.ts): a verified-identity value.
1033
+ const principals = parsed
1034
+ ? packagePrincipals(parsed, h.name, writeLine)
1035
+ : undefined;
1036
+ const principalOf = principals
1037
+ ? (expr) => {
1038
+ const why = principals.principalOf(expr);
1039
+ if (why)
1040
+ resolvedBy.set(`principal:${why}`, why);
1041
+ return why;
1042
+ }
1043
+ : undefined;
1044
+ // The boundary-from-argument test reads the WRITE's provenance, never a
1045
+ // boundary token anywhere in the handler (see boundaryFromArgument).
1046
+ boundaryArg = boundaryFromArgument(h, writes, isResolver, principalOf);
1047
+ // An awaited call, before the first write, to a function judged an
1048
+ // identity resolver (it refuses an absent identity on every path and
1049
+ // returns the verified scope) IS an identity guard, whatever arguments
1050
+ // it is handed — vantage-peers 45e40c8 tasks.complete resolves its
1051
+ // caller through requireAuthenticatedCaller before writing.
1052
+ let resolverGuard;
1053
+ {
1054
+ const codeLines = h.lines.filter(code);
1055
+ for (let i = 0; i < codeLines.length && !resolverGuard; i++) {
1056
+ if (codeLines[i].n >= writeLine)
1057
+ break;
1058
+ const m = /\bawait\s+([A-Za-z_$][\w$]*)\s*\(/.exec(codeLines[i].raw);
1059
+ if (!m || m[1] === "ctx")
1060
+ continue;
1061
+ const from = codeLines[i].raw.slice(m.index);
1062
+ const call = joinedAwaitedCall(from, codeLines, i);
1063
+ resolvedBy.clear();
1064
+ if (isResolver(m[1], call))
1065
+ resolverGuard = `${[...resolvedBy.values()].join(", ")} called at line ${codeLines[i].n}`;
1066
+ }
1067
+ resolvedBy.clear();
1068
+ }
1069
+ // Resolving the caller proves WHO writes, not that a boundary ARGUMENT
1070
+ // is bound to them: the credit stands only when no argument reaches a
1071
+ // boundary field at a write (a hollow master-only helper that returns the
1072
+ // scope must not excuse `orchestratorId: args.orchestratorId`).
1073
+ if (resolverGuard && !boundaryArg)
1074
+ guard = true;
1075
+ else
1076
+ resolverGuard = undefined;
775
1077
  const credits = [];
776
1078
  // A boundary written straight from an argument a BINDING VALIDATOR
777
1079
  // bound to the verified scope before the write: credited only when
@@ -794,10 +1096,19 @@ export function scanR6(files, rootIsConvex = false) {
794
1096
  }
795
1097
  let bound;
796
1098
  for (const loc of boundaryLocals) {
1099
+ const rhs = boundaryMemberRhs(writes, loc);
1100
+ // Every write of `loc` reads the checked package principal
1101
+ // (`orgId: who.principal.orgId`, `orgId: p.orgId` for an alias p).
1102
+ const viaPrincipal = rhs && principals ? rhs.map((x) => principals.principalOf(x)) : [];
1103
+ if (viaPrincipal.length > 0 && viaPrincipal.every((x) => x)) {
1104
+ anyDerived = true;
1105
+ resolvesScope = true;
1106
+ credits.push(`\`${rhs?.[0]}\` is ${viaPrincipal[0]}`);
1107
+ continue;
1108
+ }
797
1109
  // A value a refusing predicate bound to the caller's scope before
798
1110
  // the write (`if (!P(scope, mission.orgId)) throw`), written
799
1111
  // unchanged: derived from the verified identity through the target.
800
- const rhs = boundaryMemberRhs(writes, loc);
801
1112
  if (rhs) {
802
1113
  bound ??= predicateBoundValues(h, f, writeLine, fns, getSrc);
803
1114
  const by = rhs.map((x) => bound?.get(x));
@@ -809,10 +1120,16 @@ export function scanR6(files, rootIsConvex = false) {
809
1120
  }
810
1121
  }
811
1122
  resolvedBy.clear();
812
- const a = analyzeLocalTaint(h, loc, isResolver);
1123
+ const a = analyzeLocalTaint(h, loc, isResolver, principalOf);
813
1124
  if (a.fromResolver) {
814
1125
  resolvesScope = true;
815
- credits.push(`local "${loc}" is the return of identity resolver ${[...resolvedBy].map(([n, at]) => `${n}() (definition ${at})`).join(", ")}, which returns the verified scope itself and refuses an absent identity on every path`);
1126
+ const by = [...resolvedBy.entries()];
1127
+ const returned = by.filter(([k]) => !k.startsWith("principal:"));
1128
+ const read = by.filter(([k]) => k.startsWith("principal:"));
1129
+ if (returned.length > 0)
1130
+ credits.push(`local "${loc}" is the return of ${returned.map(([, v]) => v).join(", ")}`);
1131
+ if (read.length > 0)
1132
+ credits.push(`local "${loc}" reads ${read.map(([, v]) => v).join(", ")}`);
816
1133
  }
817
1134
  if (a.fromScope > 0) {
818
1135
  anyDerived = true;
@@ -933,7 +1250,7 @@ export function scanR6(files, rootIsConvex = false) {
933
1250
  scan.conform.push({
934
1251
  rel: f.rel,
935
1252
  n: writeLine,
936
- note: `${h.name}: boundary derived from a verified identity / target-resource guard`,
1253
+ note: `${h.name}: boundary derived from a verified identity / target-resource guard${resolverGuard ? ` — ${resolverGuard}` : ""}`,
937
1254
  });
938
1255
  }
939
1256
  else if (boundaryArg) {
@@ -944,11 +1261,23 @@ export function scanR6(files, rootIsConvex = false) {
944
1261
  });
945
1262
  }
946
1263
  else {
947
- scan.cnj.push({
948
- rel: f.rel,
949
- n: writeLine,
950
- note: `${h.name}: writes with no identity/resource guard and no clearly caller-supplied boundary field — deferred, not statically confirmed`,
951
- });
1264
+ // Two shapes read in full from the AST before deferring: a FILE-LOCAL
1265
+ // master-only wrapper dominating the write, and a possession-gated
1266
+ // write of the one row a hashed argument selects (r6-shapes.ts).
1267
+ const credit = masterOnlyWrapperCredit(raw, h.name) ??
1268
+ possessionGatedCredit(raw, h.name);
1269
+ if (credit)
1270
+ scan.conform.push({
1271
+ rel: f.rel,
1272
+ n: writeLine,
1273
+ note: `${h.name}: ${credit}`,
1274
+ });
1275
+ else
1276
+ scan.cnj.push({
1277
+ rel: f.rel,
1278
+ n: writeLine,
1279
+ note: `${h.name}: writes with no identity/resource guard and no clearly caller-supplied boundary field — deferred, not statically confirmed`,
1280
+ });
952
1281
  }
953
1282
  // A violation at a site calling a listed helper whose definition was
954
1283
  // read and NOT honoured says why, so the reader is not left believing
@@ -1033,12 +1362,12 @@ const R16_CHECK = "a refusal throws a ConvexError with a stable UPPER_SNAKE_CASE
1033
1362
  /** Classify an R-6 live probe: a boundary-less write MUST be refused fail-closed. */
1034
1363
  export function classifyR6Live(id, tool, outcome) {
1035
1364
  if (outcome.kind === "accepted") {
1036
- return {
1365
+ return withCount({
1037
1366
  id,
1038
1367
  anchor: `live:${tool}`,
1039
1368
  state: "non-conforming",
1040
1369
  verdict: `R-6 (live): tool "${tool}" ACCEPTED a write with no verified identity/boundary — fail-open.`,
1041
- };
1370
+ }, itemCount(1));
1042
1371
  }
1043
1372
  if (/RBAC|AUTH|ORG|unauthori|denied|required|forbidden/i.test(outcome.message)) {
1044
1373
  return {
@@ -1070,12 +1399,12 @@ export function classifyR16Live(id, payload) {
1070
1399
  };
1071
1400
  }
1072
1401
  if (R16_REFUSAL_KW.test(payload)) {
1073
- return {
1402
+ return withCount({
1074
1403
  id,
1075
1404
  anchor: "live:error-contract",
1076
1405
  state: "non-conforming",
1077
1406
  verdict: `R-16 (live): a triggered refusal carried NO structured code — opaque error contract (${payload.slice(0, 80)}).`,
1078
- };
1407
+ }, itemCount(1));
1079
1408
  }
1080
1409
  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.`;
1081
1410
  return {
@@ -1,3 +1,4 @@
1
+ import { hasJustificationTokenInText } from "../oracle-axis2.js";
1
2
  import { base, code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
2
3
  // ---------------------------------------------------------------------------
3
4
  // R-19 — a table holding personal data declares a retention period AND exposes
@@ -300,11 +301,21 @@ export function scanR20(files) {
300
301
  note: "background job declares no per-run bound (.take/_CAP/limit) — an unbounded run does not ship",
301
302
  });
302
303
  }
304
+ else if (hasJustificationTokenInText(f.lines.map((l) => l.raw).join("\n"))) {
305
+ // judgement retired (task k179jnhx, §23): a bound-but-unlogged job is
306
+ // flagged; the FILE's carried JUSTIFIÉ:/JUSTIFIED: token is the written
307
+ // justification — decided by presence, never a person's opinion of it.
308
+ scan.conform.push({
309
+ rel: f.rel,
310
+ n: boundLine,
311
+ note: "background job bounds its run; no outcome log (console.*) found, but the file carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed",
312
+ });
313
+ }
303
314
  else {
304
- scan.cnj.push({
315
+ scan.violate.push({
305
316
  rel: f.rel,
306
317
  n: boundLine,
307
- note: "background job bounds its run but no outcome log (console.*) found — observability not statically confirmed",
318
+ note: "background job bounds its run but no outcome log (console.*) found, and no written JUSTIFIÉ:/JUSTIFIED: token in the file — observability owes a fix or a recorded justification",
308
319
  });
309
320
  }
310
321
  }
@@ -1,3 +1,4 @@
1
+ import { hasJustificationTokenInText } from "../oracle-axis2.js";
1
2
  import { code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
2
3
  // An ADMIN-PROVISIONABLE resource: a config/template row an admin creates —
3
4
  // profiles, clients, tenants, templates, mappings, filter rules, allowlists.
@@ -114,6 +115,7 @@ export function scanR43(files) {
114
115
  const scan = emptyScan();
115
116
  const dels = deletePaths(files);
116
117
  const { creates, optOutFiles } = adminCreates(files);
118
+ const textByRel = new Map(files.map((f) => [f.rel, f.lines.map((l) => l.raw).join("\n")]));
117
119
  for (const c of creates.values()) {
118
120
  const del = dels.get(c.table);
119
121
  if (del) {
@@ -130,11 +132,21 @@ export function scanR43(files) {
130
132
  note: `admin resource ${c.table} has no delete/GC path but carries a written out-of-scope reason (allow-no-delete-path) in ${c.rel}`,
131
133
  });
132
134
  }
135
+ else if (hasJustificationTokenInText(textByRel.get(c.rel) ?? "")) {
136
+ // judgement retired (task k179jnhx, §23): the file's carried
137
+ // JUSTIFIÉ:/JUSTIFIED: token IS the written out-of-scope reason,
138
+ // decided by presence, never by a person's opinion of soundness.
139
+ scan.conform.push({
140
+ rel: c.rel,
141
+ n: c.n,
142
+ note: `admin resource ${c.table} has no delete/GC path, but ${c.rel} carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed`,
143
+ });
144
+ }
133
145
  else {
134
- scan.cnj.push({
146
+ scan.violate.push({
135
147
  rel: c.rel,
136
148
  n: c.n,
137
- note: `admin resource ${c.table} is create/upsert-provisioned but exposes NO delete/GC path and carries no written out-of-scope reason — admin-resource lifecycle asymmetry (a resource you can create but never remove, orphaning any dependents)`,
149
+ note: `admin resource ${c.table} is create/upsert-provisioned but exposes NO delete/GC path and carries no written out-of-scope reason (allow-no-delete-path / JUSTIFIÉ: / JUSTIFIED:) — admin-resource lifecycle asymmetry owes a fix or a recorded justification`,
138
150
  });
139
151
  }
140
152
  }