@cosmicdrift/kumiko-guards 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +57 -0
  2. package/README.md +16 -0
  3. package/package.json +40 -0
  4. package/src/_lib/baseline-compare.ts +56 -0
  5. package/src/_lib/generic-reason.ts +39 -0
  6. package/src/_lib/guard-kit.ts +534 -0
  7. package/src/_lib/handler-name-forms.ts +29 -0
  8. package/src/_lib/ignore-tag.ts +24 -0
  9. package/src/_lib/primitives-access.ts +19 -0
  10. package/src/_lib/roots.ts +304 -0
  11. package/src/_lib/scan-lines.ts +25 -0
  12. package/src/_lib/scan-scope.ts +152 -0
  13. package/src/_lib/security-baseline-cli.ts +54 -0
  14. package/src/_lib/security-baseline.ts +325 -0
  15. package/src/_lib/sql-inventory.ts +267 -0
  16. package/src/guard-access-denied-test.ts +135 -0
  17. package/src/guard-admin-api.ts +134 -0
  18. package/src/guard-cross-feature-imports.ts +244 -0
  19. package/src/guard-direct-entity-writes.ts +387 -0
  20. package/src/guard-direct-fetch.ts +154 -0
  21. package/src/guard-escape-hatch-declared.ts +520 -0
  22. package/src/guard-fake-tests.ts +137 -0
  23. package/src/guard-html-escape.ts +345 -0
  24. package/src/guard-no-custom-primitives.ts +196 -0
  25. package/src/guard-no-date-api.ts +186 -0
  26. package/src/guard-no-direct-fs.ts +232 -0
  27. package/src/guard-no-direct-process-env.ts +126 -0
  28. package/src/guard-no-inline-styles.ts +58 -0
  29. package/src/guard-no-logic-in-views.ts +147 -0
  30. package/src/guard-no-raw-hooks.ts +76 -0
  31. package/src/guard-open-to-all-reason.ts +112 -0
  32. package/src/guard-pre-es-patterns.ts +199 -0
  33. package/src/guard-primitives-discipline.ts +330 -0
  34. package/src/guard-raw-classname.ts +111 -0
  35. package/src/guard-raw-interactive-elements.ts +154 -0
  36. package/src/guard-raw-sql.ts +89 -0
  37. package/src/guard-renderer-boundaries.ts +157 -0
  38. package/src/guard-restricted-symbols.ts +138 -0
  39. package/src/guard-silent-skip.ts +186 -0
  40. package/src/guard-tailwind-scan-surface.ts +588 -0
  41. package/src/guard-tenant-escalation.ts +312 -0
  42. package/src/guard-thin-wrappers.ts +422 -0
  43. package/src/guard-unsafe-json-parse.ts +86 -0
  44. package/src/index.ts +29 -0
  45. package/src/run-guards.ts +78 -0
  46. package/src/run-repo-checks.ts +22 -0
  47. package/src/run-ui-guards.ts +25 -0
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: finds Date-API patterns in production code where they are banned.
4
+ *
5
+ * Checked patterns (all Date-specific, Temporal has its own equivalents):
6
+ * - `new Date(...)` → Temporal.Now.instant() / Temporal.Instant.from
7
+ * - `.toISOString()` → Temporal.Instant.toString() (canonical ISO)
8
+ * - `.getTime()` → Temporal.Instant.epochMilliseconds
9
+ *
10
+ * `Date.now()` and `Date.parse()` are NOT checked — Date.now() is the
11
+ * idiomatic way to measure a duration (`Date.now() - startedAt`), and
12
+ * Temporal.Now.instant().epochMilliseconds is 4x longer for no benefit.
13
+ *
14
+ * Background: kumiko has a Temporal-based time API via `ctx.tz`. Feature
15
+ * code should use wall-clock + IANA-TZ via ctx.tz.parse / ctx.tz.now /
16
+ * ctx.tz.fromLocatedJson, NOT `new Date(...)` directly — that's the JS-Date
17
+ * trap (implicit local-TZ conversion in the browser, Hermes lacked
18
+ * Temporal for a long time, etc.). Sprint F carried this through
19
+ * atomically.
20
+ *
21
+ * Allowlist: paths that legitimately need the Date API (HTTP header specs,
22
+ * polyfill detection, Date↔Temporal bridges, JSON wire-format backwards
23
+ * compat). Test files are generally allowed.
24
+ *
25
+ * Usage:
26
+ * bun guards/guard-no-date-api.ts
27
+ *
28
+ * Exit 1 on violations in non-allowlisted files, 0 when clean.
29
+ */
30
+
31
+ import * as path from "node:path";
32
+ import { type SourceFile, SyntaxKind } from "ts-morph";
33
+ import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
34
+
35
+ const ROOT = process.cwd();
36
+
37
+ const SCAN: ScanSpec = {
38
+ scope: "source",
39
+ extensions: ["ts"],
40
+ frameworkWithin: ["packages/*/src/**", "samples/**"],
41
+ };
42
+
43
+ // Files that are NOT checked — tests, type defs, generated code.
44
+ const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$|\.g\.ts$)/;
45
+
46
+ // Framework internals are still allowed to `new Date()`: they implement the
47
+ // TZ layer themselves or hang off the DB driver where Date is the native
48
+ // wire format. Migration plan: paths get removed from this allowlist per
49
+ // feature/sample as they migrate to ctx.tz. Goal: the allowlist shrinks.
50
+ const ALLOWLIST = [
51
+ // Framework time layer itself
52
+ /packages\/framework\/src\/time\//,
53
+ // DB layer: drizzle column wrapper, migration tooling — Date is a
54
+ // primitive wire format there
55
+ /packages\/framework\/src\/db\//,
56
+ // Bun.SQL layer (post-Drizzle replacement) — same boundary as db/,
57
+ // converts Date→Temporal at the driver boundary.
58
+ /packages\/framework\/src\/bun-db\//,
59
+ // Event-store internals: createdAt stamps, snapshot time, archive time
60
+ /packages\/framework\/src\/event-store\//,
61
+ // Pipeline: system hooks, idempotency cache (low-level timestamps)
62
+ /packages\/framework\/src\/pipeline\//,
63
+ // Errors: timestamp in the error body
64
+ /packages\/framework\/src\/errors\//,
65
+ // Jobs: job-run timestamps (the job runner is a time source for cron)
66
+ /packages\/framework\/src\/jobs\//,
67
+ // API layer: request timestamps in logs
68
+ /packages\/framework\/src\/api\//,
69
+ // Logging
70
+ /packages\/framework\/src\/logging\//,
71
+ // i18n
72
+ /packages\/framework\/src\/i18n\//,
73
+ // Observability
74
+ /packages\/framework\/src\/observability\//,
75
+ // Engine internals (factories, registry, config) — when they need Date
76
+ // it's defaults/audit stamps working together with the DB layer.
77
+ /packages\/framework\/src\/engine\/(types|registry|config-helpers|create-app|define-feature|state-machine|access|field-access|boot-validator|qualified-name)\.ts$/,
78
+ // Testing helpers
79
+ /packages\/framework\/src\/testing\//,
80
+ // Search adapter internals
81
+ /packages\/framework\/src\/search\//,
82
+ // File storage (coming in Gap-04)
83
+ /packages\/framework\/src\/files\//,
84
+
85
+ // Sprint F's atomic switch migrated bundled-features + samples to
86
+ // Temporal.Now.instant() — no blanket allowlist anymore. Beammycar's
87
+ // migration reads legacy-DB Date columns (V2 schema, no Temporal): the
88
+ // `pg` driver rows arrive as JS `Date`, the `dateToInstant` helper
89
+ // bridges them to `Temporal.Instant` via `.getTime()`. A pure bridge
90
+ // layer.
91
+ /samples\/showcases\/beammycar\/src\/migration\//,
92
+
93
+ // === Legitimate Date-API spots ===
94
+ // Polyfill detection: typeof globalThis.Temporal vs new Date(0) — no
95
+ // Temporal available because we're checking if it's there right now.
96
+ /packages\/framework\/src\/time\/polyfill\.ts$/,
97
+ // HTTP Date headers (RFC 7231): Last-Modified / Expires / signed-URL
98
+ // expiresAt are Date-typed in the HTTP spec, not a Temporal format.
99
+ /packages\/framework\/src\/files\/file-routes\.ts$/,
100
+ // Error serialization: timestamp in the JSON wire body for backwards
101
+ // compat with every client that has expected an ISO string since Sprint A.
102
+ /packages\/framework\/src\/errors\/serialize\.ts$/,
103
+ // Entity cache: parsing arbitrary input (could be Date or ISO string),
104
+ // Number.isNaN(d.getTime()) as a validity check.
105
+ /packages\/framework\/src\/pipeline\/entity-cache\.ts$/,
106
+ // Event-store insertSubsequentEvent: raw SQL row.created_at can be Date|string
107
+ // (postgres-js driver-config dependent), normalised to Temporal.Instant via
108
+ // .getTime() bridge.
109
+ /packages\/framework\/src\/event-store\/event-store\.ts$/,
110
+ // HTTP Last-Modified/ETag header: Bun/Node Response API expects a native
111
+ // Date object from fs.Stats.mtimeMs — wire boundary, not a Temporal format.
112
+ /packages\/server-runtime\/src\/run-prod-app-static-files\.ts$/,
113
+ // Bun.SQL timestamptz columns arrive as native Date (driver wire format,
114
+ // same reason as packages/framework/src/bun-db/ above).
115
+ /packages\/publish\/src\/rollback\.ts$/,
116
+ ];
117
+
118
+ interface Violation {
119
+ file: string;
120
+ line: number;
121
+ snippet: string;
122
+ pattern: "new Date" | ".toISOString" | ".getTime";
123
+ }
124
+
125
+ function isAllowlisted(filePath: string): boolean {
126
+ const rel = path.relative(ROOT, filePath);
127
+ return ALLOWLIST.some((re) => re.test(rel));
128
+ }
129
+
130
+ function findDateApiUsages(sf: SourceFile): Omit<Violation, "file">[] {
131
+ const result: Omit<Violation, "file">[] = [];
132
+
133
+ // Pattern 1: `new Date(...)`
134
+ for (const expr of sf.getDescendantsOfKind(SyntaxKind.NewExpression)) {
135
+ if (expr.getExpression().getText() === "Date") {
136
+ result.push({
137
+ line: expr.getStartLineNumber(),
138
+ snippet: expr.getText(),
139
+ pattern: "new Date",
140
+ });
141
+ }
142
+ }
143
+
144
+ // Pattern 2: `.toISOString()` and `.getTime()` — property access in a call.
145
+ // Temporal.Instant has neither method, so any hit is a Date-API call.
146
+ for (const expr of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
147
+ const callee = expr.getExpression();
148
+ if (callee.getKind() !== SyntaxKind.PropertyAccessExpression) continue;
149
+ const name = callee.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName();
150
+ if (name === "toISOString" || name === "getTime") {
151
+ result.push({
152
+ line: expr.getStartLineNumber(),
153
+ snippet: expr.getText(),
154
+ pattern: name === "toISOString" ? ".toISOString" : ".getTime",
155
+ });
156
+ }
157
+ }
158
+
159
+ return result;
160
+ }
161
+
162
+ export const guard: AstGuard = {
163
+ name: "No-Date-API Guard",
164
+ scan: SCAN,
165
+ hint: "Ersetze mit Temporal.Now.instant() / Temporal.Instant.from / .toString() / .epochMilliseconds — siehe docs/plans/architecture/timezones.md.",
166
+ run(files) {
167
+ const violations: Array<{ file: string; line: number; message: string }> = [];
168
+
169
+ for (const sf of files) {
170
+ const file = sf.getFilePath();
171
+ if (EXCLUDE.test(file)) continue;
172
+ if (isAllowlisted(file)) continue;
173
+ for (const usage of findDateApiUsages(sf)) {
174
+ violations.push({
175
+ file: path.relative(ROOT, file),
176
+ line: usage.line,
177
+ message: `[${usage.pattern}] ${usage.snippet}`,
178
+ });
179
+ }
180
+ }
181
+
182
+ return { violations };
183
+ },
184
+ };
185
+
186
+ if (import.meta.main) runStandalone(guard);
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Guard: finds direct `node:fs`/`fs` imports outside an allowlist.
4
+ *
5
+ * Background: several path-traversal bugs happened because request-input
6
+ * paths were passed straight to `node:fs` instead of through the one
7
+ * guarded place (`packages/framework/src/files/local-provider.ts` —
8
+ * `resolveContainedPath()` resolves against `basePath` and rejects anything
9
+ * outside it). New runtime code should use `FileStorageProvider` instead of
10
+ * wiring up `fs` itself again.
11
+ *
12
+ * Scans every packages/<pkg>/src directory (each root, like guard-no-date-api
13
+ * / guard-restricted-symbols) — the traversal risk isn't limited to one repo.
14
+ * Build/CLI/script/e2e tooling is filtered out via an EXCLUDE directory
15
+ * pattern rather than a file-by-file allowlist; everything else under src
16
+ * stays checked, including in app repos (e.g. marketing render jobs).
17
+ *
18
+ * Allowlist entries are repo-scoped (repo name or "*") so a path justified in
19
+ * one repo cannot silently free node:fs in another under the same relative
20
+ * path.
21
+ *
22
+ * Usage:
23
+ * bun guards/guard-no-direct-fs.ts
24
+ *
25
+ * Exit 1 on violations in non-allowlisted files, 0 when clean.
26
+ */
27
+
28
+ import { relative as pathRelative } from "node:path";
29
+ import { Node, type SourceFile, SyntaxKind } from "ts-morph";
30
+ import {
31
+ type AstGuard,
32
+ findRepoRootFor,
33
+ relFromRepoRoot,
34
+ runStandalone,
35
+ type ScanSpec,
36
+ } from "./_lib/guard-kit";
37
+ import { resolveRepoRoots } from "./_lib/roots";
38
+
39
+ const SCAN: ScanSpec = {
40
+ scope: "source",
41
+ extensions: ["ts"],
42
+ frameworkWithin: ["packages/*/src/**", "samples/**"],
43
+ };
44
+
45
+ // Tests + build/CLI/script/e2e tooling: no request input as a path.
46
+ // (^|/) so relative paths like `tools/docgen/...` match — a leading-slash-
47
+ // only pattern only worked when relFromRepoRoot fell back to absolute paths.
48
+ const EXCLUDE =
49
+ /(__tests__|\.test\.ts$|\.integration\.ts$|\.spec\.ts$|\.d\.ts$|(^|\/)(scripts|e2e|tools|bin)\/)/;
50
+
51
+ const FS_MODULES = new Set(["fs", "node:fs", "fs/promises", "node:fs/promises"]);
52
+
53
+ type AllowEntry = {
54
+ readonly repo: string | "*";
55
+ readonly pattern: RegExp;
56
+ };
57
+
58
+ const ALLOWLIST: readonly AllowEntry[] = [
59
+ // The one guarded place: path-traversal check via resolveContainedPath().
60
+ { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/files\/local-provider\.ts$/ },
61
+
62
+ // Dev-server: CLI/scaffolding/codegen, runs locally on the developer's
63
+ // machine, no request input as a path.
64
+ { repo: "kumiko-framework", pattern: /^packages\/dev-server\/src\// },
65
+
66
+ // CLI (@cosmicdrift/kumiko-cli): local dev/CI tooling reading the repo tree, no request input as path.
67
+ { repo: "kumiko-framework", pattern: /^packages\/cli\/src\// },
68
+
69
+ // Framework: migrations/schema tooling, DB codegen, boot validator —
70
+ // CLI/build time, no request input.
71
+ { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/migrations\/kumiko-drift\.ts$/ },
72
+ { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/schema-cli\.ts$/ },
73
+ // same CLI-tooling pattern as schema-cli.ts — repo-tree/node_modules walking for the upgrade command, not app-level file storage
74
+ { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/upgrade-cli\.ts$/ },
75
+ { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/es-ops\/runner\.ts$/ },
76
+ { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/db\/migrate-generator\.ts$/ },
77
+ { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/db\/migrate-runner\.ts$/ },
78
+ { repo: "kumiko-framework", pattern: /^packages\/framework\/src\/db\/rebuild-marker\.ts$/ },
79
+ {
80
+ repo: "kumiko-framework",
81
+ pattern: /^packages\/framework\/src\/engine\/boot-validator\/custom-screen-write-qns\.ts$/,
82
+ },
83
+ {
84
+ repo: "kumiko-framework",
85
+ pattern: /^packages\/framework\/src\/engine\/codemod\/pipeline-codemod\.ts$/,
86
+ },
87
+
88
+ // server-runtime: prod bundle build + static asset delivery from the
89
+ // build output (no user-controlled path).
90
+ { repo: "kumiko-framework", pattern: /^packages\/server-runtime\/src\/build-prod-bundle\.ts$/ },
91
+ {
92
+ repo: "kumiko-framework",
93
+ pattern: /^packages\/server-runtime\/src\/run-prod-app-static-files\.ts$/,
94
+ },
95
+
96
+ // create-kumiko-app: Scaffolding-CLI.
97
+ { repo: "kumiko-framework", pattern: /^packages\/create-kumiko-app\/src\/manifest\.ts$/ },
98
+
99
+ // Sample apps: codegen/screenshot-mount helper, no request input.
100
+ { repo: "kumiko-framework", pattern: /^samples\/apps\/use-all-bundled\/schema\/generate\.ts$/ },
101
+ {
102
+ repo: "kumiko-framework",
103
+ pattern: /^samples\/apps\/showcase\/src\/app\/mount-public-screenshots\.ts$/,
104
+ },
105
+
106
+ // kumiko-enterprise: ai-cli reads/writes locally on the developer's
107
+ // machine (--api-key/KUMIKO_CORPUS_PATH), no server request path.
108
+ { repo: "kumiko-enterprise", pattern: /^packages\/ai-cli\/src\/index\.ts$/ },
109
+ // Few-shot corpus loader: reads docs/few-shot-corpus.json via an upward
110
+ // walk from cwd, no user input as a path.
111
+ { repo: "kumiko-enterprise", pattern: /^packages\/ai-foundation\/src\/prompt\/corpus\.ts$/ },
112
+
113
+ // App repos: marketing landing-page renderer, iterates over a fixed
114
+ // language enum (LANGS/SUPPORTED_LANGS) — no user input in the path.
115
+ // repo:"*" — same relative path is intentional across flat-layout apps.
116
+ { repo: "*", pattern: /(^|\/)src\/marketing\/render-landing\.ts$/ },
117
+ { repo: "*", pattern: /(^|\/)src\/marketing\/rebuild-pages-job\.ts$/ },
118
+
119
+ // kumiko-enterprise publish pipeline: materialize.ts is the guarded
120
+ // place (writeMaterializedTree — resolve()+startsWith(root+sep) check,
121
+ // same pattern as local-provider.ts). build.ts/feature.ts/validate.ts
122
+ // now only touch fs for tempdir housekeeping (mkdtemp/rm, fixed
123
+ // suffixes) and a static package.json upwalk — no user path, no direct
124
+ // write anymore.
125
+ { repo: "kumiko-enterprise", pattern: /^packages\/publish\/src\/materialize\.ts$/ },
126
+ { repo: "kumiko-enterprise", pattern: /^packages\/publish\/src\/build\.ts$/ },
127
+ { repo: "kumiko-enterprise", pattern: /^packages\/publish\/src\/feature\.ts$/ },
128
+ { repo: "kumiko-enterprise", pattern: /^packages\/publish\/src\/validate\.ts$/ },
129
+ ];
130
+
131
+ export function isRepoAllowlisted(
132
+ repoName: string | undefined,
133
+ relPath: string,
134
+ allowlist: readonly AllowEntry[] = ALLOWLIST,
135
+ ): boolean {
136
+ // Fail-closed without a resolved root: only "*" entries can match, so a
137
+ // synthetic in-memory path cannot hitch a ride on a framework/enterprise
138
+ // justification (same hole guard-direct-fetch closed).
139
+ return allowlist.some(
140
+ (e) =>
141
+ (e.repo === "*" || (repoName !== undefined && e.repo === repoName)) &&
142
+ e.pattern.test(relPath),
143
+ );
144
+ }
145
+
146
+ interface Violation {
147
+ line: number;
148
+ moduleSpecifier: string;
149
+ }
150
+
151
+ function findDynamicFsRequires(sf: SourceFile): Violation[] {
152
+ const violations: Violation[] = [];
153
+
154
+ // Dynamic `import("fs")` and CommonJS `require("fs")` bypass the static
155
+ // import/export declarations — both are CallExpressions in the AST.
156
+ for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
157
+ const callee = call.getExpression();
158
+ const isRequireCall =
159
+ callee.getKind() === SyntaxKind.Identifier && callee.getText() === "require";
160
+ const isDynamicImport = callee.getKind() === SyntaxKind.ImportKeyword;
161
+ if (!isRequireCall && !isDynamicImport) continue;
162
+ const arg = call.getArguments()[0];
163
+ if (arg && Node.isStringLiteral(arg) && FS_MODULES.has(arg.getLiteralValue())) {
164
+ violations.push({ line: call.getStartLineNumber(), moduleSpecifier: arg.getLiteralValue() });
165
+ }
166
+ }
167
+ return violations;
168
+ }
169
+
170
+ function findDirectFsImports(sf: SourceFile): Violation[] {
171
+ const violations: Violation[] = [];
172
+
173
+ for (const decl of sf.getImportDeclarations()) {
174
+ const spec = decl.getModuleSpecifierValue();
175
+ if (FS_MODULES.has(spec)) {
176
+ violations.push({ line: decl.getStartLineNumber(), moduleSpecifier: spec });
177
+ }
178
+ }
179
+ for (const decl of sf.getExportDeclarations()) {
180
+ const spec = decl.getModuleSpecifierValue();
181
+ if (spec && FS_MODULES.has(spec)) {
182
+ violations.push({ line: decl.getStartLineNumber(), moduleSpecifier: spec });
183
+ }
184
+ }
185
+ for (const importEqualsDecl of sf.getDescendantsOfKind(SyntaxKind.ImportEqualsDeclaration)) {
186
+ const ref = importEqualsDecl.getModuleReference();
187
+ if (!Node.isExternalModuleReference(ref)) continue;
188
+ const arg = ref.getExpression();
189
+ if (arg && Node.isStringLiteral(arg) && FS_MODULES.has(arg.getLiteralValue())) {
190
+ violations.push({
191
+ line: importEqualsDecl.getStartLineNumber(),
192
+ moduleSpecifier: arg.getLiteralValue(),
193
+ });
194
+ }
195
+ }
196
+
197
+ violations.push(...findDynamicFsRequires(sf));
198
+
199
+ return violations;
200
+ }
201
+
202
+ export const guard: AstGuard = {
203
+ name: "No-Direct-Fs Guard",
204
+ scan: SCAN,
205
+ security: true,
206
+ hint: "Direkter node:fs-Import außerhalb der Allowlist — nutze FileStorageProvider (packages/framework/src/files/) statt fs selbst zu verdrahten. Path-Traversal-Guard existiert nur dort (resolveContainedPath). Legitimer neuer Tooling-Caller? Allowlist in guard-no-direct-fs.ts erweitern, mit Begründung + repo-Scope.",
207
+ run(files) {
208
+ const violations: Array<{ file: string; line: number; message: string }> = [];
209
+ const roots = resolveRepoRoots();
210
+
211
+ for (const sf of files) {
212
+ const file = sf.getFilePath();
213
+ const rel = relFromRepoRoot(file, roots);
214
+ if (EXCLUDE.test(rel)) continue;
215
+ const root = findRepoRootFor(file, roots);
216
+ if (isRepoAllowlisted(root?.name, rel)) continue;
217
+ for (const v of findDirectFsImports(sf)) {
218
+ violations.push({
219
+ // cwd-relative, not repo-relative `rel` — the security baseline
220
+ // needs an unambiguous path to resolve back to (repo, relPath).
221
+ file: pathRelative(process.cwd(), file),
222
+ line: v.line,
223
+ message: `[${v.moduleSpecifier}] direct fs import outside allowlist`,
224
+ });
225
+ }
226
+ }
227
+
228
+ return { violations };
229
+ },
230
+ };
231
+
232
+ if (import.meta.main) runStandalone(guard);
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * No-Direct-Process-Env Guard.
4
+ *
5
+ * Apps with composed env-schemas (`bin/env.ts`) must read runtime config from
6
+ * the validated `env` export — not scattered `process.env.X` in prod paths.
7
+ *
8
+ * Scans bin/main.ts, other bin ts files (excl. allowlist), and src (excl. tests)
9
+ * in every repo that ships `bin/env.ts`. Allowed raw process.env:
10
+ * - bin/env.ts — sole parseEnv input
11
+ * - bin/server.ts — dev entrypoint (runDevApp)
12
+ * - bin/kumiko.ts — schema/migrate CLI (DATABASE_URL, INIT_CWD)
13
+ */
14
+ import { existsSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { Glob } from "bun";
17
+ import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
18
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
19
+ import { scanLinesForPredicate } from "./_lib/scan-lines";
20
+
21
+ // .tsx deliberately excluded — client screens have no runtime process.env
22
+ // access (bundler boundary), only .ts server/bin code reads process.env
23
+ // directly. Add .tsx here if that ever stops being true.
24
+ const SCAN_PATTERNS: ReadonlyArray<string> = ["bin/*.ts", "src/**/*.ts"];
25
+
26
+ /** bin/*.ts paths that may read process.env directly (see header). */
27
+ export const ALLOWED_BIN_FILES: ReadonlySet<string> = new Set([
28
+ "bin/env.ts",
29
+ "bin/server.ts",
30
+ "bin/kumiko.ts",
31
+ ]);
32
+
33
+ const EXCLUDE_DIR = /(?:^|\/)(?:node_modules|dist|__tests__)\//;
34
+ const IS_TEST = /\.(?:test|integration)\.tsx?$/;
35
+
36
+ // process.env.FOO | process.env.foo | process.env["FOO"] | process.env[`FOO`]
37
+ const PROCESS_ENV_REF = /process\.env(?:\.[A-Za-z_][A-Za-z0-9_]*|\[(?:'[^']+'|"[^"]+"|`[^`]+`)\])/;
38
+
39
+ export type ProcessEnvFinding = {
40
+ readonly file: string;
41
+ readonly line: number;
42
+ readonly text: string;
43
+ };
44
+
45
+ /** Returns true when the line contains a live process.env reference. */
46
+ export function processEnvOnLine(line: string): boolean {
47
+ const code = line.replace(/\/\/.*$/, "");
48
+ return PROCESS_ENV_REF.test(code);
49
+ }
50
+
51
+ export function isScannableBinFile(rel: string): boolean {
52
+ return rel.startsWith("bin/") && rel.endsWith(".ts") && !ALLOWED_BIN_FILES.has(rel);
53
+ }
54
+
55
+ // Fail-scope deliberately covers every repo with bin/env.ts (rather than only
56
+ // hard-failing kumiko-studio and warning on the rest). Kept as a
57
+ // known-candidate allowlist (documents which repos use the bin/env.ts
58
+ // convention at all — "solon" is deliberately not on it) and intersected
59
+ // with the resolved roots, so a restricted scope actually narrows what this
60
+ // guard scans instead of always walking every repo regardless of scope
61
+ // (infra#721 follow-up — a money-horse-scoped push must not fail on a
62
+ // finding in publicstatus).
63
+ const CANDIDATE_ENV_TS_REPOS: ReadonlySet<string> = new Set([
64
+ "kumiko-studio",
65
+ "publicstatus",
66
+ "money-horse",
67
+ "phronexsis",
68
+ ]);
69
+
70
+ export function reposWithEnvTs(roots: ReadonlyArray<RepoRoot> = resolveRepoRoots()): RepoRoot[] {
71
+ return roots.filter(
72
+ (root) => CANDIDATE_ENV_TS_REPOS.has(root.name) && existsSync(join(root.absPath, "bin/env.ts")),
73
+ );
74
+ }
75
+
76
+ export function findDirectProcessEnv(roots?: ReadonlyArray<RepoRoot>): ProcessEnvFinding[] {
77
+ return scanDirectProcessEnv(roots).findings;
78
+ }
79
+
80
+ /**
81
+ * Like findDirectProcessEnv, additionally reports the number of files read
82
+ * and repos with bin/env.ts — without that an empty scan can't be told apart
83
+ * from a clean one (infra#433).
84
+ */
85
+ export function scanDirectProcessEnv(roots: ReadonlyArray<RepoRoot> = resolveRepoRoots()): {
86
+ readonly findings: ProcessEnvFinding[];
87
+ readonly scannedFiles: number;
88
+ readonly reposPresent: number;
89
+ } {
90
+ const findings: ProcessEnvFinding[] = [];
91
+ let scannedFiles = 0;
92
+ const repos = reposWithEnvTs(roots);
93
+ for (const repo of repos) {
94
+ const repoDir = repo.absPath;
95
+ for (const pattern of SCAN_PATTERNS) {
96
+ for (const rel of new Glob(pattern).scanSync({ cwd: repoDir })) {
97
+ if (EXCLUDE_DIR.test(`/${rel}`) || IS_TEST.test(rel)) continue;
98
+ if (rel.startsWith("bin/") && !isScannableBinFile(rel)) continue;
99
+ const abs = join(repoDir, rel);
100
+ scannedFiles++;
101
+ scanLinesForPredicate(abs, `${repo.name}/${rel}`, processEnvOnLine, findings);
102
+ }
103
+ }
104
+ }
105
+ return { findings, scannedFiles, reposPresent: repos.length };
106
+ }
107
+
108
+ export const check: RepoCheck = {
109
+ name: "guard-no-direct-process-env",
110
+ hint: "Read runtime config from bin/env.ts (validated env export), not process.env in bin/main.ts, bin helpers, or src/.",
111
+ run(roots) {
112
+ const scan = scanDirectProcessEnv(roots);
113
+ return {
114
+ violations: scan.findings.map((f) => ({ file: f.file, line: f.line, message: f.text })),
115
+ matchedFiles: scan.scannedFiles,
116
+ // reposPresent as the applicability signal: no repo with bin/env.ts means
117
+ // not applicable, repos present but nothing scanned means broken resolution.
118
+ notApplicable: scan.reposPresent === 0,
119
+ };
120
+ },
121
+ };
122
+
123
+ if (import.meta.main) {
124
+ const failed = reportResults(await runRepoChecks([check]));
125
+ process.exit(failed > 0 ? 1 : 0);
126
+ }
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bun
2
+ // No inline CSS in app code: `style={...}` props and `React.CSSProperties`
3
+ // style objects bypass theme tokens and dark mode. Use widgets/tokens.
4
+ //
5
+ // Part of App-Mounting 2.0 (infra#208).
6
+
7
+ import { type SourceFile, SyntaxKind } from "ts-morph";
8
+ import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
9
+ import { hasIgnoreTag } from "./_lib/ignore-tag";
10
+
11
+ const SCAN: ScanSpec = {
12
+ scope: "source",
13
+ extensions: ["ts", "tsx"],
14
+ frameworkWithin: ["packages/bundled-features/src/**"],
15
+ };
16
+ const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
17
+ const IGNORE_TAG = "kumiko-lint-ignore no-inline-styles";
18
+
19
+ export const guard: AstGuard = {
20
+ name: "No-Inline-Styles Guard (App-Repos)",
21
+ scan: SCAN,
22
+ hint:
23
+ "style=/CSSProperties in App-Code durch Widgets + Theme-Tokens ersetzen. " +
24
+ `Begründete Ausnahme (z.B. dynamische Breite aus Daten): // ${IGNORE_TAG} <Grund>`,
25
+ run(files: readonly SourceFile[]) {
26
+ const violations: GuardViolation[] = [];
27
+ for (const sf of files) {
28
+ if (EXCLUDE.test(sf.getFilePath())) continue;
29
+ for (const attr of sf.getDescendantsOfKind(SyntaxKind.JsxAttribute)) {
30
+ if (attr.getNameNode().getText() !== "style") continue;
31
+ if (hasIgnoreTag(attr, IGNORE_TAG)) continue;
32
+ violations.push({
33
+ file: sf.getFilePath(),
34
+ line: attr.getStartLineNumber(),
35
+ message: "style=-Prop in App-Code (Theme-Tokens/Widgets nutzen)",
36
+ });
37
+ }
38
+ // ponytail: name-text comparison doesn't tolerate aliased imports
39
+ // (import { CSSProperties as CSS }) and false-positives on same-named
40
+ // local types. The style=-JsxAttribute above is the primary, more
41
+ // robust signal path — this TypeReference check is deliberately
42
+ // best-effort.
43
+ for (const ref of sf.getDescendantsOfKind(SyntaxKind.TypeReference)) {
44
+ const name = ref.getTypeName().getText();
45
+ if (name !== "CSSProperties" && name !== "React.CSSProperties") continue;
46
+ if (hasIgnoreTag(ref, IGNORE_TAG)) continue;
47
+ violations.push({
48
+ file: sf.getFilePath(),
49
+ line: ref.getStartLineNumber(),
50
+ message: "CSSProperties-Style-Objekt in App-Code (Theme-Tokens/Widgets nutzen)",
51
+ });
52
+ }
53
+ }
54
+ return { violations };
55
+ },
56
+ };
57
+
58
+ if (import.meta.main) runStandalone(guard);