@cosmicdrift/kumiko-guards 0.3.0 → 0.281.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.3.0",
3
+ "version": "0.281.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.1.0",
30
+ "@cosmicdrift/kumiko-repo-manifest": "0.281.0",
31
31
  "ts-morph": "^28.0.0"
32
32
  },
33
33
  "publishConfig": {
@@ -21,8 +21,9 @@
21
21
  * 2. `failUnprocessable(X, ...)` — same rule.
22
22
  * 3. Object literals containing `reason: "X"` — same rule for the X.
23
23
  * Skips `openToAll: { reason: "..." }` / `escapeHatch: { reason: "..." }`
24
- * — those are prose access-declaration justifications, enforced instead
25
- * by guard-open-to-all-reason.ts / guard-escape-hatch-declared.ts.
24
+ * and `declareEscapeHatch({ reason: "..." })` — those are prose
25
+ * access-declaration justifications, enforced instead by
26
+ * guard-open-to-all-reason.ts / guard-escape-hatch-declared.ts.
26
27
  *
27
28
  * Non-literal reasons (computed, template strings with interpolation,
28
29
  * identifier references) are assumed to be typed-from-a-const and pass.
@@ -134,14 +135,21 @@ function scanFile(sf: SourceFile): Violation[] {
134
135
  }
135
136
 
136
137
  // A `reason` PropertyAssignment whose object literal is the initializer of
137
- // an `openToAll` or `escapeHatch` PropertyAssignment is an access-declaration
138
+ // an `openToAll` / `escapeHatch` PropertyAssignment, or the sole argument
139
+ // object of a bare `declareEscapeHatch(...)` call, is an access-declaration
138
140
  // justification, not an error-reason code.
139
141
  export function isAccessDeclarationReason(prop: Node): boolean {
140
142
  const objectLiteral = prop.getParent();
141
143
  if (!objectLiteral?.isKind(SyntaxKind.ObjectLiteralExpression)) return false;
142
144
  const owner = objectLiteral.getParent();
143
- if (!owner?.isKind(SyntaxKind.PropertyAssignment)) return false;
144
- return ACCESS_DECLARATION_NAMES.has(owner.getName());
145
+ if (owner?.isKind(SyntaxKind.PropertyAssignment)) {
146
+ return ACCESS_DECLARATION_NAMES.has(owner.getName());
147
+ }
148
+ if (owner?.isKind(SyntaxKind.CallExpression)) {
149
+ const callee = owner.getExpression();
150
+ return callee.isKind(SyntaxKind.Identifier) && callee.getText() === "declareEscapeHatch";
151
+ }
152
+ return false;
145
153
  }
146
154
 
147
155
  // Returns the offending string if this node is a string literal that does
@@ -6,15 +6,18 @@
6
6
  * R1 raw-outside-system-scope: a TenantDb `.raw` escape used outside a
7
7
  * `r.systemScope()` feature.
8
8
  * R2 unsafe-raw-outside-system-scope: `ctx.systemDb.unsafeRaw(...)` outside
9
- * systemScope, a job scope, an explicit `withUnsafeRawGrant(...)`, or a
10
- * handler/hook that lexically declares `escapeHatch`.
9
+ * systemScope, a job scope, an explicit `withUnsafeRawGrant(...)`, a
10
+ * handler/hook that lexically declares `escapeHatch`, or a standalone
11
+ * function whose direct body declares `declareEscapeHatch({ reason: "..." })`.
11
12
  * R3 system-identity-outside-declared-scope: `queryAs`/`writeAs` called
12
13
  * with a system identity outside systemScope, `.job.ts`, an `r.job(...)`
13
- * call, a `*Job` function, or a handler/hook that lexically declares
14
- * `escapeHatch`.
15
- * R4 generic-reason: `acknowledgeCrossTenant`/`unsafeRaw`, or a declared
16
- * `escapeHatch: { reason }`/`unsafeAllTenants: { reason }`, given a
17
- * placeholder reason literal hard-fails everywhere, not baselined.
14
+ * call, a `*Job` function, a handler/hook that lexically declares
15
+ * `escapeHatch`, or a standalone function whose direct body declares
16
+ * `declareEscapeHatch({ reason: "..." })`.
17
+ * R4 generic-reason: `acknowledgeCrossTenant`/`unsafeRaw`, a declared
18
+ * `escapeHatch: { reason }`/`unsafeAllTenants: { reason }`, or a
19
+ * `declareEscapeHatch({ reason })` call, given a placeholder reason
20
+ * literal — hard-fails everywhere, not baselined.
18
21
  * R5 unsafe-all-tenants-outside-declared-scope: an `unsafeAllTenants: true`
19
22
  * or `unsafeAllTenants: { reason: "..." }` option, passed directly as a
20
23
  * call argument, outside systemScope, `.job.ts`, an `r.job(...)` call,
@@ -26,26 +29,49 @@
26
29
  * FunctionExpression, under any key name — e.g. `handler`, `export`,
27
30
  * `delete`), or in an options object passed to
28
31
  * `r.hook`/`writeHandler`/`queryHandler`/`streamHandler`/`useExtension`
29
- * alongside the handler function argument. Referenced-by-variable functions,
30
- * spread options, computed/string keys, and non-literal escapeHatch values
31
- * are conservatively not recognized (miss, don't falsely clear).
32
+ * alongside the handler function argument. A standalone function (arrow
33
+ * function, function expression, method declaration, or function
34
+ * declaration) is additionally recognized when a statement in its own direct
35
+ * body — not a nested function's, not inside an `if` — calls the bare
36
+ * identifier `declareEscapeHatch` with exactly one object-literal argument
37
+ * carrying a literal, non-placeholder `reason` (from
38
+ * `@cosmicdrift/kumiko-framework/engine`'s `declareEscapeHatch`: a helper
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
43
+ * lexical — the guard matches on the name `declareEscapeHatch`, not on where
44
+ * it was imported from, so a same-named local function clears just as well;
45
+ * consistent with `escapeHatch:` itself, which is likewise never checked for
46
+ * 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).
32
49
  *
33
50
  * Empty reasons, `openToAll.personalData` and PII are the framework boot validator's
34
- * job (access-declarations.ts), not this guard's. Known false-negatives:
35
- * multi-hop aliasing, `ctx["db"]` through an intermediate variable, and a
36
- * TenantDb/system-identity handed to another function across file
37
- * boundaries all conservative (miss, don't falsely flag). R5 additionally
38
- * misses `unsafeAllTenants` given via an identifier, a ternary, `false`, or
39
- * `undefined`, and an options object passed by variable reference or spread
40
- * rather than as a literal call argument — all conservative (miss, don't
41
- * falsely flag).
51
+ * job (access-declarations.ts), not this guard's except a `declareEscapeHatch`
52
+ * reason, which has no boot validator behind it: an empty or placeholder
53
+ * reason there is never recognized as a valid declaration (see R4). Known
54
+ * false-negatives: multi-hop aliasing, `ctx["db"]` through an intermediate
55
+ * variable, and a TenantDb/system-identity handed to another function across
56
+ * file boundaries with no `declareEscapeHatch` call at the escalation site
57
+ * (declarable now, so no longer a blanket false-negative) — all conservative
58
+ * (miss, don't falsely flag). R5 additionally misses `unsafeAllTenants`
59
+ * given via an identifier, a ternary, `false`, or `undefined`, and an
60
+ * options object passed by variable reference or spread rather than as a
61
+ * literal call argument — all conservative (miss, don't falsely flag).
42
62
  *
43
63
  * Usage:
44
64
  * bun guards/guard-escape-hatch-declared.ts
45
65
  * Baseline: bun guards/run-guards.ts --write-security-baseline
46
66
  */
47
67
  import * as path from "node:path";
48
- import { type Node, type ObjectLiteralExpression, type SourceFile, SyntaxKind } from "ts-morph";
68
+ import {
69
+ type Node,
70
+ type ObjectLiteralExpression,
71
+ type PropertyAssignment,
72
+ type SourceFile,
73
+ SyntaxKind,
74
+ } from "ts-morph";
49
75
  import { isGenericReason, literalReasonText } from "./_lib/generic-reason";
50
76
  import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
51
77
 
@@ -330,15 +356,72 @@ function isEscapeHatchDeclaredFunction(fn: Node): boolean {
330
356
  return false;
331
357
  }
332
358
 
359
+ // The bare-identifier `reason` PropertyAssignment on an object literal —
360
+ // shared between the declareEscapeHatch statement check below and its R4
361
+ // generic-reason collector, so both agree on what counts as the reason.
362
+ function findReasonPropertyAssignment(
363
+ obj: ObjectLiteralExpression,
364
+ ): PropertyAssignment | undefined {
365
+ return obj
366
+ .getProperties()
367
+ .find(
368
+ (prop): prop is PropertyAssignment =>
369
+ prop.isKind(SyntaxKind.PropertyAssignment) &&
370
+ prop.getNameNode().isKind(SyntaxKind.Identifier) &&
371
+ prop.getNameNode().getText() === "reason",
372
+ );
373
+ }
374
+
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;
381
+ const expr = stmt.getExpression();
382
+ if (!expr.isKind(SyntaxKind.CallExpression)) return false;
383
+ const callee = expr.getExpression();
384
+ if (!callee.isKind(SyntaxKind.Identifier) || callee.getText() !== "declareEscapeHatch") {
385
+ return false;
386
+ }
387
+ const args = expr.getArguments();
388
+ 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());
393
+ return reasonText !== undefined && !isGenericReason(reasonText);
394
+ }
395
+
396
+ // Only the function's own direct body — not a nested function's, not an
397
+ // `if`'s — so a declareEscapeHatch call does not cover the function it is
398
+ // itself nested inside (miss, don't falsely clear).
399
+ function hasDeclaredEscapeHatchStatement(fn: Node): boolean {
400
+ let body: Node | undefined;
401
+ if (
402
+ fn.isKind(SyntaxKind.ArrowFunction) ||
403
+ fn.isKind(SyntaxKind.FunctionExpression) ||
404
+ fn.isKind(SyntaxKind.MethodDeclaration) ||
405
+ fn.isKind(SyntaxKind.FunctionDeclaration)
406
+ ) {
407
+ body = fn.getBody();
408
+ }
409
+ if (!body?.isKind(SyntaxKind.Block)) return false;
410
+ return body.getStatements().some((stmt) => isValidDeclareEscapeHatchCall(stmt));
411
+ }
412
+
333
413
  function isInsideEscapeHatchDeclaredFunction(node: Node): boolean {
334
414
  let ancestor: Node | undefined = node.getParent();
335
415
  while (ancestor) {
336
416
  if (
337
417
  ancestor.isKind(SyntaxKind.ArrowFunction) ||
338
418
  ancestor.isKind(SyntaxKind.FunctionExpression) ||
339
- ancestor.isKind(SyntaxKind.MethodDeclaration)
419
+ ancestor.isKind(SyntaxKind.MethodDeclaration) ||
420
+ ancestor.isKind(SyntaxKind.FunctionDeclaration)
340
421
  ) {
341
- if (isEscapeHatchDeclaredFunction(ancestor)) return true;
422
+ if (isEscapeHatchDeclaredFunction(ancestor) || hasDeclaredEscapeHatchStatement(ancestor)) {
423
+ return true;
424
+ }
342
425
  }
343
426
  ancestor = ancestor.getParent();
344
427
  }
@@ -453,6 +536,35 @@ function findGenericReasonMethodCalls(sf: SourceFile, root: string): GenericReas
453
536
  return out;
454
537
  }
455
538
 
539
+ // The declareEscapeHatch({ reason }) form falls through both existing R4
540
+ // collectors: its callee is a bare identifier, not a PropertyAccessExpression
541
+ // (unlike acknowledgeCrossTenant/unsafeRaw), and its reason sits directly in
542
+ // the call argument, not under an escapeHatch:/unsafeAllTenants: property.
543
+ function findGenericReasonDeclareEscapeHatchCalls(
544
+ sf: SourceFile,
545
+ root: string,
546
+ ): GenericReasonFinding[] {
547
+ const out: GenericReasonFinding[] = [];
548
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
549
+ const callee = call.getExpression();
550
+ if (!callee.isKind(SyntaxKind.Identifier) || callee.getText() !== "declareEscapeHatch") {
551
+ continue;
552
+ }
553
+ const arg = call.getArguments()[0];
554
+ if (!arg?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
555
+ const reasonProp = findReasonPropertyAssignment(arg);
556
+ if (!reasonProp) continue;
557
+ const reasonText = literalReasonText(reasonProp.getInitializer());
558
+ if (reasonText === undefined || !isGenericReason(reasonText)) continue;
559
+ out.push({
560
+ file: path.relative(root, sf.getFilePath()),
561
+ line: call.getStartLineNumber(),
562
+ message: `declareEscapeHatch({ reason: "${reasonText}" }) uses a placeholder reason — give a concrete, reviewable justification for this cross-tenant/unsafe access.`,
563
+ });
564
+ }
565
+ return out;
566
+ }
567
+
456
568
  const REASON_OBJECT_PROPERTY_NAMES = ["escapeHatch", "unsafeAllTenants"] as const;
457
569
 
458
570
  function findGenericReasonObjectProperty(
@@ -488,6 +600,7 @@ export function findGenericReasonCalls(
488
600
  const out: GenericReasonFinding[] = [];
489
601
  for (const sf of scannableFiles(files)) {
490
602
  out.push(...findGenericReasonMethodCalls(sf, root));
603
+ out.push(...findGenericReasonDeclareEscapeHatchCalls(sf, root));
491
604
  for (const propertyName of REASON_OBJECT_PROPERTY_NAMES) {
492
605
  out.push(...findGenericReasonObjectProperty(sf, root, propertyName));
493
606
  }