@cosmicdrift/kumiko-guards 0.3.0 → 0.282.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.282.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.282.0",
31
31
  "ts-morph": "^28.0.0"
32
32
  },
33
33
  "publishConfig": {
@@ -479,6 +479,23 @@ export function guardKitPreflightError(guardCount: number, rootCount: number): s
479
479
  return undefined;
480
480
  }
481
481
 
482
+ /**
483
+ * Validates CLI args for one subcommand against the flags it actually
484
+ * understands — every arg must match `known` exactly (not just a `--`-prefix
485
+ * check, which let a single-dash typo or a stray positional through
486
+ * unnoticed). An unknown arg must fail loud, never pass through silently.
487
+ */
488
+ export function cliFlagsError(
489
+ subcommand: string,
490
+ argv: readonly string[],
491
+ known: readonly string[],
492
+ ): string | undefined {
493
+ const unknown = argv.filter((arg) => !known.includes(arg));
494
+ if (unknown.length === 0) return undefined;
495
+ const knownList = known.length > 0 ? known.join(", ") : "(none)";
496
+ return `Unknown argument${unknown.length > 1 ? "s" : ""} for "${subcommand}": ${unknown.join(", ")}. Known flags: ${knownList}`;
497
+ }
498
+
482
499
  export type GuardKitBannerDeps = {
483
500
  readonly resolution?: RootResolution;
484
501
  };
@@ -505,6 +522,47 @@ export function printGuardKitBanner(
505
522
  if (project) console.log(`Project: ${project.getSourceFiles().length} files`);
506
523
  }
507
524
 
525
+ export type SuiteInventory = {
526
+ readonly count: number;
527
+ readonly names: readonly string[];
528
+ };
529
+
530
+ export type GuardKitInventory = {
531
+ readonly version: string;
532
+ readonly total: number;
533
+ readonly suites: {
534
+ readonly guards: SuiteInventory;
535
+ readonly ui: SuiteInventory;
536
+ readonly checks: SuiteInventory;
537
+ };
538
+ };
539
+
540
+ function suiteInventory(items: readonly { readonly name: string }[]): SuiteInventory {
541
+ const names = items.map((item) => item.name);
542
+ return { count: names.length, names };
543
+ }
544
+
545
+ /**
546
+ * Static registration inventory for the three suites — reads each array's
547
+ * `.name` only, no scan/project/guard.run(). What a consumer's CI checks
548
+ * against instead of running the guards, e.g. so a guard dropped from a
549
+ * suite's array is missing here too, not just silently absent from a run.
550
+ */
551
+ export function buildGuardKitInventory(args: {
552
+ readonly guards: readonly { readonly name: string }[];
553
+ readonly uiGuards: readonly { readonly name: string }[];
554
+ readonly checks: readonly { readonly name: string }[];
555
+ }): GuardKitInventory {
556
+ const guards = suiteInventory(args.guards);
557
+ const ui = suiteInventory(args.uiGuards);
558
+ const checks = suiteInventory(args.checks);
559
+ return {
560
+ version: guardKitVersion(),
561
+ total: guards.count + ui.count + checks.count,
562
+ suites: { guards, ui, checks },
563
+ };
564
+ }
565
+
508
566
  /**
509
567
  * Vacuity floor for guards with their own `main()`.
510
568
  *
package/src/changes.json CHANGED
@@ -1,4 +1,21 @@
1
1
  [
2
+ {
3
+ "version": "0.282.0",
4
+ "type": "fix",
5
+ "title": "kumiko-guards bin now passes flags through to the guards, ui, and checks subcommands",
6
+ "detail": "The bin's `guards` subcommand called run-guards.ts's suite runner directly, skipping the `--explain`/`--write-security-baseline`/`--strict-security-baseline` handling that only existed in run-guards.ts's own `if (import.meta.main)` block — a consumer running `bunx @cosmicdrift/kumiko-guards guards --write-security-baseline` got a normal guard run with the flag silently dropped. Each suite (guards, ui, checks) now exports a `run*Cli(argv)` function that validates argv against that suite's known flags and applies them; both the bin and the suite's own direct-invocation entry point call the same function, so they cannot drift apart again. Validation matches each arg exactly against the known list (not just a `--`-prefix check), so a single-dash typo or a stray positional also exits 1 with the flags that subcommand accepts, rather than passing through unnoticed."
7
+ },
8
+ {
9
+ "version": "0.282.0",
10
+ "type": "improvement",
11
+ "title": "kumiko-guards list prints the registration inventory as JSON"
12
+ },
13
+ {
14
+ "version": "0.282.0",
15
+ "type": "improvement",
16
+ "title": "Port the upgrade-state and runtime-isolation guards",
17
+ "detail": "guard-upgrade-state and check-runtime-isolation ported from the private infra/guards package into the public @cosmicdrift/kumiko-guards package, rebuilt onto the public RepoCheck pattern with single-repo root resolution. guard-app-dockerfile, guard-doc-status, check-licenses, and check-security stay in infra/guards as CDGS-specific house rules (private registry scope, CDGS doc taxonomy, and CDGS license/security exception files with no equivalent consumer-facing mechanism in the public package) — they were deliberately not ported, not forgotten."
18
+ },
2
19
  {
3
20
  "version": "0.3.0",
4
21
  "type": "improvement",
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env bun
2
+ // Runtime-Isolation Guard.
3
+ //
4
+ // Prevents production code (runtime) from transitively loading test or
5
+ // tooling modules — the original trigger was a top-level Vitest import in a
6
+ // testing helper that crashed drizzle-kit under Node. Every file gets a
7
+ // runtime context assigned; every import edge is checked against a compat
8
+ // matrix.
9
+ //
10
+ // Per-file classification (highest priority first):
11
+ // 1. File directive → // @runtime <kind> in the first 5 lines
12
+ // 2. Path pattern → *.test.ts, *.integration.ts, *.e2e.ts,
13
+ // **/__tests__/**, **/testing/** → test;
14
+ // plus a handful of verified-isomorphic
15
+ // single-file/directory carve-outs (see
16
+ // runtime-isolation-classify.ts)
17
+ // 3. Workspace → package.json `"kumiko": { "runtime": "..." }`
18
+ // 4. Client reachability → transitively reachable, via value imports,
19
+ // from a browser-bundle entry
20
+ // (`src/client-*.tsx`) or from any file that
21
+ // already classifies as "client" on its own
22
+ // (directive/path/workspace)
23
+ // 5. Default → runtime
24
+ //
25
+ // Compat matrix: which runtime context may import which.
26
+ // runtime → runtime, client
27
+ // client → client
28
+ // dev → runtime, client, dev, tooling
29
+ // tooling → runtime, client, dev, tooling, test
30
+ // test → everything
31
+ //
32
+ // Single-repo only (unlike infra/guards' check-runtime-isolation.ts, which
33
+ // scans every sibling checkout in one shared ts-morph project): this public
34
+ // package only ever resolves the repo `cwd` sits in, so there is exactly one
35
+ // repo root to classify files against.
36
+ //
37
+ // Pure classification + violation detection live in
38
+ // `runtime-isolation-classify.ts` (unit-testable with an in-memory ts-morph
39
+ // project) — this file only does the repo/glob resolution, ts-morph project
40
+ // setup, and RepoCheck wiring.
41
+ //
42
+ // A file/import edge classified "client" importing "runtime" can be a real
43
+ // finding this guard needs to see, but can also be a known, reviewed
44
+ // exception (e.g. samples/recipes are worked examples, not the framework's
45
+ // own production surface — see kumiko-framework#2337 for the one currently
46
+ // frozen here). Exceptions are tracked via a per-repo baseline file
47
+ // (`.kumiko-runtime-isolation-baseline.json`, same `baselineRatchet`
48
+ // mechanism as check-complexity/guard-pii-annotations/etc.), not a
49
+ // hardcoded list in this source file: a consumer repo can freeze or clear
50
+ // its own findings the same way, with `--write-baseline`.
51
+ //
52
+ // Usage:
53
+ // bun packages/guards/src/check-runtime-isolation.ts
54
+ // bun packages/guards/src/check-runtime-isolation.ts --write-baseline
55
+
56
+ import * as path from "node:path";
57
+ import { Project } from "ts-morph";
58
+ import {
59
+ baselineRatchet,
60
+ type GuardViolation,
61
+ type RepoCheck,
62
+ reportResults,
63
+ runRepoChecks,
64
+ } from "./_lib/guard-kit";
65
+ import { frameworkTsConfigPath, type RepoRoot, resolveRepoRoots } from "./_lib/roots";
66
+ import { type ScanSpec, scanFiles } from "./_lib/scan-scope";
67
+ import {
68
+ classify,
69
+ computeClientReachablePaths,
70
+ findRuntimeIsolationViolations,
71
+ isClientEntryPath,
72
+ type Runtime,
73
+ } from "./runtime-isolation-classify";
74
+
75
+ const SCAN: ScanSpec = { scope: "source", extensions: ["ts", "tsx"] };
76
+ const BASELINE_FILE = ".kumiko-runtime-isolation-baseline.json";
77
+
78
+ type RawViolation = {
79
+ readonly file: string;
80
+ readonly line: number;
81
+ readonly fileRuntime: Runtime;
82
+ readonly importedSpec: string;
83
+ readonly importedRuntime: Runtime;
84
+ };
85
+
86
+ function printViolation(v: RawViolation, root: string): void {
87
+ const fileRel = path.relative(root, v.file);
88
+ console.log(` ${fileRel}:${v.line}`);
89
+ console.log(` [${v.fileRuntime}] imports [${v.importedRuntime}] "${v.importedSpec}"`);
90
+ }
91
+
92
+ function violationKey(rootAbsPath: string, v: RawViolation): string {
93
+ return `${path.relative(rootAbsPath, v.file)}::${v.importedSpec}`;
94
+ }
95
+
96
+ function countByKey(
97
+ rootAbsPath: string,
98
+ violations: readonly RawViolation[],
99
+ ): Record<string, number> {
100
+ const counts: Record<string, number> = {};
101
+ for (const v of violations) {
102
+ const key = violationKey(rootAbsPath, v);
103
+ counts[key] = (counts[key] ?? 0) + 1;
104
+ }
105
+ return counts;
106
+ }
107
+
108
+ function ratchetFor(rootAbsPath: string) {
109
+ return baselineRatchet({
110
+ file: path.join(rootAbsPath, BASELINE_FILE),
111
+ formatVersion: 1,
112
+ unit: "runtime-isolation violation(s)",
113
+ });
114
+ }
115
+
116
+ function scanRoot(root: RepoRoot): {
117
+ allViolations: readonly RawViolation[];
118
+ outsideRoot: readonly string[];
119
+ scannedFiles: number;
120
+ } {
121
+ const tsConfigFilePath = frameworkTsConfigPath();
122
+ const project =
123
+ tsConfigFilePath !== undefined
124
+ ? new Project({
125
+ tsConfigFilePath,
126
+ skipAddingFilesFromTsConfig: true,
127
+ skipFileDependencyResolution: true,
128
+ })
129
+ : new Project({ skipAddingFilesFromTsConfig: true, skipFileDependencyResolution: true });
130
+
131
+ const paths = scanFiles(SCAN, [root]);
132
+ // Exact-path lookup, never a re-glob: `project.getSourceFiles(paths)`
133
+ // treats each array entry as a glob pattern — pathologically slow (and,
134
+ // for a bracketed filename like `[id].tsx`, a broken character class)
135
+ // over thousands of paths. `getSourceFile(path)` is an exact lookup.
136
+ const scannedFiles = paths
137
+ .map((p) => project.getSourceFile(p) ?? project.addSourceFileAtPath(p))
138
+ .filter((f) => {
139
+ const fp = f.getFilePath();
140
+ return !fp.includes("/node_modules/") && !fp.includes("/dist/");
141
+ });
142
+
143
+ const workspaceCache = new Map<string, Runtime | null>();
144
+ const clientReachable = computeClientReachablePaths(scannedFiles, (sf) => {
145
+ const rel = path.relative(root.absPath, sf.getFilePath());
146
+ if (isClientEntryPath(rel)) return true;
147
+ return classify(sf.getFilePath(), root.absPath, workspaceCache) === "client";
148
+ });
149
+
150
+ const { violations: allViolations, outsideRoot } = findRuntimeIsolationViolations(
151
+ scannedFiles,
152
+ root.absPath,
153
+ workspaceCache,
154
+ clientReachable,
155
+ );
156
+
157
+ return { allViolations, outsideRoot, scannedFiles: scannedFiles.length };
158
+ }
159
+
160
+ export const check: RepoCheck = {
161
+ name: "Runtime-Isolation Check",
162
+ hint:
163
+ "runtime may import runtime/client only; client may import client only; " +
164
+ "dev/tooling/test are more permissive. See runtime-isolation-classify.ts for the compat matrix. " +
165
+ "New, deliberate exception? `bun packages/guards/src/check-runtime-isolation.ts --write-baseline`",
166
+ run(roots) {
167
+ const root = roots[0];
168
+ if (!root) return { violations: [], matchedFiles: 0, notApplicable: true };
169
+ const rootAbsPath = root.absPath;
170
+
171
+ const { allViolations, outsideRoot, scannedFiles } = scanRoot(root);
172
+ for (const v of allViolations) printViolation(v, rootAbsPath);
173
+
174
+ const counts = countByKey(rootAbsPath, allViolations);
175
+ const resolveLine = (key: string): number => {
176
+ const relFile = key.split("::")[0];
177
+ return allViolations.find((v) => path.relative(rootAbsPath, v.file) === relFile)?.line ?? 1;
178
+ };
179
+ const violations: GuardViolation[] = ratchetFor(rootAbsPath).check(
180
+ counts,
181
+ "runtime may import runtime/client only; client may import client only — see runtime-isolation-classify.ts for the compat matrix.",
182
+ {
183
+ formatDriftRemediation:
184
+ "Run `bun packages/guards/src/check-runtime-isolation.ts --write-baseline` once.",
185
+ resolveLine,
186
+ },
187
+ );
188
+
189
+ if (outsideRoot.length > 0) {
190
+ console.log(
191
+ ` Runtime-Isolation Check: ${outsideRoot.length} import target(s) outside the repo root (skipped).`,
192
+ );
193
+ }
194
+
195
+ return { violations, matchedFiles: scannedFiles, notApplicable: false };
196
+ },
197
+ };
198
+
199
+ if (import.meta.main) {
200
+ const args = process.argv.slice(2);
201
+ if (args.includes("--write-baseline")) {
202
+ const root = resolveRepoRoots()[0];
203
+ if (!root) {
204
+ console.error("No repo root resolved — nothing to baseline.");
205
+ process.exit(1);
206
+ }
207
+ const { allViolations } = scanRoot(root);
208
+ for (const v of allViolations) printViolation(v, root.absPath);
209
+ ratchetFor(root.absPath).write(countByKey(root.absPath, allViolations));
210
+ process.exit(0);
211
+ }
212
+ const failed = reportResults(await runRepoChecks([check]));
213
+ process.exit(failed > 0 ? 1 : 0);
214
+ }
package/src/cli.ts CHANGED
@@ -1,55 +1,79 @@
1
1
  #!/usr/bin/env bun
2
2
  // Consumer entry point for `bunx @cosmicdrift/kumiko-guards`. Runs all three
3
3
  // suites (or one, via subcommand) using the exact call forms the three
4
- // runners already use in their own `if (import.meta.main)` blocks.
5
- import {
6
- buildSharedProject,
7
- printGuardKitBanner,
8
- reportResults,
9
- runGuards,
10
- runRepoChecks,
11
- } from "./_lib/guard-kit";
12
- import { GUARDS } from "./run-guards";
13
- import { REPO_CHECKS } from "./run-repo-checks";
14
- import { UI_GUARDS } from "./run-ui-guards";
15
-
16
- const SUBCOMMANDS = ["guards", "ui", "checks"] as const;
4
+ // runners already use in their own `if (import.meta.main)` blocks — including
5
+ // flags, which each runner's own *Cli function validates and applies so this
6
+ // bin and the direct `bun run-*.ts` invocation can never drift apart.
7
+ import { buildGuardKitInventory, cliFlagsError } from "./_lib/guard-kit";
8
+ import { GUARD_FLAGS, GUARDS, runGuardsCli } from "./run-guards";
9
+ import { REPO_CHECK_FLAGS, REPO_CHECKS, runRepoChecksCli } from "./run-repo-checks";
10
+ import { runUiGuardsCli, UI_GUARD_FLAGS, UI_GUARDS } from "./run-ui-guards";
11
+
12
+ const SUBCOMMANDS = ["guards", "ui", "checks", "list"] as const;
17
13
  type Subcommand = (typeof SUBCOMMANDS)[number];
18
14
 
15
+ const SUBCOMMAND_FLAGS: Record<Subcommand, readonly string[]> = {
16
+ guards: GUARD_FLAGS,
17
+ ui: UI_GUARD_FLAGS,
18
+ checks: REPO_CHECK_FLAGS,
19
+ list: [],
20
+ };
21
+
19
22
  function isSubcommand(value: string): value is Subcommand {
20
23
  return (SUBCOMMANDS as readonly string[]).includes(value);
21
24
  }
22
25
 
23
- function runGuardsSuite(): number {
24
- const project = buildSharedProject(GUARDS);
25
- printGuardKitBanner(GUARDS.length, project);
26
- return reportResults(runGuards(GUARDS, project));
27
- }
28
-
29
- function runUiGuardsSuite(): number {
30
- const project = buildSharedProject(UI_GUARDS);
31
- printGuardKitBanner(UI_GUARDS.length, project);
32
- return reportResults(runGuards(UI_GUARDS, project));
33
- }
34
-
35
- async function runRepoChecksSuite(): Promise<number> {
36
- printGuardKitBanner(REPO_CHECKS.length);
37
- return reportResults(await runRepoChecks(REPO_CHECKS));
26
+ function printHelp(): void {
27
+ console.log("Usage: kumiko-guards [guards|ui|checks|list] [flags]");
28
+ console.log();
29
+ for (const sub of SUBCOMMANDS) {
30
+ const flags = SUBCOMMAND_FLAGS[sub];
31
+ console.log(` ${sub}${flags.length > 0 ? ` [${flags.join("|")}]` : ""}`);
32
+ }
38
33
  }
39
34
 
40
35
  async function main(): Promise<void> {
41
36
  const subcommand = process.argv[2];
37
+ if (subcommand === "--help" || subcommand === "-h") {
38
+ printHelp();
39
+ process.exit(0);
40
+ }
42
41
  if (subcommand !== undefined && !isSubcommand(subcommand)) {
43
42
  console.error(
44
43
  `Unknown subcommand "${subcommand}". Valid subcommands: ${SUBCOMMANDS.join(", ")}`,
45
44
  );
46
45
  process.exit(1);
47
46
  }
47
+ const flags = process.argv.slice(3);
48
+
49
+ if (subcommand === "list") {
50
+ const flagsError = cliFlagsError("list", flags, SUBCOMMAND_FLAGS.list);
51
+ if (flagsError !== undefined) {
52
+ console.error(flagsError);
53
+ process.exit(1);
54
+ }
55
+ // Registration inventory only — no scan, no project, no guard.run(). What
56
+ // CI checks against instead of running the guards: a guard dropped from a
57
+ // suite's array goes missing here too, not just silently from a run.
58
+ const inventory = buildGuardKitInventory({
59
+ guards: GUARDS,
60
+ uiGuards: UI_GUARDS,
61
+ checks: REPO_CHECKS,
62
+ });
63
+ console.log(JSON.stringify(inventory));
64
+ return;
65
+ }
48
66
 
49
67
  let failed = 0;
50
- if (subcommand === undefined || subcommand === "guards") failed += runGuardsSuite();
51
- if (subcommand === undefined || subcommand === "ui") failed += runUiGuardsSuite();
52
- if (subcommand === undefined || subcommand === "checks") failed += await runRepoChecksSuite();
68
+ if (subcommand === undefined || subcommand === "guards") {
69
+ failed += runGuardsCli(subcommand === "guards" ? flags : []);
70
+ }
71
+ if (subcommand === undefined || subcommand === "ui") {
72
+ failed += runUiGuardsCli(subcommand === "ui" ? flags : []);
73
+ }
74
+ if (subcommand === undefined || subcommand === "checks") {
75
+ failed += await runRepoChecksCli(subcommand === "checks" ? flags : []);
76
+ }
53
77
 
54
78
  process.exit(failed > 0 ? 1 : 0);
55
79
  }
@@ -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