@cosmicdrift/kumiko-guards 0.382.0 → 0.383.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.
@@ -1,743 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Guard: escape-hatch declarations for the cross-tenant/system-identity
4
- * surface (ctx.db.raw / ctx.systemDb.unsafeRaw / ctx.queryAs|writeAs(system)).
5
- *
6
- * R1 raw-outside-system-scope: a TenantDb `.raw` escape used outside a
7
- * `r.systemScope()` feature.
8
- * R2 unsafe-raw-outside-system-scope: `ctx.systemDb.unsafeRaw(...)` outside
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: "..." })`.
12
- * R3 system-identity-outside-declared-scope: `queryAs`/`writeAs` called
13
- * with a system identity outside systemScope, `.job.ts`, an `r.job(...)`
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.
21
- * R5 unsafe-all-tenants-outside-declared-scope: an `unsafeAllTenants: true`
22
- * or `unsafeAllTenants: { reason: "..." }` option, passed directly as a
23
- * call argument, outside systemScope, `.job.ts`, an `r.job(...)` call,
24
- * a `*Job` function, or a scope that already declares `escapeHatch`.
25
- *
26
- * escapeHatch (R2/R3) is recognized only as a direct, literal `escapeHatch`
27
- * property (object literal, or ternary of two object literals) either in the
28
- * same object literal as any inline function property (ArrowFunction or
29
- * FunctionExpression, under any key name — e.g. `handler`, `export`,
30
- * `delete`), or in an options object passed to
31
- * `r.hook`/`writeHandler`/`queryHandler`/`streamHandler`/`useExtension`
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 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). `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
49
- * lexical except for origin: `declareEscapeHatch` (and `withUnsafeRawGrant`
50
- * for R2) must resolve to an import from `@cosmicdrift/kumiko-framework`
51
- * (or, inside the framework package, a relative import into its `src`), so a
52
- * same-named local function does not clear. `escapeHatch:` itself is a plain
53
- * property and is never checked for origin. Referenced-by-variable functions, spread options, computed/string
54
- * keys, and a non-literal, non-module-local-const `escapeHatch`/`reason`
55
- * value are conservatively not recognized (miss, don't falsely clear).
56
- *
57
- * Empty reasons, `openToAll.personalData` and PII are the framework boot validator's
58
- * job (access-declarations.ts), not this guard's — except a `declareEscapeHatch`
59
- * reason, which has no boot validator behind it: an empty or placeholder
60
- * reason there is never recognized as a valid declaration (see R4). Known
61
- * false-negatives: multi-hop aliasing, `ctx["db"]` through an intermediate
62
- * variable, and a TenantDb/system-identity handed to another function across
63
- * file boundaries with no `declareEscapeHatch` call at the escalation site
64
- * (declarable now, so no longer a blanket false-negative) — all conservative
65
- * (miss, don't falsely flag). R5 additionally misses `unsafeAllTenants`
66
- * given via an identifier, a ternary, `false`, or `undefined`, and an
67
- * options object passed by variable reference or spread rather than as a
68
- * literal call argument — all conservative (miss, don't falsely flag).
69
- *
70
- * Usage:
71
- * bun guards/guard-escape-hatch-declared.ts
72
- * Baseline: kumiko-guards guards --write-security-baseline
73
- */
74
- import * as path from "node:path";
75
- import {
76
- type BindingElement,
77
- type Node,
78
- type ObjectLiteralExpression,
79
- type PropertyAssignment,
80
- type SourceFile,
81
- SyntaxKind,
82
- type VariableDeclaration,
83
- VariableDeclarationKind,
84
- } from "ts-morph";
85
- import { isFrameworkImportOf } from "./_lib/framework-import";
86
- import { isGenericReason, resolveReasonText } from "./_lib/generic-reason";
87
- import {
88
- ALL_REPO_KINDS,
89
- type AstGuard,
90
- type GuardViolation,
91
- runStandalone,
92
- type ScanSpec,
93
- } from "./_lib/guard-kit";
94
-
95
- const SCAN: ScanSpec = {
96
- kinds: ALL_REPO_KINDS,
97
- scope: "source",
98
- extensions: ["ts"],
99
- frameworkWithin: ["packages/*/src/**", "samples/**"],
100
- };
101
- const EXCLUDE = /(__tests__\/|\.test\.tsx?$|\.d\.ts$)/;
102
-
103
- const DB_RECEIVER_NAMES = new Set(["db", "dbOutsideTransaction"]);
104
- const SYSTEM_IDENTITY_RE = /^(SYSTEM(_[A-Z0-9_]+)?|system(User|Actor)?)$/;
105
- const CTX_IDENTIFIER_NAMES = new Set(["ctx", "context", "handlerCtx", "handlerContext"]);
106
-
107
- export type EscapeHatchFinding = {
108
- file: string;
109
- line: number;
110
- rule:
111
- | "raw-outside-system-scope"
112
- | "unsafe-raw-outside-system-scope"
113
- | "system-identity-outside-declared-scope"
114
- | "unsafe-all-tenants-outside-declared-scope";
115
- message: string;
116
- };
117
-
118
- export type GenericReasonFinding = {
119
- file: string;
120
- line: number;
121
- message: string;
122
- };
123
-
124
- function scannableFiles(files: readonly SourceFile[]): SourceFile[] {
125
- return files.filter((sf) => !EXCLUDE.test(sf.getFilePath()));
126
- }
127
-
128
- export function systemScopeDirs(files: readonly SourceFile[]): string[] {
129
- const dirs: string[] = [];
130
- for (const sf of scannableFiles(files)) {
131
- if (!sf.getFullText().includes("systemScope")) continue;
132
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
133
- const expr = call.getExpression();
134
- if (
135
- expr.isKind(SyntaxKind.PropertyAccessExpression) &&
136
- expr.getName() === "systemScope" &&
137
- call.getArguments().length === 0
138
- ) {
139
- dirs.push(path.dirname(sf.getFilePath()));
140
- break;
141
- }
142
- }
143
- }
144
- return dirs;
145
- }
146
-
147
- function isInSystemScope(filePath: string, dirs: readonly string[]): boolean {
148
- return dirs.some((dir) => filePath.startsWith(`${dir}/`));
149
- }
150
-
151
- function isDirectTenantDbAccess(node: Node): boolean {
152
- if (node.isKind(SyntaxKind.PropertyAccessExpression)) {
153
- return DB_RECEIVER_NAMES.has(node.getName());
154
- }
155
- if (node.isKind(SyntaxKind.ElementAccessExpression)) {
156
- const arg = node.getArgumentExpression();
157
- if (arg?.isKind(SyntaxKind.StringLiteral)) {
158
- return DB_RECEIVER_NAMES.has(arg.getLiteralValue());
159
- }
160
- }
161
- return false;
162
- }
163
-
164
- function isTenantDbVariable(decl: VariableDeclaration): boolean {
165
- if (decl.getTypeNode()?.getText() === "TenantDb") return true;
166
- const init = decl.getInitializer();
167
- return init !== undefined && isDirectTenantDbAccess(init);
168
- }
169
-
170
- function isCtxDbBindingElement(decl: BindingElement): boolean {
171
- const propName = decl.getPropertyNameNode()?.getText() ?? decl.getName();
172
- if (!DB_RECEIVER_NAMES.has(propName)) return false;
173
- // Only `const { db } = ctx` — parameter destructuring (`({ db }) =>`)
174
- // stays a deliberate false-negative, not worth the ambiguity.
175
- const owner = decl.getParent()?.getParent();
176
- const init = owner?.isKind(SyntaxKind.VariableDeclaration) ? owner.getInitializer() : undefined;
177
- return init?.isKind(SyntaxKind.Identifier) === true && CTX_IDENTIFIER_NAMES.has(init.getText());
178
- }
179
-
180
- function isTenantDbDeclaration(decl: Node): boolean {
181
- if (decl.isKind(SyntaxKind.VariableDeclaration)) return isTenantDbVariable(decl);
182
- if (decl.isKind(SyntaxKind.BindingElement)) return isCtxDbBindingElement(decl);
183
- if (decl.isKind(SyntaxKind.Parameter)) return decl.getTypeNode()?.getText() === "TenantDb";
184
- return false;
185
- }
186
-
187
- function isTenantDbExpression(node: Node): boolean {
188
- if (isDirectTenantDbAccess(node)) return true;
189
- if (!node.isKind(SyntaxKind.Identifier)) return false;
190
- return (node.getSymbol()?.getDeclarations() ?? []).some(isTenantDbDeclaration);
191
- }
192
-
193
- // Text prefilters: a node walk per rule per file is the guard's dominant cost, and
194
- // a rule's anchor name must appear verbatim in any file it can fire on.
195
- const RAW_ACCESS = /\.\s*raw\b/;
196
-
197
- function findRawFindings(
198
- sf: SourceFile,
199
- root: string,
200
- systemDirs: readonly string[],
201
- ): EscapeHatchFinding[] {
202
- if (isInSystemScope(sf.getFilePath(), systemDirs)) return [];
203
- const out: EscapeHatchFinding[] = [];
204
- if (!RAW_ACCESS.test(sf.getFullText())) return out;
205
- for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
206
- if (pa.getName() !== "raw") continue;
207
- if (!isTenantDbExpression(pa.getExpression())) continue;
208
- out.push({
209
- file: path.relative(root, sf.getFilePath()),
210
- line: pa.getStartLineNumber(),
211
- rule: "raw-outside-system-scope",
212
- message:
213
- "ctx.db.raw used outside a systemScope feature — a tenant-scoped raw escape needs a systemScope feature, or route the actual query through the framework's tenant filtering.",
214
- });
215
- }
216
- return out;
217
- }
218
-
219
- function findUnsafeRawFindings(
220
- sf: SourceFile,
221
- root: string,
222
- systemDirs: readonly string[],
223
- ): EscapeHatchFinding[] {
224
- if (isInSystemScope(sf.getFilePath(), systemDirs)) return [];
225
- const out: EscapeHatchFinding[] = [];
226
- if (!sf.getFullText().includes("unsafeRaw")) return out;
227
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
228
- const expr = call.getExpression();
229
- if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
230
- if (expr.getName() !== "unsafeRaw") continue;
231
- if (isAllowedEscapeHatchCall(call, sf, systemDirs) || isExplicitUnsafeRawGrant(call)) continue;
232
- out.push({
233
- file: path.relative(root, sf.getFilePath()),
234
- line: call.getStartLineNumber(),
235
- rule: "unsafe-raw-outside-system-scope",
236
- message:
237
- '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.' +
238
- unresolvableDeclareEscapeHatchHint(call),
239
- });
240
- }
241
- return out;
242
- }
243
-
244
- function isExplicitUnsafeRawGrant(call: Node): boolean {
245
- if (!call.isKind(SyntaxKind.CallExpression)) return false;
246
- const expr = call.getExpression();
247
- if (!expr.isKind(SyntaxKind.PropertyAccessExpression) || expr.getName() !== "unsafeRaw") {
248
- return false;
249
- }
250
- const receiver = expr.getExpression();
251
- return (
252
- receiver.isKind(SyntaxKind.CallExpression) &&
253
- isFrameworkImportOf(receiver.getExpression(), "withUnsafeRawGrant")
254
- );
255
- }
256
-
257
- function isSystemIdentityExpression(node: Node): boolean {
258
- if (node.isKind(SyntaxKind.CallExpression)) {
259
- const calleeText = node.getExpression().getText();
260
- return calleeText.endsWith("createSystemUser") || calleeText.endsWith("systemUserOf");
261
- }
262
- if (!node.isKind(SyntaxKind.Identifier)) return false;
263
- if (SYSTEM_IDENTITY_RE.test(node.getText())) return true;
264
- const decls = node.getSymbol()?.getDeclarations() ?? [];
265
- for (const decl of decls) {
266
- if (!decl.isKind(SyntaxKind.VariableDeclaration)) continue;
267
- const init = decl.getInitializer();
268
- if (init && isSystemIdentityExpression(init)) return true;
269
- }
270
- return false;
271
- }
272
-
273
- function hasJobScopeAncestor(call: Node): boolean {
274
- let ancestor: Node | undefined = call.getParent();
275
- while (ancestor) {
276
- if (ancestor.isKind(SyntaxKind.CallExpression)) {
277
- const calleeExpr = ancestor.getExpression();
278
- if (
279
- calleeExpr.isKind(SyntaxKind.PropertyAccessExpression) &&
280
- calleeExpr.getName() === "job"
281
- ) {
282
- return true;
283
- }
284
- }
285
- if (
286
- ancestor.isKind(SyntaxKind.FunctionDeclaration) ||
287
- ancestor.isKind(SyntaxKind.MethodDeclaration)
288
- ) {
289
- if (ancestor.getName()?.endsWith("Job")) return true;
290
- }
291
- if (ancestor.isKind(SyntaxKind.ArrowFunction)) {
292
- const parent = ancestor.getParent();
293
- if (parent?.isKind(SyntaxKind.VariableDeclaration) && parent.getName().endsWith("Job")) {
294
- return true;
295
- }
296
- }
297
- ancestor = ancestor.getParent();
298
- }
299
- return false;
300
- }
301
-
302
- function unwrapParenthesized(node: Node): Node {
303
- let current = node;
304
- while (current.isKind(SyntaxKind.ParenthesizedExpression)) {
305
- current = current.getExpression();
306
- }
307
- return current;
308
- }
309
-
310
- function isEscapeHatchDeclarationValue(node: Node | undefined): boolean {
311
- if (!node) return false;
312
- const value = unwrapParenthesized(node);
313
- if (value.isKind(SyntaxKind.ObjectLiteralExpression)) return true;
314
- if (value.isKind(SyntaxKind.ConditionalExpression)) {
315
- const whenTrue = unwrapParenthesized(value.getWhenTrue());
316
- const whenFalse = unwrapParenthesized(value.getWhenFalse());
317
- return (
318
- whenTrue.isKind(SyntaxKind.ObjectLiteralExpression) &&
319
- whenFalse.isKind(SyntaxKind.ObjectLiteralExpression)
320
- );
321
- }
322
- return false;
323
- }
324
-
325
- function objectDeclaresEscapeHatch(obj: ObjectLiteralExpression): boolean {
326
- return obj.getProperties().some((prop) => {
327
- if (!prop.isKind(SyntaxKind.PropertyAssignment)) return false;
328
- const nameNode = prop.getNameNode();
329
- if (!nameNode.isKind(SyntaxKind.Identifier) || nameNode.getText() !== "escapeHatch") {
330
- return false;
331
- }
332
- return isEscapeHatchDeclarationValue(prop.getInitializer());
333
- });
334
- }
335
-
336
- const ESCAPE_HATCH_CALL_METHODS = new Set([
337
- "hook",
338
- "writeHandler",
339
- "queryHandler",
340
- "streamHandler",
341
- "useExtension",
342
- ]);
343
-
344
- function isEscapeHatchDeclaredFunction(fn: Node): boolean {
345
- const parent = fn.getParent();
346
- if (!parent) return false;
347
-
348
- if (fn.isKind(SyntaxKind.MethodDeclaration)) {
349
- return parent.isKind(SyntaxKind.ObjectLiteralExpression) && objectDeclaresEscapeHatch(parent);
350
- }
351
-
352
- if (!fn.isKind(SyntaxKind.ArrowFunction) && !fn.isKind(SyntaxKind.FunctionExpression)) {
353
- return false;
354
- }
355
-
356
- if (parent.isKind(SyntaxKind.PropertyAssignment)) {
357
- const obj = parent.getParent();
358
- return (
359
- parent.getInitializer() === fn &&
360
- obj.isKind(SyntaxKind.ObjectLiteralExpression) &&
361
- objectDeclaresEscapeHatch(obj)
362
- );
363
- }
364
-
365
- if (parent.isKind(SyntaxKind.CallExpression)) {
366
- const args = parent.getArguments();
367
- if (!args.includes(fn)) return false;
368
- const callee = parent.getExpression();
369
- if (
370
- !callee.isKind(SyntaxKind.PropertyAccessExpression) ||
371
- !ESCAPE_HATCH_CALL_METHODS.has(callee.getName())
372
- ) {
373
- return false;
374
- }
375
- return args.some(
376
- (arg) =>
377
- arg !== fn &&
378
- arg.isKind(SyntaxKind.ObjectLiteralExpression) &&
379
- objectDeclaresEscapeHatch(arg),
380
- );
381
- }
382
-
383
- return false;
384
- }
385
-
386
- // The bare-identifier `reason` PropertyAssignment on an object literal —
387
- // shared between the declareEscapeHatch statement check below and its R4
388
- // generic-reason collector, so both agree on what counts as the reason.
389
- function findReasonPropertyAssignment(
390
- obj: ObjectLiteralExpression,
391
- ): PropertyAssignment | undefined {
392
- return obj
393
- .getProperties()
394
- .find(
395
- (prop): prop is PropertyAssignment =>
396
- prop.isKind(SyntaxKind.PropertyAssignment) &&
397
- prop.getNameNode().isKind(SyntaxKind.Identifier) &&
398
- prop.getNameNode().getText() === "reason",
399
- );
400
- }
401
-
402
- // Syntactic extraction only — shared by the clearance check below and by
403
- // the "why" hint on R2/R3, which needs the reason node even when it turns
404
- // out not to resolve.
405
- function declareEscapeHatchReasonNode(stmt: Node): Node | undefined {
406
- if (!stmt.isKind(SyntaxKind.ExpressionStatement)) return undefined;
407
- const expr = stmt.getExpression();
408
- if (!expr.isKind(SyntaxKind.CallExpression)) return undefined;
409
- const callee = expr.getExpression();
410
- if (!isFrameworkImportOf(callee, "declareEscapeHatch")) return undefined;
411
- const args = expr.getArguments();
412
- const arg = args[0];
413
- if (args.length !== 1 || !arg?.isKind(SyntaxKind.ObjectLiteralExpression)) return undefined;
414
- return findReasonPropertyAssignment(arg)?.getInitializer();
415
- }
416
-
417
- // declareEscapeHatch({ reason: "..." }) as a direct-body statement of a
418
- // standalone function. No boot validator backs this form (unlike the
419
- // escapeHatch: {...} property, which access-declarations.ts checks at boot),
420
- // so an empty/placeholder reason is rejected here rather than left to it.
421
- function isValidDeclareEscapeHatchCall(stmt: Node): boolean {
422
- const reasonNode = declareEscapeHatchReasonNode(stmt);
423
- if (!reasonNode) return false;
424
- const reasonText = resolveReasonText(reasonNode);
425
- return reasonText !== undefined && !isGenericReason(reasonText);
426
- }
427
-
428
- // The three reason shapes the guard can name a concrete cause for: an
429
- // import, a function call, or a template with substitutions — none of
430
- // those are statically judgeable. An ambient/uninitialized identifier
431
- // (e.g. a `declare const` parameter) stays silently unresolved instead;
432
- // there is nothing more specific to say about it.
433
- function isExplicitlyUnresolvableReason(node: Node): boolean {
434
- if (node.isKind(SyntaxKind.CallExpression)) return true;
435
- if (node.isKind(SyntaxKind.TemplateExpression)) return true;
436
- if (!node.isKind(SyntaxKind.Identifier)) return false;
437
- const decls = node.getSymbol()?.getDeclarations() ?? [];
438
- return decls.some(
439
- (decl) =>
440
- decl.isKind(SyntaxKind.ImportSpecifier) ||
441
- decl.isKind(SyntaxKind.ImportClause) ||
442
- decl.isKind(SyntaxKind.NamespaceImport) ||
443
- isNonConstVariableDeclaration(decl),
444
- );
445
- }
446
-
447
- function isNonConstVariableDeclaration(decl: Node): boolean {
448
- if (!decl.isKind(SyntaxKind.VariableDeclaration)) return false;
449
- const list = decl.getParent();
450
- return (
451
- list.isKind(SyntaxKind.VariableDeclarationList) &&
452
- list.getDeclarationKind() !== VariableDeclarationKind.Const
453
- );
454
- }
455
-
456
- const UNRESOLVABLE_REASON_HINT =
457
- " A declareEscapeHatch({ reason }) call was found here, but its reason is an import, a function call, or a template with substitutions (or a `let`/non-`const` binding) — none of those can be statically judged, so declareEscapeHatch needs a string literal or a module-local const instead.";
458
-
459
- // Walks the same ancestor chain as isInsideEscapeHatchDeclaredFunction, but
460
- // looks for a declareEscapeHatch statement whose reason is one of the three
461
- // explicitly-unresolvable shapes above, to explain a still-firing R2/R3
462
- // finding rather than leave the reader to guess why a visible
463
- // declareEscapeHatch call didn't clear it.
464
- function unresolvableDeclareEscapeHatchHint(node: Node): string {
465
- let ancestor: Node | undefined = node.getParent();
466
- while (ancestor) {
467
- if (
468
- ancestor.isKind(SyntaxKind.ArrowFunction) ||
469
- ancestor.isKind(SyntaxKind.FunctionExpression) ||
470
- ancestor.isKind(SyntaxKind.MethodDeclaration) ||
471
- ancestor.isKind(SyntaxKind.FunctionDeclaration)
472
- ) {
473
- const body = ancestor.getBody();
474
- if (body?.isKind(SyntaxKind.Block)) {
475
- for (const stmt of body.getStatements()) {
476
- const reasonNode = declareEscapeHatchReasonNode(stmt);
477
- if (reasonNode && isExplicitlyUnresolvableReason(reasonNode)) {
478
- return UNRESOLVABLE_REASON_HINT;
479
- }
480
- }
481
- }
482
- }
483
- ancestor = ancestor.getParent();
484
- }
485
- return "";
486
- }
487
-
488
- // Only the function's own direct body — not a nested function's, not an
489
- // `if`'s — so a declareEscapeHatch call does not cover the function it is
490
- // itself nested inside (miss, don't falsely clear).
491
- function hasDeclaredEscapeHatchStatement(fn: Node): boolean {
492
- let body: Node | undefined;
493
- if (
494
- fn.isKind(SyntaxKind.ArrowFunction) ||
495
- fn.isKind(SyntaxKind.FunctionExpression) ||
496
- fn.isKind(SyntaxKind.MethodDeclaration) ||
497
- fn.isKind(SyntaxKind.FunctionDeclaration)
498
- ) {
499
- body = fn.getBody();
500
- }
501
- if (!body?.isKind(SyntaxKind.Block)) return false;
502
- const firstStatement = body.getStatements()[0];
503
- return firstStatement !== undefined && isValidDeclareEscapeHatchCall(firstStatement);
504
- }
505
-
506
- function isInsideEscapeHatchDeclaredFunction(node: Node): boolean {
507
- let ancestor: Node | undefined = node.getParent();
508
- while (ancestor) {
509
- if (
510
- ancestor.isKind(SyntaxKind.ArrowFunction) ||
511
- ancestor.isKind(SyntaxKind.FunctionExpression) ||
512
- ancestor.isKind(SyntaxKind.MethodDeclaration) ||
513
- ancestor.isKind(SyntaxKind.FunctionDeclaration)
514
- ) {
515
- if (isEscapeHatchDeclaredFunction(ancestor) || hasDeclaredEscapeHatchStatement(ancestor)) {
516
- return true;
517
- }
518
- }
519
- ancestor = ancestor.getParent();
520
- }
521
- return false;
522
- }
523
-
524
- function isAllowedEscapeHatchCall(
525
- call: Node,
526
- sf: SourceFile,
527
- systemDirs: readonly string[],
528
- ): boolean {
529
- const filePath = sf.getFilePath();
530
- if (isInSystemScope(filePath, systemDirs)) return true;
531
- if (filePath.endsWith(".job.ts")) return true;
532
- return hasJobScopeAncestor(call) || isInsideEscapeHatchDeclaredFunction(call);
533
- }
534
-
535
- function findSystemIdentityFindings(
536
- sf: SourceFile,
537
- root: string,
538
- systemDirs: readonly string[],
539
- ): EscapeHatchFinding[] {
540
- const out: EscapeHatchFinding[] = [];
541
- const text = sf.getFullText();
542
- if (!text.includes("queryAs") && !text.includes("writeAs")) return out;
543
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
544
- const expr = call.getExpression();
545
- if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
546
- const methodName = expr.getName();
547
- if (methodName !== "queryAs" && methodName !== "writeAs") continue;
548
- const firstArg = call.getArguments()[0];
549
- if (!firstArg || !isSystemIdentityExpression(firstArg)) continue;
550
- if (isAllowedEscapeHatchCall(call, sf, systemDirs)) continue;
551
- out.push({
552
- file: path.relative(root, sf.getFilePath()),
553
- line: call.getStartLineNumber(),
554
- rule: "system-identity-outside-declared-scope",
555
- message:
556
- `${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.` +
557
- unresolvableDeclareEscapeHatchHint(call),
558
- });
559
- }
560
- return out;
561
- }
562
-
563
- function isUnsafeAllTenantsDeclarationValue(node: Node): boolean {
564
- if (node.isKind(SyntaxKind.TrueKeyword)) return true;
565
- return node.isKind(SyntaxKind.ObjectLiteralExpression);
566
- }
567
-
568
- function findUnsafeAllTenantsFindings(
569
- sf: SourceFile,
570
- root: string,
571
- systemDirs: readonly string[],
572
- ): EscapeHatchFinding[] {
573
- const out: EscapeHatchFinding[] = [];
574
- if (!sf.getFullText().includes("unsafeAllTenants")) return out;
575
- for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
576
- const nameNode = prop.getNameNode();
577
- if (!nameNode.isKind(SyntaxKind.Identifier) || nameNode.getText() !== "unsafeAllTenants") {
578
- continue;
579
- }
580
- const initializer = prop.getInitializer();
581
- if (!initializer) continue;
582
- const value = unwrapParenthesized(initializer);
583
- if (!isUnsafeAllTenantsDeclarationValue(value)) continue;
584
- const obj = prop.getParent();
585
- if (!obj.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
586
- const call = obj.getParent();
587
- if (!call?.isKind(SyntaxKind.CallExpression)) continue;
588
- if (!call.getArguments().includes(obj)) continue;
589
- if (objectDeclaresEscapeHatch(obj)) continue;
590
- if (isAllowedEscapeHatchCall(call, sf, systemDirs)) continue;
591
- out.push({
592
- file: path.relative(root, sf.getFilePath()),
593
- line: prop.getStartLineNumber(),
594
- rule: "unsafe-all-tenants-outside-declared-scope",
595
- message:
596
- 'unsafeAllTenants used outside a declared scope — restrict to a systemScope feature, a .job.ts / r.job(...) job, declare { escapeHatch: { reason: "..." } } alongside it, or call declareEscapeHatch({ reason: "..." }) as a direct body statement of a named hook.' +
597
- unresolvableDeclareEscapeHatchHint(call),
598
- });
599
- }
600
- return out;
601
- }
602
-
603
- export function findEscapeHatchFindings(
604
- files: readonly SourceFile[],
605
- root: string,
606
- ): EscapeHatchFinding[] {
607
- const systemDirs = systemScopeDirs(files);
608
- const out: EscapeHatchFinding[] = [];
609
- for (const sf of scannableFiles(files)) {
610
- out.push(...findRawFindings(sf, root, systemDirs));
611
- out.push(...findUnsafeRawFindings(sf, root, systemDirs));
612
- out.push(...findSystemIdentityFindings(sf, root, systemDirs));
613
- out.push(...findUnsafeAllTenantsFindings(sf, root, systemDirs));
614
- }
615
- return out;
616
- }
617
-
618
- const GENERIC_REASON_METHODS = new Set(["acknowledgeCrossTenant", "unsafeRaw"]);
619
-
620
- function findGenericReasonMethodCalls(sf: SourceFile, root: string): GenericReasonFinding[] {
621
- const out: GenericReasonFinding[] = [];
622
- const text = sf.getFullText();
623
- if (![...GENERIC_REASON_METHODS].some((method) => text.includes(method))) return out;
624
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
625
- const expr = call.getExpression();
626
- if (!expr.isKind(SyntaxKind.PropertyAccessExpression)) continue;
627
- const methodName = expr.getName();
628
- if (!GENERIC_REASON_METHODS.has(methodName)) continue;
629
- const reasonText = resolveReasonText(call.getArguments()[0]);
630
- if (reasonText === undefined || !isGenericReason(reasonText)) continue;
631
- out.push({
632
- file: path.relative(root, sf.getFilePath()),
633
- line: call.getStartLineNumber(),
634
- message: `${methodName}("${reasonText}") uses a placeholder reason — give a concrete, reviewable justification for this cross-tenant/unsafe access.`,
635
- });
636
- }
637
- return out;
638
- }
639
-
640
- // The declareEscapeHatch({ reason }) form falls through both existing R4
641
- // collectors: its callee is a bare identifier, not a PropertyAccessExpression
642
- // (unlike acknowledgeCrossTenant/unsafeRaw), and its reason sits directly in
643
- // the call argument, not under an escapeHatch:/unsafeAllTenants: property.
644
- function findGenericReasonDeclareEscapeHatchCalls(
645
- sf: SourceFile,
646
- root: string,
647
- ): GenericReasonFinding[] {
648
- const out: GenericReasonFinding[] = [];
649
- if (!sf.getFullText().includes("declareEscapeHatch")) return out;
650
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
651
- const callee = call.getExpression();
652
- if (!callee.isKind(SyntaxKind.Identifier) || callee.getText() !== "declareEscapeHatch") {
653
- continue;
654
- }
655
- const arg = call.getArguments()[0];
656
- if (!arg?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
657
- const reasonProp = findReasonPropertyAssignment(arg);
658
- if (!reasonProp) continue;
659
- const reasonText = resolveReasonText(reasonProp.getInitializer());
660
- if (reasonText === undefined || !isGenericReason(reasonText)) continue;
661
- out.push({
662
- file: path.relative(root, sf.getFilePath()),
663
- line: call.getStartLineNumber(),
664
- message: `declareEscapeHatch({ reason: "${reasonText}" }) uses a placeholder reason — give a concrete, reviewable justification for this cross-tenant/unsafe access.`,
665
- });
666
- }
667
- return out;
668
- }
669
-
670
- const REASON_OBJECT_PROPERTY_NAMES = ["escapeHatch", "unsafeAllTenants"] as const;
671
-
672
- function findGenericReasonObjectProperty(
673
- sf: SourceFile,
674
- root: string,
675
- propertyName: string,
676
- ): GenericReasonFinding[] {
677
- const out: GenericReasonFinding[] = [];
678
- if (!sf.getFullText().includes(propertyName)) return out;
679
- for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
680
- if (pa.getName() !== propertyName) continue;
681
- const init = pa.getInitializer();
682
- if (!init?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
683
- const reasonProp = init.getProperty("reason");
684
- if (!reasonProp?.isKind(SyntaxKind.PropertyAssignment)) continue;
685
- const reasonText = resolveReasonText(reasonProp.getInitializer());
686
- if (reasonText === undefined) continue;
687
- // Empty/whitespace is the boot validator's job — no double-check.
688
- if (reasonText.trim() === "") continue;
689
- if (!isGenericReason(reasonText)) continue;
690
- out.push({
691
- file: path.relative(root, sf.getFilePath()),
692
- line: pa.getStartLineNumber(),
693
- message: `${propertyName}: { reason: "${reasonText}" } uses a placeholder reason — give a concrete, reviewable justification for this cross-tenant/unsafe access.`,
694
- });
695
- }
696
- return out;
697
- }
698
-
699
- export function findGenericReasonCalls(
700
- files: readonly SourceFile[],
701
- root: string,
702
- ): GenericReasonFinding[] {
703
- const out: GenericReasonFinding[] = [];
704
- for (const sf of scannableFiles(files)) {
705
- out.push(...findGenericReasonMethodCalls(sf, root));
706
- out.push(...findGenericReasonDeclareEscapeHatchCalls(sf, root));
707
- for (const propertyName of REASON_OBJECT_PROPERTY_NAMES) {
708
- out.push(...findGenericReasonObjectProperty(sf, root, propertyName));
709
- }
710
- }
711
- return out;
712
- }
713
-
714
- export function createEscapeHatchGuard(opts: { root: string }): AstGuard {
715
- return {
716
- name: "Escape-Hatch-Declared Guard",
717
- scan: SCAN,
718
- security: true,
719
- hint: 'Declare an escape hatch (r.systemScope() on the feature definition, .job.ts/r.job(...) for jobs, or { escapeHatch: { reason: "..." } } on the handler or hook), or remove the ctx.db.raw/unsafeRaw/queryAs|writeAs(system)/unsafeAllTenants access. Baseline after a deliberate reduction: `kumiko-guards guards --write-security-baseline`',
720
- run(files) {
721
- const violations: GuardViolation[] = [
722
- ...findGenericReasonCalls(files, opts.root).map((f) => ({
723
- file: f.file,
724
- line: f.line,
725
- message: f.message,
726
- neverFrozen: true,
727
- })),
728
- ...findEscapeHatchFindings(files, opts.root).map((f) => ({
729
- file: f.file,
730
- line: f.line,
731
- message: f.message,
732
- })),
733
- ];
734
- return { violations };
735
- },
736
- };
737
- }
738
-
739
- export const guard = createEscapeHatchGuard({ root: process.cwd() });
740
-
741
- if (import.meta.main) {
742
- runStandalone(guard);
743
- }