@cosmicdrift/kumiko-guards 0.340.0 → 0.342.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.340.0",
3
+ "version": "0.342.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>",
@@ -28,7 +28,7 @@
28
28
  "kumiko-pre-push": "./src/pre-push.sh"
29
29
  },
30
30
  "dependencies": {
31
- "@cosmicdrift/kumiko-repo-manifest": "0.340.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.342.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
@@ -56,7 +56,7 @@ export type AstGuard = {
56
56
  /** `roots` are the same roots `runGuards` scanned `files` with — a guard classifying paths against repo roots (`relFromRepoRoot`) must use these, not re-derive its own single-repo `resolveRepoRoots()`, or a multi-root caller's roots never reach it. */
57
57
  run(files: readonly SourceFile[], roots?: readonly RepoRoot[]): GuardOutcome;
58
58
  /** Ratchet guards only: freeze the current findings into that guard's own baseline file. */
59
- writeBaseline?(files: readonly SourceFile[]): void;
59
+ writeBaseline?(files: readonly SourceFile[], roots?: readonly RepoRoot[]): void;
60
60
  };
61
61
 
62
62
  export function isSecurityGuard(guard: Pick<AstGuard, "security">): boolean {
@@ -195,6 +195,8 @@ export function baselineRatchet(args: {
195
195
  readonly file: string;
196
196
  readonly formatVersion: number;
197
197
  readonly unit: string;
198
+ /** A missing baseline file counts as an empty baseline (every current entry is a regression) instead of a warning. For guards whose suppression must not hold without a frozen file. */
199
+ readonly failClosed?: boolean;
198
200
  }): {
199
201
  write(current: Readonly<Record<string, number>>): void;
200
202
  check(
@@ -229,6 +231,13 @@ export function baselineRatchet(args: {
229
231
  readonly resolveLine?: (file: string) => number;
230
232
  },
231
233
  ): GuardViolation[] => {
234
+ if (!existsSync(args.file) && args.failClosed === true) {
235
+ return compareToBaseline(current, {}).regressions.map((regression) => ({
236
+ file: regression.file,
237
+ line: opts?.resolveLine?.(regression.file) ?? 1,
238
+ message: `${args.unit} without a baseline file (${args.file}): ${regression.current} not frozen. ${remediation}`,
239
+ }));
240
+ }
232
241
  if (!existsSync(args.file)) {
233
242
  console.log(
234
243
  ` No baseline found (${args.file}). Freeze it first with \`--write-baseline\` — warning until then, no fail.`,
package/src/changes.json CHANGED
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.341.0",
4
+ "type": "breaking",
5
+ "title": "Repo-local direct-fs exceptions with a frozen reason, bundled read-only assets",
6
+ "detail": "`guard-no-direct-fs` has a repo-local exception: `// kumiko-lint-ignore direct-fs <reason>` on the `node:fs` import (or the line above) suppresses the finding only while the `<file>::<reason>` pair is frozen in `.kumiko-direct-fs-baseline.json` at the repo root. It fails closed: without a baseline file, with an unreadable file, with a changed reason, or with more markers than frozen, the finding stays. A bare tag without a reason does not count. Freeze with `kumiko-guards guards --write-baseline --guard=\"No-Direct-Fs Guard\"`. The guard hint now points to the marker, `FileStorageProvider` and `readBundledAsset`. `AstGuard.writeBaseline` receives the scanned roots as an optional second argument, and `baselineRatchet` takes an opt-in `failClosed` that treats a missing baseline file as empty.\n**Migration (breaking-ish):** the framework allowlist no longer contains `src/marketing/render-landing.ts` and `src/marketing/rebuild-pages-job.ts` for every repo. Apps that have these files (today money-horse, phronexsis, show-pony, publicstatus) put `// kumiko-lint-ignore direct-fs <reason>` above the `node:fs` import in each file and run `kumiko-guards guards --write-baseline --guard=\"No-Direct-Fs Guard\"` once in the app repo. Commit the generated `.kumiko-direct-fs-baseline.json`.\n`@cosmicdrift/kumiko-server-runtime` can ship read-only files from an app's own build. Declare them in `package.json` under `kumiko.assets` (`[{ \"name\": \"inter-bold.ttf\", \"source\": \"packages/site-kit/fonts/inter-bold.ttf\" }]`, `source` relative to the app's `package.json`). `buildProdBundle` copies them to `dist/kumiko-bundled-assets/`, which the static file server does not serve, and fails the build on an invalid name, a duplicate, a source outside the package or a missing file. `readBundledAsset(name)` and `resolveBundledAsset(name)` read the dist copy in prod and the declared source in dev, so apps need no `node:fs` for it.",
7
+ "migration": "The allowlist no longer frees src/marketing/render-landing.ts and src/marketing/rebuild-pages-job.ts in every repo. Put // kumiko-lint-ignore direct-fs <reason> above the node:fs import in each such file, run kumiko-guards guards --write-baseline --guard=\"No-Direct-Fs Guard\" once and commit .kumiko-direct-fs-baseline.json."
8
+ },
2
9
  {
3
10
  "version": "0.340.0",
4
11
  "type": "improvement",
@@ -19,17 +19,29 @@
19
19
  * one repo cannot silently free node:fs in another under the same relative
20
20
  * path.
21
21
  *
22
+ * Repo-local exception for a justified import (tooling, build-time reads):
23
+ * // kumiko-lint-ignore direct-fs <reason>
24
+ * on the import line or the line directly above. A bare tag without a reason
25
+ * does not count. The marker suppresses only while its `<file>::<reason>` pair
26
+ * is frozen in `.kumiko-direct-fs-baseline.json` at the repo root — fail-closed:
27
+ * no file, an unreadable file, a changed reason or more markers than frozen
28
+ * all make the guard fail. Freeze with:
29
+ * kumiko-guards guards --write-baseline --guard="No-Direct-Fs Guard"
30
+ *
22
31
  * Usage:
23
32
  * bun guards/guard-no-direct-fs.ts
24
33
  *
25
34
  * Exit 1 on violations in non-allowlisted files, 0 when clean.
26
35
  */
27
36
 
28
- import { relative as pathRelative } from "node:path";
37
+ import { existsSync } from "node:fs";
38
+ import { join, relative as pathRelative } from "node:path";
29
39
  import { Node, type SourceFile, SyntaxKind } from "ts-morph";
30
40
  import {
31
41
  type AstGuard,
42
+ baselineRatchet,
32
43
  findRepoRootFor,
44
+ type GuardViolation,
33
45
  relFromRepoRoot,
34
46
  runStandalone,
35
47
  type ScanSpec,
@@ -86,8 +98,10 @@ const ALLOWLIST: readonly AllowEntry[] = [
86
98
  },
87
99
 
88
100
  // server-runtime: prod bundle build + static asset delivery from the
89
- // build output (no user-controlled path).
101
+ // build output (no user-controlled path). bundled-assets.ts reads declared
102
+ // read-only assets, name-pattern + containment checked.
90
103
  { repo: "kumiko-framework", pattern: /^packages\/server-runtime\/src\/build-prod-bundle\.ts$/ },
104
+ { repo: "kumiko-framework", pattern: /^packages\/server-runtime\/src\/bundled-assets\.ts$/ },
91
105
  {
92
106
  repo: "kumiko-framework",
93
107
  pattern: /^packages\/server-runtime\/src\/run-prod-app-static-files\.ts$/,
@@ -110,12 +124,6 @@ const ALLOWLIST: readonly AllowEntry[] = [
110
124
  // walk from cwd, no user input as a path.
111
125
  { repo: "kumiko-enterprise", pattern: /^packages\/ai-foundation\/src\/prompt\/corpus\.ts$/ },
112
126
 
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
127
  // kumiko-enterprise publish pipeline: materialize.ts is the guarded
120
128
  // place (writeMaterializedTree — resolve()+startsWith(root+sep) check,
121
129
  // same pattern as local-provider.ts). build.ts/feature.ts/validate.ts
@@ -153,6 +161,100 @@ interface Violation {
153
161
  moduleSpecifier: string;
154
162
  }
155
163
 
164
+ const BASELINE_FILE = ".kumiko-direct-fs-baseline.json";
165
+ // Must sit in a // comment, so a string literal describing the marker does not suppress.
166
+ const MARKER_WITH_REASON_RE = /(^|\s)\/\/\s*kumiko-lint-ignore direct-fs\s+(\S.*)$/;
167
+
168
+ const MARKER_REMEDIATION =
169
+ "New, reworded or unfrozen direct-fs marker. Prefer FileStorageProvider or readBundledAsset; " +
170
+ 'otherwise freeze the exception with `kumiko-guards guards --write-baseline --guard="No-Direct-Fs Guard"` ' +
171
+ "so the baseline diff shows up in review.";
172
+
173
+ type RootMarkers = {
174
+ readonly root: RepoRoot;
175
+ /** `<file>::<reason>` -> distinct marker lines (1-based). */
176
+ readonly linesByKey: Map<string, Set<number>>;
177
+ };
178
+
179
+ function ratchetFor(root: RepoRoot) {
180
+ return baselineRatchet({
181
+ file: join(root.absPath, BASELINE_FILE),
182
+ formatVersion: 1,
183
+ unit: "direct-fs marker(s)",
184
+ failClosed: true,
185
+ });
186
+ }
187
+
188
+ function markerFor(
189
+ lines: readonly string[],
190
+ importLine: number,
191
+ ): { readonly reason: string; readonly line: number } | undefined {
192
+ const sameLine = MARKER_WITH_REASON_RE.exec(lines[importLine - 1] ?? "")?.[2];
193
+ if (sameLine !== undefined) return { reason: sameLine.trim(), line: importLine };
194
+ const above = MARKER_WITH_REASON_RE.exec(lines[importLine - 2] ?? "")?.[2];
195
+ return above === undefined ? undefined : { reason: above.trim(), line: importLine - 1 };
196
+ }
197
+
198
+ function countsOf(markers: RootMarkers): Record<string, number> {
199
+ return Object.fromEntries([...markers.linesByKey].map(([key, lines]) => [key, lines.size]));
200
+ }
201
+
202
+ function collect(
203
+ files: readonly SourceFile[],
204
+ roots: readonly RepoRoot[],
205
+ ): { readonly unsuppressed: GuardViolation[]; readonly markers: Map<string, RootMarkers> } {
206
+ const unsuppressed: GuardViolation[] = [];
207
+ const markers = new Map<string, RootMarkers>();
208
+
209
+ for (const sf of files) {
210
+ const file = sf.getFilePath();
211
+ const rel = relFromRepoRoot(file, roots);
212
+ if (EXCLUDE.test(rel)) continue;
213
+ const root = findRepoRootFor(file, roots);
214
+ if (isRepoAllowlisted(root?.name, rel)) continue;
215
+ const lines = sf.getFullText().split("\n");
216
+ for (const v of findDirectFsImports(sf)) {
217
+ const marker = root === undefined ? undefined : markerFor(lines, v.line);
218
+ if (root !== undefined && marker !== undefined) {
219
+ const entry = markers.get(root.absPath) ?? { root, linesByKey: new Map() };
220
+ const key = `${rel}::${marker.reason}`;
221
+ const set = entry.linesByKey.get(key) ?? new Set<number>();
222
+ set.add(marker.line);
223
+ entry.linesByKey.set(key, set);
224
+ markers.set(root.absPath, entry);
225
+ continue;
226
+ }
227
+ unsuppressed.push({
228
+ // cwd-relative, not repo-relative `rel` — the security baseline
229
+ // needs an unambiguous path to resolve back to (repo, relPath).
230
+ file: pathRelative(process.cwd(), file),
231
+ line: v.line,
232
+ message: `[${v.moduleSpecifier}] direct fs import outside allowlist`,
233
+ });
234
+ }
235
+ }
236
+ return { unsuppressed, markers };
237
+ }
238
+
239
+ function markerViolations(markers: ReadonlyMap<string, RootMarkers>): GuardViolation[] {
240
+ return [...markers.values()].flatMap((m) =>
241
+ ratchetFor(m.root)
242
+ .check(countsOf(m), MARKER_REMEDIATION, {
243
+ resolveLine: (key) => [...(m.linesByKey.get(key) ?? [])].sort((a, b) => a - b)[0] ?? 1,
244
+ })
245
+ .map((v) => {
246
+ const [relFile] = v.file.split("::");
247
+ const isMarkerKey = v.file.includes("::") && relFile !== undefined;
248
+ return {
249
+ ...v,
250
+ file: isMarkerKey ? pathRelative(process.cwd(), join(m.root.absPath, relFile)) : v.file,
251
+ // A frozen security baseline must never excuse an unfrozen marker.
252
+ neverFrozen: true,
253
+ };
254
+ }),
255
+ );
256
+ }
257
+
156
258
  function findDynamicFsRequires(sf: SourceFile): Violation[] {
157
259
  const violations: Violation[] = [];
158
260
 
@@ -208,28 +310,20 @@ export const guard: AstGuard = {
208
310
  name: "No-Direct-Fs Guard",
209
311
  scan: SCAN,
210
312
  security: true,
211
- hint: "Direct node:fs import outside the allowlist — use FileStorageProvider (packages/framework/src/files/) instead of wiring fs yourself. The path-traversal guard (resolveContainedPath) only exists there. Legitimate new tooling caller? Extend the allowlist in guard-no-direct-fs.ts, with a reason + repo scope.",
313
+ hint: 'Direct node:fs import outside the allowlist — use FileStorageProvider (packages/framework/src/files/) instead of wiring fs yourself; the path-traversal guard (resolveContainedPath) only exists there. Read-only files your own build ships: declare them under package.json `kumiko.assets` and use readBundledAsset(name) from @cosmicdrift/kumiko-server-runtime. Legitimate tooling exception: mark the import with `// kumiko-lint-ignore direct-fs <reason>` and freeze it with `kumiko-guards guards --write-baseline --guard="No-Direct-Fs Guard"` (.kumiko-direct-fs-baseline.json, reviewed as a diff).',
212
314
  run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
213
- const violations: Array<{ file: string; line: number; message: string }> = [];
214
-
215
- for (const sf of files) {
216
- const file = sf.getFilePath();
217
- const rel = relFromRepoRoot(file, roots);
218
- if (EXCLUDE.test(rel)) continue;
219
- const root = findRepoRootFor(file, roots);
220
- if (isRepoAllowlisted(root?.name, rel)) continue;
221
- for (const v of findDirectFsImports(sf)) {
222
- violations.push({
223
- // cwd-relative, not repo-relative `rel` — the security baseline
224
- // needs an unambiguous path to resolve back to (repo, relPath).
225
- file: pathRelative(process.cwd(), file),
226
- line: v.line,
227
- message: `[${v.moduleSpecifier}] direct fs import outside allowlist`,
228
- });
315
+ const { unsuppressed, markers } = collect(files, roots);
316
+ return { violations: [...unsuppressed, ...markerViolations(markers)] };
317
+ },
318
+ writeBaseline(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
319
+ const { markers } = collect(files, roots);
320
+ for (const m of markers.values()) ratchetFor(m.root).write(countsOf(m));
321
+ // A repo whose last marker is gone keeps a stale baseline unless it is shrunk too.
322
+ for (const root of roots) {
323
+ if (!markers.has(root.absPath) && existsSync(join(root.absPath, BASELINE_FILE))) {
324
+ ratchetFor(root).write({});
229
325
  }
230
326
  }
231
-
232
- return { violations };
233
327
  },
234
328
  };
235
329
 
package/src/run-guards.ts CHANGED
@@ -160,7 +160,10 @@ export function runGuardsCli(argv: readonly string[], deps: RunGuardsCliDeps = {
160
160
  return 1;
161
161
  }
162
162
  const project = buildSharedProject([target], deps.writeBaselineRoots);
163
- target.writeBaseline(filesForGuard(project, target, deps.writeBaselineRoots));
163
+ target.writeBaseline(
164
+ filesForGuard(project, target, deps.writeBaselineRoots),
165
+ deps.writeBaselineRoots,
166
+ );
164
167
  return 0;
165
168
  }
166
169
  const strictSecurityBaseline = flags.includes("--strict-security-baseline");