@cosmicdrift/kumiko-guards 0.282.0 → 0.283.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.282.0",
3
+ "version": "0.283.0",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -27,7 +27,7 @@
27
27
  "kumiko-guards": "./src/cli.ts"
28
28
  },
29
29
  "dependencies": {
30
- "@cosmicdrift/kumiko-repo-manifest": "0.282.0",
30
+ "@cosmicdrift/kumiko-repo-manifest": "0.283.0",
31
31
  "ts-morph": "^28.0.0"
32
32
  },
33
33
  "publishConfig": {
@@ -1,4 +1,4 @@
1
- import { type Node, SyntaxKind } from "ts-morph";
1
+ import { type Node, SyntaxKind, VariableDeclarationKind } from "ts-morph";
2
2
 
3
3
  const GENERIC_REASONS = new Set([
4
4
  "",
@@ -29,6 +29,36 @@ export function literalReasonText(node: Node | undefined): string | undefined {
29
29
  return undefined;
30
30
  }
31
31
 
32
+ // Resolves an Identifier to a module-local `const` initializer, so a
33
+ // shared reason constant clears the same way as writing its text inline.
34
+ // No import-boundary resolution, no `let` (reassignment stays unjudged).
35
+ function resolveConstIdentifierText(node: Node): string | undefined {
36
+ if (!node.isKind(SyntaxKind.Identifier)) return undefined;
37
+ const declarations = node.getSymbol()?.getDeclarations() ?? [];
38
+ for (const decl of declarations) {
39
+ if (!decl.isKind(SyntaxKind.VariableDeclaration)) continue;
40
+ if (decl.getSourceFile() !== node.getSourceFile()) continue;
41
+ if (decl.getVariableStatement()?.getDeclarationKind() !== VariableDeclarationKind.Const) {
42
+ continue;
43
+ }
44
+ const text = literalReasonText(decl.getInitializer());
45
+ if (text !== undefined) return text;
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ // Same contract as literalReasonText, plus one hop: an Identifier that
51
+ // resolves to a module-local `const` string/template initializer resolves
52
+ // to that text. An import, a call, a template with substitutions, or a
53
+ // `let`/reassigned binding stays undefined — same "not statically judgeable"
54
+ // convention as literalReasonText.
55
+ export function resolveReasonText(node: Node | undefined): string | undefined {
56
+ if (!node) return undefined;
57
+ const literal = literalReasonText(node);
58
+ if (literal !== undefined) return literal;
59
+ return resolveConstIdentifierText(node);
60
+ }
61
+
32
62
  export function isGenericReason(text: string): boolean {
33
63
  const lowered = text.trim().toLowerCase();
34
64
  if (GENERIC_PREFIXES.some((prefix) => lowered.startsWith(prefix))) {
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.283.0",
4
+ "type": "fix",
5
+ "title": "Escape-Hatch-Declared Guard resolves a module-local const reason instead of forcing duplicate literals",
6
+ "detail": "`_lib/generic-reason.ts`'s `literalReasonText` only accepted a string/template literal in place — an Identifier (even a module-local `const SOME_REASON = \"...\"`) fell through as \"not statically judgeable\" and was rejected exactly like a real placeholder. Every consumer declaring several hooks with the same justification (e.g. publicstatus's five GDPR delete hooks) had to repeat the same reason text literally in each `declareEscapeHatch({ reason })` / `escapeHatch: { reason }` / `unsafeAllTenants: { reason }` / `acknowledgeCrossTenant(reason)` call, because a shared constant made the guard fail.\nThe guard now resolves an Identifier to a module-local `const` initializer (string or non-templated template literal only — no imports, no `let`, no reassignment) via a new `resolveReasonText`, used at all four call sites; `isGenericReason` is unchanged and still applies to the resolved text, so a const resolving to `\"todo\"` is rejected exactly as before. An import, a function call, or a template literal with substitutions still doesn't resolve, and the `unsafe-raw-outside-system-scope` / `system-identity-outside-declared-scope` findings now say why when a nearby `declareEscapeHatch` call's reason is one of those three shapes. Both R2 and R3's base messages now also name `declareEscapeHatch({ reason: \"...\" })` as a direct body statement of a named hook among the allowed ways to clear the finding."
7
+ },
2
8
  {
3
9
  "version": "0.282.0",
4
10
  "type": "fix",
@@ -34,18 +34,24 @@
34
34
  * declaration) is additionally recognized when a statement in its own direct
35
35
  * body — not a nested function's, not inside an `if` — calls the bare
36
36
  * identifier `declareEscapeHatch` with exactly one object-literal argument
37
- * carrying a literal, non-placeholder `reason` (from
37
+ * carrying a non-placeholder `reason` (from
38
38
  * `@cosmicdrift/kumiko-framework/engine`'s `declareEscapeHatch`: a helper
39
39
  * that escalates on a `HandlerContext` handed to it by its caller, rather
40
- * than a `HandlerContext` from its own registration). The declaration does
41
- * not propagate upward: it covers escalations inside that function's own
42
- * body, not the function it is nested inside. This detection is purely
40
+ * than a `HandlerContext` from its own registration). `reason` may be a
41
+ * string/template literal or an Identifier resolving to a module-local
42
+ * `const` with such an initializer (`_lib/generic-reason.ts`'s
43
+ * `resolveReasonText`, shared by R2/R3/R4's four call sites) — so one shared
44
+ * constant covers several declarations without repeating the literal text.
45
+ * An import, a function call, or a template with substitutions stays
46
+ * unresolved and the R2/R3 finding names that explicitly. The declaration
47
+ * does not propagate upward: it covers escalations inside that function's
48
+ * own body, not the function it is nested inside. This detection is purely
43
49
  * lexical — the guard matches on the name `declareEscapeHatch`, not on where
44
50
  * it was imported from, so a same-named local function clears just as well;
45
51
  * consistent with `escapeHatch:` itself, which is likewise never checked for
46
52
  * origin. Referenced-by-variable functions, spread options, computed/string
47
- * keys, and non-literal escapeHatch/reason values are conservatively not
48
- * recognized (miss, don't falsely clear).
53
+ * keys, and a non-literal, non-module-local-const `escapeHatch`/`reason`
54
+ * value are conservatively not recognized (miss, don't falsely clear).
49
55
  *
50
56
  * Empty reasons, `openToAll.personalData` and PII are the framework boot validator's
51
57
  * job (access-declarations.ts), not this guard's — except a `declareEscapeHatch`
@@ -72,7 +78,7 @@ import {
72
78
  type SourceFile,
73
79
  SyntaxKind,
74
80
  } from "ts-morph";
75
- import { isGenericReason, literalReasonText } from "./_lib/generic-reason";
81
+ import { isGenericReason, resolveReasonText } from "./_lib/generic-reason";
76
82
  import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
77
83
 
78
84
  const SCAN: ScanSpec = {
@@ -208,7 +214,8 @@ function findUnsafeRawFindings(
208
214
  line: call.getStartLineNumber(),
209
215
  rule: "unsafe-raw-outside-system-scope",
210
216
  message:
211
- 'unsafeRaw(...) used outside a systemScope feature and outside a handler/hook declaring escapeHatch — declare { escapeHatch: { reason: "..." } } on the handler or hook, declare the feature systemScope, or use ctx.systemDb.acknowledgeCrossTenant(reason) for a scoped read.',
217
+ 'unsafeRaw(...) used outside a systemScope feature and outside a handler/hook declaring escapeHatch — declare { escapeHatch: { reason: "..." } } on the handler or hook, declare the feature systemScope, call declareEscapeHatch({ reason: "..." }) as a direct body statement of a named hook, or use ctx.systemDb.acknowledgeCrossTenant(reason) for a scoped read.' +
218
+ unresolvableDeclareEscapeHatchHint(call),
212
219
  });
213
220
  }
214
221
  return out;
@@ -372,27 +379,84 @@ function findReasonPropertyAssignment(
372
379
  );
373
380
  }
374
381
 
375
- // declareEscapeHatch({ reason: "..." }) as a direct-body statement of a
376
- // standalone function. No boot validator backs this form (unlike the
377
- // escapeHatch: {...} property, which access-declarations.ts checks at boot),
378
- // so an empty/placeholder reason is rejected here rather than left to it.
379
- function isValidDeclareEscapeHatchCall(stmt: Node): boolean {
380
- if (!stmt.isKind(SyntaxKind.ExpressionStatement)) return false;
382
+ // Syntactic extraction only shared by the clearance check below and by
383
+ // the "why" hint on R2/R3, which needs the reason node even when it turns
384
+ // out not to resolve.
385
+ function declareEscapeHatchReasonNode(stmt: Node): Node | undefined {
386
+ if (!stmt.isKind(SyntaxKind.ExpressionStatement)) return undefined;
381
387
  const expr = stmt.getExpression();
382
- if (!expr.isKind(SyntaxKind.CallExpression)) return false;
388
+ if (!expr.isKind(SyntaxKind.CallExpression)) return undefined;
383
389
  const callee = expr.getExpression();
384
390
  if (!callee.isKind(SyntaxKind.Identifier) || callee.getText() !== "declareEscapeHatch") {
385
- return false;
391
+ return undefined;
386
392
  }
387
393
  const args = expr.getArguments();
388
394
  const arg = args[0];
389
- if (args.length !== 1 || !arg?.isKind(SyntaxKind.ObjectLiteralExpression)) return false;
390
- const reasonProp = findReasonPropertyAssignment(arg);
391
- if (!reasonProp) return false;
392
- const reasonText = literalReasonText(reasonProp.getInitializer());
395
+ if (args.length !== 1 || !arg?.isKind(SyntaxKind.ObjectLiteralExpression)) return undefined;
396
+ return findReasonPropertyAssignment(arg)?.getInitializer();
397
+ }
398
+
399
+ // declareEscapeHatch({ reason: "..." }) as a direct-body statement of a
400
+ // standalone function. No boot validator backs this form (unlike the
401
+ // escapeHatch: {...} property, which access-declarations.ts checks at boot),
402
+ // so an empty/placeholder reason is rejected here rather than left to it.
403
+ function isValidDeclareEscapeHatchCall(stmt: Node): boolean {
404
+ const reasonNode = declareEscapeHatchReasonNode(stmt);
405
+ if (!reasonNode) return false;
406
+ const reasonText = resolveReasonText(reasonNode);
393
407
  return reasonText !== undefined && !isGenericReason(reasonText);
394
408
  }
395
409
 
410
+ // The three reason shapes the guard can name a concrete cause for: an
411
+ // import, a function call, or a template with substitutions — none of
412
+ // those are statically judgeable. An ambient/uninitialized identifier
413
+ // (e.g. a `declare const` parameter) stays silently unresolved instead;
414
+ // there is nothing more specific to say about it.
415
+ function isExplicitlyUnresolvableReason(node: Node): boolean {
416
+ if (node.isKind(SyntaxKind.CallExpression)) return true;
417
+ if (node.isKind(SyntaxKind.TemplateExpression)) return true;
418
+ if (!node.isKind(SyntaxKind.Identifier)) return false;
419
+ const decls = node.getSymbol()?.getDeclarations() ?? [];
420
+ return decls.some(
421
+ (decl) =>
422
+ decl.isKind(SyntaxKind.ImportSpecifier) ||
423
+ decl.isKind(SyntaxKind.ImportClause) ||
424
+ decl.isKind(SyntaxKind.NamespaceImport),
425
+ );
426
+ }
427
+
428
+ const UNRESOLVABLE_REASON_HINT =
429
+ " A declareEscapeHatch({ reason }) call was found here, but its reason is an import, a function call, or a template with substitutions — none of those can be statically judged, so declareEscapeHatch needs a string literal or a module-local const instead.";
430
+
431
+ // Walks the same ancestor chain as isInsideEscapeHatchDeclaredFunction, but
432
+ // looks for a declareEscapeHatch statement whose reason is one of the three
433
+ // explicitly-unresolvable shapes above, to explain a still-firing R2/R3
434
+ // finding rather than leave the reader to guess why a visible
435
+ // declareEscapeHatch call didn't clear it.
436
+ function unresolvableDeclareEscapeHatchHint(node: Node): string {
437
+ let ancestor: Node | undefined = node.getParent();
438
+ while (ancestor) {
439
+ if (
440
+ ancestor.isKind(SyntaxKind.ArrowFunction) ||
441
+ ancestor.isKind(SyntaxKind.FunctionExpression) ||
442
+ ancestor.isKind(SyntaxKind.MethodDeclaration) ||
443
+ ancestor.isKind(SyntaxKind.FunctionDeclaration)
444
+ ) {
445
+ const body = ancestor.getBody();
446
+ if (body?.isKind(SyntaxKind.Block)) {
447
+ for (const stmt of body.getStatements()) {
448
+ const reasonNode = declareEscapeHatchReasonNode(stmt);
449
+ if (reasonNode && isExplicitlyUnresolvableReason(reasonNode)) {
450
+ return UNRESOLVABLE_REASON_HINT;
451
+ }
452
+ }
453
+ }
454
+ }
455
+ ancestor = ancestor.getParent();
456
+ }
457
+ return "";
458
+ }
459
+
396
460
  // Only the function's own direct body — not a nested function's, not an
397
461
  // `if`'s — so a declareEscapeHatch call does not cover the function it is
398
462
  // itself nested inside (miss, don't falsely clear).
@@ -457,7 +521,9 @@ function findSystemIdentityFindings(
457
521
  file: path.relative(root, sf.getFilePath()),
458
522
  line: call.getStartLineNumber(),
459
523
  rule: "system-identity-outside-declared-scope",
460
- message: `${methodName}(...) called with a system identity outside a declared scope — restrict to a systemScope feature, a .job.ts / r.job(...) job, or declare { escapeHatch: { reason: "..." } } on the handler or hook.`,
524
+ message:
525
+ `${methodName}(...) called with a system identity outside a declared scope — restrict to a systemScope feature, a .job.ts / r.job(...) job, declare { escapeHatch: { reason: "..." } } on the handler or hook, or call declareEscapeHatch({ reason: "..." }) as a direct body statement of a named hook.` +
526
+ unresolvableDeclareEscapeHatchHint(call),
461
527
  });
462
528
  }
463
529
  return out;
@@ -525,7 +591,7 @@ function findGenericReasonMethodCalls(sf: SourceFile, root: string): GenericReas
525
591
  if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
526
592
  const methodName = expr.getName();
527
593
  if (!GENERIC_REASON_METHODS.has(methodName)) continue;
528
- const reasonText = literalReasonText(call.getArguments()[0]);
594
+ const reasonText = resolveReasonText(call.getArguments()[0]);
529
595
  if (reasonText === undefined || !isGenericReason(reasonText)) continue;
530
596
  out.push({
531
597
  file: path.relative(root, sf.getFilePath()),
@@ -554,7 +620,7 @@ function findGenericReasonDeclareEscapeHatchCalls(
554
620
  if (!arg?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
555
621
  const reasonProp = findReasonPropertyAssignment(arg);
556
622
  if (!reasonProp) continue;
557
- const reasonText = literalReasonText(reasonProp.getInitializer());
623
+ const reasonText = resolveReasonText(reasonProp.getInitializer());
558
624
  if (reasonText === undefined || !isGenericReason(reasonText)) continue;
559
625
  out.push({
560
626
  file: path.relative(root, sf.getFilePath()),
@@ -579,7 +645,7 @@ function findGenericReasonObjectProperty(
579
645
  if (!init?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
580
646
  const reasonProp = init.getProperty("reason");
581
647
  if (!reasonProp?.isKind(SyntaxKind.PropertyAssignment)) continue;
582
- const reasonText = literalReasonText(reasonProp.getInitializer());
648
+ const reasonText = resolveReasonText(reasonProp.getInitializer());
583
649
  if (reasonText === undefined) continue;
584
650
  // Empty/whitespace is the boot validator's job — no double-check.
585
651
  if (reasonText.trim() === "") continue;