@cosmicdrift/kumiko-guards 0.383.0 → 0.384.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.383.0",
3
+ "version": "0.384.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>",
@@ -51,7 +51,7 @@
51
51
  "LICENSE"
52
52
  ],
53
53
  "peerDependencies": {
54
- "@cosmicdrift/kumiko-framework": "0.383.0"
54
+ "@cosmicdrift/kumiko-framework": "0.384.0"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@cosmicdrift/kumiko-framework": {
@@ -59,6 +59,6 @@
59
59
  }
60
60
  },
61
61
  "devDependencies": {
62
- "@cosmicdrift/kumiko-framework": "0.383.0"
62
+ "@cosmicdrift/kumiko-framework": "0.384.0"
63
63
  }
64
64
  }
package/src/changes.json CHANGED
@@ -1,4 +1,32 @@
1
1
  [
2
+ {
3
+ "version": "0.384.0",
4
+ "type": "improvement",
5
+ "title": "Unsafe-Append-Event guard removed",
6
+ "detail": "The event store rejects unregistered event types and validates every payload against its schema at runtime, for appendEvent and unsafeAppendEvent alike, so the static guard is redundant.",
7
+ "migration": "Delete `.kumiko-unsafe-append-event-baseline.json`."
8
+ },
9
+ {
10
+ "version": "0.384.0",
11
+ "type": "improvement",
12
+ "title": "Access-Denied-Test guard replaced by a contract test helper",
13
+ "detail": "Roles are checked centrally before the payload is parsed. The new `expectRoleRestrictedHandlersDenyForeignRole` test helper proves this over HTTP for every role-restricted write and query handler, so the guard that searched for a matching test per handler is removed.",
14
+ "migration": "In an app integration test with setupTestStack and the app features, call `expectRoleRestrictedHandlersDenyForeignRole(stack)` and remove the \"Access-Denied-Test Guard\" entry from `.kumiko-security-baseline.json`."
15
+ },
16
+ {
17
+ "version": "0.384.0",
18
+ "type": "improvement",
19
+ "title": "i18n-Keys guard no longer checks locales",
20
+ "detail": "The guard only reports keys that are used without a definition. Locale completeness moved to the boot validator, which sees the real registered translations of every feature.",
21
+ "migration": "Delete `src/i18n-guard-locales.ts` if it exists."
22
+ },
23
+ {
24
+ "version": "0.384.0",
25
+ "type": "improvement",
26
+ "title": "PII-Annotations guard removed",
27
+ "detail": "Entity text and longText fields now have to declare their personal stance by type and at boot, so the static guard that searched for unannotated fields is redundant.",
28
+ "migration": "Delete `.kumiko-pii-annotations-baseline.json`."
29
+ },
2
30
  {
3
31
  "version": "0.383.0",
4
32
  "type": "improvement",
@@ -46,7 +46,7 @@
46
46
  // own production surface — see kumiko-framework#2337 for the one currently
47
47
  // frozen here). Exceptions are tracked via a per-repo baseline file
48
48
  // (`.kumiko-runtime-isolation-baseline.json`, same `baselineRatchet`
49
- // mechanism as check-complexity/guard-pii-annotations/etc.), not a
49
+ // mechanism as check-complexity/etc.), not a
50
50
  // hardcoded list in this source file: a consumer repo can freeze or clear
51
51
  // its own findings the same way, with `--write-baseline`.
52
52
  //
@@ -3,13 +3,13 @@
3
3
  * Guard: i18n keys must be defined before they are used.
4
4
  *
5
5
  * Scan: t() calls in app tsx, definitions from r.translations and i18n bundles.
6
- * Declarative screen/nav keys: validateBoot (runtime).
6
+ * Declarative screen/nav keys and per-feature locale completeness: validateBoot (runtime).
7
7
  *
8
8
  * Usage:
9
9
  * bun guards/guard-i18n-keys.ts
10
10
  */
11
11
 
12
- import { existsSync, readFileSync } from "node:fs";
12
+ import { existsSync } from "node:fs";
13
13
  import * as path from "node:path";
14
14
  import {
15
15
  type Node,
@@ -37,7 +37,6 @@ interface UsedKey {
37
37
 
38
38
  interface DefinedKey {
39
39
  fullKey: string;
40
- locales: Set<string>;
41
40
  file: string;
42
41
  line: number;
43
42
  }
@@ -105,10 +104,10 @@ function findEnclosingFeatureName(node: Node): string | null {
105
104
  return null;
106
105
  }
107
106
 
108
- function extractKeysFromTranslationsObject(
109
- obj: ObjectLiteralExpression,
110
- ): Array<{ key: string; locales: Set<string>; line: number }> {
111
- const out: Array<{ key: string; locales: Set<string>; line: number }> = [];
107
+ type KeyEntry = { key: string; line: number };
108
+
109
+ function extractKeysFromTranslationsObject(obj: ObjectLiteralExpression): KeyEntry[] {
110
+ const out: KeyEntry[] = [];
112
111
  for (const prop of obj.getProperties()) {
113
112
  if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
114
113
  const nameNode = prop.getNameNode();
@@ -119,23 +118,13 @@ function extractKeysFromTranslationsObject(
119
118
  keyName = nameNode.getText();
120
119
  } else continue;
121
120
 
122
- const initializer = prop.getInitializer();
123
- if (!initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
124
- const locales = new Set<string>();
125
- for (const localeProp of initializer.getProperties()) {
126
- if (!localeProp.isKind(SyntaxKind.PropertyAssignment)) continue;
127
- const localeName = localeProp.getNameNode();
128
- if (localeName.isKind(SyntaxKind.StringLiteral)) locales.add(localeName.getLiteralText());
129
- else if (localeName.isKind(SyntaxKind.Identifier)) locales.add(localeName.getText());
130
- }
131
- out.push({ key: keyName, locales, line: prop.getStartLineNumber() });
121
+ if (!prop.getInitializer()?.isKind(SyntaxKind.ObjectLiteralExpression)) continue;
122
+ out.push({ key: keyName, line: prop.getStartLineNumber() });
132
123
  }
133
124
  return out;
134
125
  }
135
126
 
136
- function extractLocaleFirstKeys(
137
- obj: ObjectLiteralExpression,
138
- ): Array<{ key: string; locales: Set<string>; line: number }> {
127
+ function extractLocaleFirstKeys(obj: ObjectLiteralExpression): KeyEntry[] {
139
128
  const localeMaps = new Map<string, ObjectLiteralExpression>();
140
129
  for (const prop of obj.getProperties()) {
141
130
  if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
@@ -158,45 +147,26 @@ function extractLocaleFirstKeys(
158
147
  }
159
148
  }
160
149
 
161
- const out: Array<{ key: string; locales: Set<string>; line: number }> = [];
162
- for (const key of keySet) {
163
- const locales = new Set<string>();
164
- for (const [locale, map] of localeMaps) {
165
- const has = map.getProperties().some((prop) => {
166
- if (!prop.isKind(SyntaxKind.PropertyAssignment)) return false;
167
- const nameNode = prop.getNameNode();
168
- return nameNode.isKind(SyntaxKind.StringLiteral) && nameNode.getLiteralText() === key;
169
- });
170
- if (has) locales.add(locale);
171
- }
172
- out.push({ key, locales, line: obj.getStartLineNumber() });
173
- }
174
- return out;
150
+ return [...keySet].map((key) => ({ key, line: obj.getStartLineNumber() }));
175
151
  }
176
152
 
177
- function pushDefined(
178
- defined: DefinedKey[],
179
- fullKey: string,
180
- locales: Set<string>,
181
- file: string,
182
- line: number,
183
- ): void {
184
- defined.push({ fullKey, locales, file, line });
153
+ function pushDefined(defined: DefinedKey[], fullKey: string, file: string, line: number): void {
154
+ defined.push({ fullKey, file, line });
185
155
  }
186
156
 
187
157
  function addDefinedEntries(
188
158
  defined: DefinedKey[],
189
- entries: Array<{ key: string; locales: Set<string>; line: number }>,
159
+ entries: KeyEntry[],
190
160
  file: string,
191
161
  featureName: string | null,
192
162
  ): void {
193
163
  for (const entry of entries) {
194
164
  if (entry.key.includes(":")) {
195
- pushDefined(defined, entry.key, entry.locales, file, entry.line);
165
+ pushDefined(defined, entry.key, file, entry.line);
196
166
  continue;
197
167
  }
198
168
  if (featureName) {
199
- pushDefined(defined, `${featureName}:${entry.key}`, entry.locales, file, entry.line);
169
+ pushDefined(defined, `${featureName}:${entry.key}`, file, entry.line);
200
170
  }
201
171
  }
202
172
  }
@@ -255,9 +225,9 @@ function collectInlineKeysInitializer(
255
225
  ): void {
256
226
  if (initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) {
257
227
  for (const entry of extractKeysFromTranslationsObject(initializer)) {
258
- pushDefined(defined, `${featureName}:${entry.key}`, entry.locales, file, entry.line);
228
+ pushDefined(defined, `${featureName}:${entry.key}`, file, entry.line);
259
229
  if (entry.key.includes(":")) {
260
- pushDefined(defined, entry.key, entry.locales, file, entry.line);
230
+ pushDefined(defined, entry.key, file, entry.line);
261
231
  }
262
232
  }
263
233
  } else if (initializer?.isKind(SyntaxKind.Identifier)) {
@@ -305,77 +275,6 @@ export function isFrameworkMonorepo(root: string): boolean {
305
275
  return existsSync(path.join(root, "packages/framework/package.json"));
306
276
  }
307
277
 
308
- /** Parse `export const NAME = [...]` locale literals. Returns null if absent;
309
- * throws if the export exists but is not a static string-literal array
310
- * (spread / alias / region-tag-only without literals) — silent de/en fallback
311
- * would hide a real declaration (infra#602). */
312
- export function parseLocaleConstArray(source: string, constName: string): string[] | null {
313
- const exportRe = new RegExp(`export\\s+const\\s+${constName}\\s*(?::[^=]+)?=\\s*([^;]+)`);
314
- const match = source.match(exportRe);
315
- if (!match?.[1]) return null;
316
- const rhs = match[1].trim();
317
- if (!rhs.startsWith("[")) {
318
- throw new Error(
319
- `i18n-keys: export const ${constName} is not a static array literal — refuse silent de/en fallback`,
320
- );
321
- }
322
- if (rhs.includes("...")) {
323
- throw new Error(
324
- `i18n-keys: export const ${constName} uses array spreads — refuse partial/silent locale set`,
325
- );
326
- }
327
- const locales = [...rhs.matchAll(/["']([a-z]{2}(?:-[A-Z]{2})?)["']/g)]
328
- .map((m) => m[1])
329
- .filter((l): l is string => l !== undefined);
330
- if (locales.length === 0) {
331
- throw new Error(
332
- `i18n-keys: export const ${constName} = [...] has no string locale literals — refuse silent de/en fallback`,
333
- );
334
- }
335
- return locales;
336
- }
337
-
338
- /** Resolve owning repo root from a guard-relative path (may be `../…`). */
339
- export function repoRootForRelFile(rel: string): string {
340
- const abs = path.isAbsolute(rel) ? rel : path.resolve(ROOT, rel);
341
- let curr = path.dirname(abs);
342
- let fallback: string | undefined;
343
- while (curr !== path.dirname(curr)) {
344
- const base = path.basename(curr);
345
- // Never treat worktree/parent markers as a repo root (infra#602).
346
- if (base === ".." || base === ".wt") {
347
- curr = path.dirname(curr);
348
- continue;
349
- }
350
- if (existsSync(path.join(curr, "packages/framework/package.json"))) {
351
- return curr;
352
- }
353
- if (existsSync(path.join(curr, "package.json"))) {
354
- // Outermost package.json wins so nested packages/* resolve to the repo.
355
- fallback = curr;
356
- }
357
- curr = path.dirname(curr);
358
- }
359
- return fallback ?? ROOT;
360
- }
361
- /** Repo-declared locales for translation completeness; empty set skips the check. */
362
- export function resolveExpectedLocales(root: string = ROOT): Set<string> {
363
- if (isFrameworkMonorepo(root)) return new Set();
364
-
365
- const declPaths: Array<{ rel: string; constName: string }> = [
366
- { rel: "src/i18n-guard-locales.ts", constName: "I18N_GUARD_LOCALES" },
367
- { rel: "src/marketing/locale-routes.ts", constName: "LOCALES" },
368
- ];
369
- for (const { rel, constName } of declPaths) {
370
- const abs = path.join(root, rel);
371
- if (!existsSync(abs)) continue;
372
- const locales = parseLocaleConstArray(readFileSync(abs, "utf-8"), constName);
373
- if (locales) return new Set(locales);
374
- // parseLocaleConstArray throws on unreadable decls; null = no export.
375
- }
376
- return new Set(["de", "en"]);
377
- }
378
-
379
278
  // Published packages ship `dist` (compiled JS) only; workspace links also carry `src`.
380
279
  const INSTALLED_FRAMEWORK_SOURCES = [
381
280
  "kumiko-*/{src,dist}/**/*.{ts,tsx,js}",
@@ -424,8 +323,6 @@ export function collectInstalledFrameworkKeys(root: string = ROOT): Set<string>
424
323
  export type I18nKeysRunOptions = {
425
324
  /** Inject the framework keys of the installed packages — tests must not scan node_modules. */
426
325
  readonly installedFrameworkKeys?: () => ReadonlySet<string>;
427
- /** Inject expected locales per owning root — tests must not rely on process.cwd(). */
428
- readonly expectedLocalesForRoot?: (root: string) => Set<string>;
429
326
  };
430
327
 
431
328
  export function checkI18nKeys(
@@ -451,47 +348,19 @@ export function checkI18nKeys(
451
348
  ? new Set<string>()
452
349
  : (options.installedFrameworkKeys ?? collectInstalledFrameworkKeys)();
453
350
  const missing = undefinedInApp.filter((u) => !installedKeys.has(u.key));
454
- const resolveLocales = options.expectedLocalesForRoot ?? resolveExpectedLocales;
455
- const expectedByRoot = new Map<string, Set<string>>();
456
- const expectedForFile = (rel: string): Set<string> => {
457
- const root = repoRootForRelFile(rel);
458
- let locales = expectedByRoot.get(root);
459
- if (!locales) {
460
- locales = resolveLocales(root);
461
- expectedByRoot.set(root, locales);
462
- }
463
- return locales;
464
- };
465
-
466
- const localeViolations = definedKeys.flatMap((d) => {
467
- const expectedLocales = expectedForFile(d.file);
468
- const missingLocales = [...expectedLocales].filter((l) => !d.locales.has(l));
469
- if (missingLocales.length === 0) return [];
470
- return [
471
- {
472
- file: d.file,
473
- line: d.line,
474
- message: `Key "${d.fullKey}" is missing locale: ${missingLocales.join(", ")}`,
475
- },
476
- ];
477
- });
478
-
479
351
  return {
480
- violations: [
481
- ...missing.map((m) => ({
482
- file: m.file,
483
- line: m.line,
484
- message: `used key without a definition: "${m.key}"`,
485
- })),
486
- ...localeViolations,
487
- ],
352
+ violations: missing.map((m) => ({
353
+ file: m.file,
354
+ line: m.line,
355
+ message: `used key without a definition: "${m.key}"`,
356
+ })),
488
357
  };
489
358
  }
490
359
 
491
360
  export const guard: AstGuard = {
492
361
  name: "i18n-Keys Guard",
493
362
  scan: SCAN,
494
- hint: "Used i18n key without a definition, or missing locale — add the key/locale to the feature's translations map (locales from src/i18n-guard-locales.ts or src/marketing/locale-routes.ts).",
363
+ hint: "Used i18n key without a definition — add the key to the feature's translations map.",
495
364
  run(files) {
496
365
  return checkI18nKeys(files);
497
366
  },
@@ -173,10 +173,9 @@ export const guard: AstGuard = {
173
173
 
174
174
  // Flags are read ONLY here, not in run() — the shared runner
175
175
  // (run-guards.ts/run-ui-guards.ts) runs every guard with the same argv, a
176
- // --write-baseline there must not silently rewrite the baseline (same
177
- // pattern as guard-pii-annotations.ts).
176
+ // --write-baseline there must not silently rewrite the baseline.
178
177
  //
179
- // No --no-baseline: unlike guard-pii-annotations.ts this guard has a
178
+ // No --no-baseline: this guard has a
180
179
  // second, non-baselined rule (PRIMITIVE_NAME) — a --no-baseline that
181
180
  // discards analyse()'s return and exits 0 would silence that too. Nobody
182
181
  // reads the flag (the shared runner only calls run()).
@@ -22,7 +22,7 @@
22
22
  * compare WITH their modifier prefixes intact (`hover:mb-2` is its own
23
23
  * Tailwind candidate, distinct from `mb-2`).
24
24
  *
25
- * Baseline-regression guard like guard-pii-annotations.ts: pins the
25
+ * Baseline-regression guard: pins the
26
26
  * currently-known violation count per file. Reductions are allowed but
27
27
  * don't auto-update the baseline. Without a baseline file the guard stays
28
28
  * warning-only (bootstrap: run `--write-baseline` once).
package/src/run-guards.ts CHANGED
@@ -23,7 +23,6 @@ import { writeSecurityBaselines } from "./_lib/security-baseline-cli";
23
23
  import { guard as asCasts } from "./check-as-casts";
24
24
  import { guard as complexity } from "./check-complexity";
25
25
  import { guard as predicateExtraction } from "./check-predicates";
26
- import { guard as accessDeniedTest } from "./guard-access-denied-test";
27
26
  import { guard as adminApi } from "./guard-admin-api";
28
27
  import { guard as appFeatureStructure } from "./guard-app-feature-structure";
29
28
  import { guard as crossFeatureImports } from "./guard-cross-feature-imports";
@@ -42,19 +41,16 @@ import { guard as noDateApi } from "./guard-no-date-api";
42
41
  import { guard as noDirectFs } from "./guard-no-direct-fs";
43
42
  import { guard as noLogicInViews } from "./guard-no-logic-in-views";
44
43
  import { guard as noTemporalPolyfillImport } from "./guard-no-temporal-polyfill-import";
45
- import { guard as piiAnnotations } from "./guard-pii-annotations";
46
44
  import { guard as restrictedSymbols } from "./guard-restricted-symbols";
47
45
  import { guard as screenConventions } from "./guard-screen-conventions";
48
46
  import { guard as silentSkip } from "./guard-silent-skip";
49
47
  import { guard as tenantEscalation } from "./guard-tenant-escalation";
50
48
  import { guard as testTemplateDrift } from "./guard-test-template-drift";
51
49
  import { guard as testTimeouts } from "./guard-test-timeouts";
52
- import { guard as unsafeAppendEvent } from "./guard-unsafe-append-event";
53
50
  import { guard as unsafeJsonParse } from "./guard-unsafe-json-parse";
54
51
  import { guard as writeHandlerQns } from "./guard-write-handler-qns";
55
52
 
56
53
  export const GUARDS = [
57
- accessDeniedTest,
58
54
  adminApi,
59
55
  directEntityWrites,
60
56
  directFetch,
@@ -74,7 +70,6 @@ export const GUARDS = [
74
70
  i18nKeys,
75
71
  i18nLocaleMount,
76
72
  i18nLocaleTerminology,
77
- piiAnnotations,
78
73
  complexity,
79
74
  predicateExtraction,
80
75
  screenConventions,
@@ -85,7 +80,6 @@ export const GUARDS = [
85
80
  libTestCoverage,
86
81
  testTimeouts,
87
82
  testTemplateDrift,
88
- unsafeAppendEvent,
89
83
  ];
90
84
 
91
85
  export const GUARD_FLAGS = [
@@ -1,167 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Guard: role-restricted write handlers need an access-denied test.
4
- *
5
- * Rule: any `defineWriteHandler`/`*.writeHandler` call whose `access` object
6
- * has a `roles` property (and no `openToAll`, and no anonymous/all role) must
7
- * have a test somewhere in the repo that mentions the handler AND asserts a
8
- * rejection (AccessDeniedError / access_denied / 403). Matching is file-level
9
- * and within the handler's own repo, same contract as guard-tenant-escalation.
10
- *
11
- * Tripwire: a false-negative (a weak name match hiding a real gap) is
12
- * tolerated; a false-positive on a clean repo is not.
13
- *
14
- * Usage:
15
- * bun guards/guard-access-denied-test.ts
16
- * Baseline: kumiko-guards guards --write-security-baseline
17
- */
18
-
19
- import * as path from "node:path";
20
- import {
21
- type CallExpression,
22
- type ObjectLiteralExpression,
23
- type SourceFile,
24
- SyntaxKind,
25
- } from "ts-morph";
26
- import { findRepoRootFor } from "./_lib/baseline-compare";
27
- import {
28
- ALL_REPO_KINDS,
29
- type AstGuard,
30
- type GuardViolation,
31
- runStandalone,
32
- type ScanSpec,
33
- } from "./_lib/guard-kit";
34
- import {
35
- escapeRegExp,
36
- literalStringOf,
37
- mentionsAsWord,
38
- nameForms,
39
- } from "./_lib/handler-name-forms";
40
- import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
41
- import { TEST_FILE_RE } from "./_lib/test-file";
42
-
43
- const ROOT = process.cwd();
44
- const SCAN: ScanSpec = {
45
- kinds: ALL_REPO_KINDS,
46
- scope: "source+tests",
47
- extensions: ["ts"],
48
- frameworkWithin: ["packages/*/src/**", "samples/**"],
49
- };
50
-
51
- // Any role that lets an unauthenticated/unrestricted caller through means
52
- // nobody is actually excluded by this handler's roles.
53
- const OPEN_ROLE_RE = /["'`](?:anonymous|all)["'`]|\baccess\.(?:all|anonymous)\b/;
54
-
55
- type RoleRestrictedHandler = { name: string; file: string; line: number };
56
-
57
- function writeHandlerOptions(call: CallExpression): ObjectLiteralExpression | undefined {
58
- const callee = call.getExpression().getText();
59
- if (callee !== "defineWriteHandler" && !/\.writeHandler$/.test(callee)) return undefined;
60
- const arg = call.getArguments()[0];
61
- if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) return undefined;
62
- return arg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
63
- }
64
-
65
- function accessObjectOf(obj: ObjectLiteralExpression): ObjectLiteralExpression | undefined {
66
- const accessProp = obj.getProperty("access");
67
- if (!accessProp || accessProp.getKind() !== SyntaxKind.PropertyAssignment) return undefined;
68
- const accessInit = accessProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
69
- if (!accessInit || accessInit.getKind() !== SyntaxKind.ObjectLiteralExpression) return undefined;
70
- return accessInit.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
71
- }
72
-
73
- function restrictsToRoles(accessObj: ObjectLiteralExpression): boolean {
74
- if (accessObj.getProperty("openToAll")) return false;
75
- const rolesProp = accessObj.getProperty("roles");
76
- if (!rolesProp) return false;
77
- if (
78
- rolesProp.getKind() !== SyntaxKind.PropertyAssignment &&
79
- rolesProp.getKind() !== SyntaxKind.ShorthandPropertyAssignment
80
- )
81
- return false;
82
- return !OPEN_ROLE_RE.test(rolesProp.getText());
83
- }
84
-
85
- function roleRestrictedHandlerOf(
86
- call: CallExpression,
87
- filePath: string,
88
- ): RoleRestrictedHandler | undefined {
89
- const obj = writeHandlerOptions(call);
90
- if (!obj) return undefined;
91
- const name = literalStringOf(obj.getProperty("name"));
92
- if (!name) return undefined;
93
- const accessObj = accessObjectOf(obj);
94
- if (!accessObj || !restrictsToRoles(accessObj)) return undefined;
95
- return { name, file: filePath, line: call.getStartLineNumber() };
96
- }
97
-
98
- export function findRoleRestrictedWriteHandlers(
99
- files: readonly SourceFile[],
100
- ): RoleRestrictedHandler[] {
101
- const out: RoleRestrictedHandler[] = [];
102
- for (const sf of files) {
103
- if (TEST_FILE_RE.test(sf.getFilePath())) continue;
104
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
105
- const handler = roleRestrictedHandlerOf(call, sf.getFilePath());
106
- if (handler) out.push(handler);
107
- }
108
- }
109
- return out;
110
- }
111
-
112
- const ACCESS_DENIED_ASSERTION = /\bAccessDeniedError\b|\baccess_denied\b|\b403\b/;
113
-
114
- const SHORT_WORD = /^[a-z0-9]+$/;
115
-
116
- // A single lowercase word ("create", "approve") is common enough in
117
- // unrelated test scaffolding (createTestStack, approveAll) that a bare word-
118
- // boundary match is too loose — only count it as a string-literal end
119
- // ("approve"/"feat:write:approve") or a property access (Handlers.approve).
120
- export function testMentionsHandler(text: string, handlerName: string): boolean {
121
- return nameForms(handlerName).some((form) => {
122
- if (!SHORT_WORD.test(form)) return mentionsAsWord(text, form);
123
- const esc = escapeRegExp(form);
124
- return new RegExp(`[:"'\`]${esc}["'\`]|\\.${esc}\\b`).test(text);
125
- });
126
- }
127
-
128
- export function findHandlersWithoutAccessDeniedTest(
129
- files: readonly SourceFile[],
130
- roots: readonly { readonly absPath: string }[] = [],
131
- ): RoleRestrictedHandler[] {
132
- const repoKeyOf = (filePath: string): string => findRepoRootFor(filePath, roots)?.absPath ?? "";
133
- const testTextsByRepo = new Map<string, string[]>();
134
- for (const sf of files) {
135
- if (!TEST_FILE_RE.test(sf.getFilePath())) continue;
136
- const text = sf.getFullText();
137
- if (!ACCESS_DENIED_ASSERTION.test(text)) continue;
138
- const repoKey = repoKeyOf(sf.getFilePath());
139
- const texts = testTextsByRepo.get(repoKey) ?? [];
140
- texts.push(text);
141
- testTextsByRepo.set(repoKey, texts);
142
- }
143
- return findRoleRestrictedWriteHandlers(files).filter((h) => {
144
- const testTexts = testTextsByRepo.get(repoKeyOf(h.file)) ?? [];
145
- return !testTexts.some((text) => testMentionsHandler(text, h.name));
146
- });
147
- }
148
-
149
- export const guard: AstGuard = {
150
- name: "Access-Denied-Test Guard",
151
- scan: SCAN,
152
- security: true,
153
- hint: "Role-restricted write handlers need a test that references the handler and asserts a caller without the role is rejected (AccessDeniedError / access_denied / 403). Existing gaps are frozen in the security baseline; after closing one: `kumiko-guards guards --write-security-baseline`.",
154
- run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
155
- // consumer CI scans only its own repo, so a sibling repo's test must not count as coverage
156
- const violations: GuardViolation[] = findHandlersWithoutAccessDeniedTest(files, roots).map(
157
- (h) => ({
158
- file: path.relative(ROOT, h.file),
159
- line: h.line,
160
- message: `role-restricted write handler "${h.name}" has no access-denied test — add a test that references the handler and asserts a caller without the role is rejected (AccessDeniedError / access_denied / 403).`,
161
- }),
162
- );
163
- return { violations };
164
- },
165
- };
166
-
167
- if (import.meta.main) runStandalone(guard);
@@ -1,279 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Guard: PII-typical entity field names without annotation, with a baseline
4
- * regression guard like check-complexity.ts.
5
- *
6
- * Mirrors the boot heuristic from validatePiiAndRetention. Authors mark
7
- * fields with { pii: true }, { userOwned }, { tenantOwned: true },
8
- * { allowPlaintext: "reason" } (legacy) or { personal: ... } (0.210.0
9
- * successor to the four legacy subject annotations).
10
- *
11
- * `.kumiko-pii-annotations-baseline.json` in the repo root pins the frozen
12
- * finding count per file:
13
- * - current <= baseline per file: PASS
14
- * - current > baseline per file: FAIL (new unannotated PII field)
15
- * Reductions do NOT update the baseline automatically — run `--write-baseline`
16
- * after annotation commits. Without a baseline file the guard stays
17
- * warning-only (bootstrap: run `--write-baseline` once).
18
- *
19
- * Usage:
20
- * bun guards/guard-pii-annotations.ts # compare against baseline
21
- * bun guards/guard-pii-annotations.ts --write-baseline # rewrite baseline
22
- * bun guards/guard-pii-annotations.ts --no-baseline # skip comparison
23
- */
24
- import * as path from "node:path";
25
- import {
26
- type CallExpression,
27
- type Node,
28
- type ObjectLiteralExpression,
29
- type SourceFile,
30
- SyntaxKind,
31
- } from "ts-morph";
32
- import {
33
- type AstGuard,
34
- baselineRatchet,
35
- buildSharedProject,
36
- filesForGuard,
37
- type GuardOutcome,
38
- type GuardViolation,
39
- isLocalFinding,
40
- relFromRepoRoot,
41
- runStandalone,
42
- type ScanSpec,
43
- } from "./_lib/guard-kit";
44
- import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
45
-
46
- const ROOT = process.cwd();
47
-
48
- const SCAN: ScanSpec = {
49
- scope: "source",
50
- extensions: ["ts"],
51
- frameworkWithin: ["packages/*/src/**"],
52
- };
53
- const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
54
-
55
- // Keep in sync with boot-validator/entity-handler.ts PII_*_NAME_HINTS.
56
- const PII_DIRECT_NAME_HINTS = new Set([
57
- "email",
58
- "phone",
59
- "phonenumber",
60
- "mobile",
61
- "address",
62
- "street",
63
- "postalcode",
64
- "zipcode",
65
- "zip",
66
- "city",
67
- "displayname",
68
- "firstname",
69
- "lastname",
70
- "fullname",
71
- "birthday",
72
- "birthdate",
73
- "dateofbirth",
74
- "dob",
75
- "ssn",
76
- "taxid",
77
- "vatid",
78
- "passport",
79
- "iban",
80
- "bic",
81
- ]);
82
-
83
- const PII_USER_OWNED_NAME_HINTS = new Set([
84
- "body",
85
- "text",
86
- "content",
87
- "message",
88
- "comment",
89
- "description",
90
- "note",
91
- "notes",
92
- ]);
93
-
94
- function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
95
- return relFromRepoRoot(sf.getFilePath(), roots);
96
- }
97
-
98
- function fieldFactoryOptions(call: CallExpression): ObjectLiteralExpression | undefined {
99
- const first = call.getArguments()[0];
100
- return first?.isKind(SyntaxKind.ObjectLiteralExpression) ? first : undefined;
101
- }
102
-
103
- // Subject annotations answering "is this PII" (kept in sync with the
104
- // author-facing PersonalAnnotations type, @cosmicdrift/kumiko-types
105
- // packages/types/src/fields.ts). `personal` (0.210.0) is the successor
106
- // to the four legacy fields below — every value counts as answered,
107
- // including `personal: false` (the framework requires a `reason` for
108
- // it). `find` is NOT a silencer: it resolves to lookupable/searchable/
109
- // sensitive, which never answered the PII question either. `encrypted`
110
- // isn't one either — the boot validator checks it only together with
111
- // `sensitive` (ciphertext-at-rest), never as a substitute.
112
- function isPiiSilencerAssignment(name: string, init: Node | undefined): boolean {
113
- if (name === "personal") {
114
- return (
115
- init !== undefined &&
116
- init.getKind() !== SyntaxKind.UndefinedKeyword &&
117
- !(init.isKind(SyntaxKind.Identifier) && init.getText() === "undefined") &&
118
- init.getKind() !== SyntaxKind.NullKeyword
119
- );
120
- }
121
- if (name === "allowPlaintext") return init?.isKind(SyntaxKind.StringLiteral) ?? false;
122
- if (name === "pii") return init?.getKind() === SyntaxKind.TrueKeyword;
123
- if (name === "tenantOwned") return init?.getKind() === SyntaxKind.TrueKeyword;
124
- if (name === "userOwned") return init?.isKind(SyntaxKind.ObjectLiteralExpression) ?? false;
125
- return false;
126
- }
127
-
128
- function objectHasPiiSilencer(obj: ObjectLiteralExpression): boolean {
129
- for (const prop of obj.getProperties()) {
130
- if (!prop.isKind(SyntaxKind.PropertyAssignment)) continue;
131
- if (isPiiSilencerAssignment(prop.getName(), prop.getInitializer())) return true;
132
- }
133
- return false;
134
- }
135
-
136
- function enclosingFieldName(call: CallExpression): string | null {
137
- let node = call.getParent();
138
- while (node) {
139
- if (node.isKind(SyntaxKind.PropertyAssignment)) {
140
- return node.getName();
141
- }
142
- node = node.getParent();
143
- }
144
- return null;
145
- }
146
-
147
- export interface Finding {
148
- file: string;
149
- line: number;
150
- fieldName: string;
151
- hint: string;
152
- }
153
-
154
- function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding[] {
155
- const findings: Finding[] = [];
156
- for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
157
- const callee = call.getExpression().getText();
158
- if (callee !== "createTextField" && callee !== "createLongTextField") continue;
159
-
160
- const fieldName = enclosingFieldName(call);
161
- if (!fieldName) continue;
162
-
163
- const options = fieldFactoryOptions(call);
164
- if (options && objectHasPiiSilencer(options)) continue;
165
-
166
- const lower = fieldName.toLowerCase();
167
- let hint: string | null = null;
168
- if (PII_DIRECT_NAME_HINTS.has(lower)) {
169
- hint = `{ personal: ... } or { pii: true } or { allowPlaintext: "is-business-data" }`;
170
- } else if (PII_USER_OWNED_NAME_HINTS.has(lower)) {
171
- hint = `{ personal: { of: "<authorIdField>" }, find: "exact" } or { userOwned: { ownerField: "<authorIdField>" } } or { allowPlaintext: "..." }`;
172
- }
173
- if (!hint) continue;
174
-
175
- const line = call.getStartLineNumber();
176
- const file = relFile(sf, roots);
177
- findings.push({ file, line, fieldName, hint });
178
- }
179
- return findings;
180
- }
181
-
182
- export function scan(
183
- files: readonly SourceFile[],
184
- roots: readonly RepoRoot[] = resolveRepoRoots(),
185
- ): {
186
- findings: Finding[];
187
- scanned: number;
188
- } {
189
- const findings: Finding[] = [];
190
- let scanned = 0;
191
- for (const sf of files) {
192
- if (EXCLUDE.test(sf.getFilePath())) continue;
193
- scanned++;
194
- findings.push(...scanFieldFactories(sf, roots));
195
- }
196
- return { findings, scanned };
197
- }
198
-
199
- function localFindings(findings: readonly Finding[]): readonly Finding[] {
200
- return findings.filter(isLocalFinding);
201
- }
202
-
203
- function countByFile(findings: readonly Finding[]): Record<string, number> {
204
- const counts: Record<string, number> = {};
205
- for (const f of findings) counts[f.file] = (counts[f.file] ?? 0) + 1;
206
- return counts;
207
- }
208
-
209
- const BASELINE_FILE = ".kumiko-pii-annotations-baseline.json";
210
- const piiBaseline = baselineRatchet({
211
- file: path.join(ROOT, BASELINE_FILE),
212
- formatVersion: 1,
213
- unit: "PII finding(s)",
214
- });
215
-
216
- // One place for "what goes into the baseline", used by both the compare and
217
- // the write path — a sibling finding leaking into the written file would turn
218
- // a foreign checkout's code into a local, unfixable red.
219
- export function baselineCounts(findings: readonly Finding[]): Record<string, number> {
220
- return countByFile(localFindings(findings));
221
- }
222
-
223
- function checkBaseline(findings: readonly Finding[]): GuardViolation[] {
224
- const local = localFindings(findings);
225
- const resolveLine = (file: string): number => local.find((f) => f.file === file)?.line ?? 1;
226
- return piiBaseline.check(
227
- baselineCounts(findings),
228
- `Annotate the field ({ personal: ... } / { pii: true } / { userOwned: ... } / { tenantOwned: true } / { allowPlaintext: "..." }).`,
229
- {
230
- formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard="PII-Annotations Guard"\` once.`,
231
- resolveLine,
232
- },
233
- );
234
- }
235
-
236
- function analyse(
237
- files: readonly SourceFile[],
238
- roots: readonly RepoRoot[],
239
- compareBaseline: boolean,
240
- ): GuardOutcome {
241
- const { findings } = scan(files, roots);
242
- if (!compareBaseline) {
243
- for (const f of findings)
244
- console.warn(
245
- ` [pii-annotations WARN] ${f.file}:${f.line} field "${f.fieldName}" looks like PII — mark ${f.hint}`,
246
- );
247
- console.log(" Baseline comparison skipped (--no-baseline).");
248
- return { violations: [] };
249
- }
250
- return { violations: checkBaseline(findings) };
251
- }
252
-
253
- export const guard: AstGuard = {
254
- name: "PII-Annotations Guard",
255
- scan: SCAN,
256
- hint: 'after a deliberate annotation: `kumiko-guards guards --write-baseline --guard="PII-Annotations Guard"`',
257
- run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
258
- writeBaseline: (files) => piiBaseline.write(baselineCounts(scan(files).findings)),
259
- };
260
-
261
- // Flags are read ONLY here, not in run() — the shared runner
262
- // (run-guards.ts) drives all guards with the same argv, and a
263
- // --write-baseline there must not silently rewrite the
264
- // baseline.
265
- if (import.meta.main) {
266
- const args = process.argv.slice(2);
267
- if (args.includes("--write-baseline")) {
268
- const project = buildSharedProject([guard]);
269
- const { findings } = scan(filesForGuard(project, guard));
270
- piiBaseline.write(baselineCounts(findings));
271
- process.exit(0);
272
- }
273
- if (args.includes("--no-baseline")) {
274
- const project = buildSharedProject([guard]);
275
- analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
276
- process.exit(0);
277
- }
278
- runStandalone(guard);
279
- }
@@ -1,125 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Guard: reports `ctx.unsafeAppendEvent` (also `ctx["unsafeAppendEvent"]` and destructuring) outside `migrations/`, `upcasters/` and tests.
4
- *
5
- * `appendEvent` is the typed default, `tryAppendEvent` the optimistic variant. The untyped
6
- * `unsafeAppendEvent` skips payload typing and is meant for migrations and upcasters, where the
7
- * event type is not knowable at compile time.
8
- *
9
- * Existing call sites are frozen per file in `.kumiko-unsafe-append-event-baseline.json`; a count
10
- * over the baseline fails, at or under passes. Without the file (consumer repos) the guard only
11
- * warns.
12
- *
13
- * Usage:
14
- * bun packages/guards/src/guard-unsafe-append-event.ts # compare to baseline
15
- * bun packages/guards/src/guard-unsafe-append-event.ts --write-baseline # freeze current
16
- */
17
-
18
- import * as path from "node:path";
19
-
20
- import { Node, type SourceFile, SyntaxKind } from "ts-morph";
21
- import {
22
- ALL_REPO_KINDS,
23
- type AstGuard,
24
- baselineRatchet,
25
- buildSharedProject,
26
- filesForGuard,
27
- isLocalFinding,
28
- relFromRepoRoot,
29
- runStandalone,
30
- type ScanSpec,
31
- } from "./_lib/guard-kit";
32
- import { resolveRepoRoots } from "./_lib/roots";
33
-
34
- const SCAN: ScanSpec = {
35
- kinds: ALL_REPO_KINDS,
36
- scope: "source",
37
- extensions: ["ts"],
38
- frameworkWithin: ["packages/*/src/**", "samples/**"],
39
- };
40
-
41
- const SYMBOL = "unsafeAppendEvent";
42
- const ALLOWED_PATH = /(^|\/)(migrations|upcasters|__tests__)\/|\.test\.ts$|\.d\.ts$/;
43
-
44
- function unsafeAppendEventLines(sf: SourceFile): number[] {
45
- const propertyAccesses = sf
46
- .getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
47
- .filter((access) => access.getNameNode().getText() === SYMBOL);
48
- const elementAccesses = sf
49
- .getDescendantsOfKind(SyntaxKind.ElementAccessExpression)
50
- .filter((access) => {
51
- const argument = access.getArgumentExpression();
52
- return (
53
- argument !== undefined &&
54
- (Node.isStringLiteral(argument) || Node.isNoSubstitutionTemplateLiteral(argument)) &&
55
- argument.getLiteralText() === SYMBOL
56
- );
57
- });
58
- const destructurings = sf
59
- .getDescendantsOfKind(SyntaxKind.BindingElement)
60
- .filter(
61
- (element) => (element.getPropertyNameNode() ?? element.getNameNode()).getText() === SYMBOL,
62
- );
63
- return [...propertyAccesses, ...elementAccesses, ...destructurings].map((node) =>
64
- node.getStartLineNumber(),
65
- );
66
- }
67
-
68
- type Finding = { readonly file: string; readonly line: number };
69
-
70
- function scan(files: readonly SourceFile[], roots = resolveRepoRoots()): Finding[] {
71
- const findings: Finding[] = [];
72
- for (const sf of files) {
73
- const file = relFromRepoRoot(sf.getFilePath(), roots);
74
- if (ALLOWED_PATH.test(file)) continue;
75
- for (const line of unsafeAppendEventLines(sf)) findings.push({ file, line });
76
- }
77
- return findings;
78
- }
79
-
80
- function countsPerFile(findings: readonly Finding[]): Record<string, number> {
81
- const counts: Record<string, number> = {};
82
- for (const f of findings.filter(isLocalFinding)) counts[f.file] = (counts[f.file] ?? 0) + 1;
83
- return counts;
84
- }
85
-
86
- const GUARD_NAME = "Unsafe-Append-Event Guard";
87
- const DEFAULT_BASELINE_FILE = path.join(process.cwd(), ".kumiko-unsafe-append-event-baseline.json");
88
-
89
- const REMEDIATION =
90
- "unsafeAppendEvent drops payload typing. Use ctx.appendEvent (default) or ctx.tryAppendEvent (conflict as result); the untyped variant belongs in migrations/ and upcasters/.";
91
-
92
- const REWRITE_COMMAND = `kumiko-guards guards --write-baseline --guard="${GUARD_NAME}"`;
93
-
94
- export function createUnsafeAppendEventGuard(baselineFile: string): AstGuard {
95
- const baseline = baselineRatchet({
96
- file: baselineFile,
97
- formatVersion: 1,
98
- unit: "unsafeAppendEvent call(s)",
99
- });
100
- return {
101
- name: GUARD_NAME,
102
- scan: SCAN,
103
- hint: `${REMEDIATION} After a deliberate change: \`${REWRITE_COMMAND}\`.`,
104
- run(files, roots = resolveRepoRoots()) {
105
- const findings = scan(files, roots);
106
- const violations = baseline.check(countsPerFile(findings), REMEDIATION, {
107
- formatDriftRemediation: `Run \`${REWRITE_COMMAND}\` once.`,
108
- resolveLine: (file) => findings.find((f) => f.file === file)?.line ?? 1,
109
- });
110
- return { violations };
111
- },
112
- writeBaseline: (files, roots) => baseline.write(countsPerFile(scan(files, roots))),
113
- };
114
- }
115
-
116
- export const guard = createUnsafeAppendEventGuard(DEFAULT_BASELINE_FILE);
117
-
118
- if (import.meta.main) {
119
- if (process.argv.slice(2).includes("--write-baseline")) {
120
- const project = buildSharedProject([guard]);
121
- guard.writeBaseline?.(filesForGuard(project, guard));
122
- process.exit(0);
123
- }
124
- runStandalone(guard);
125
- }