@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 +2 -2
- package/src/_lib/generic-reason.ts +31 -1
- package/src/changes.json +6 -0
- package/src/guard-escape-hatch-declared.ts +90 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "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.
|
|
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
|
|
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).
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
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
|
|
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
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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;
|