@cosmicdrift/kumiko-guards 0.331.0 → 0.333.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.331.0",
3
+ "version": "0.333.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.331.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.333.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
@@ -10,11 +10,37 @@
10
10
  */
11
11
  export const GIT_ENV_KEYS = ["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "USER", "LOGNAME"] as const;
12
12
 
13
+ /**
14
+ * Extra keys for git calls that talk to a remote (`git fetch`): without the
15
+ * SSH agent / proxy settings the transport cannot authenticate or connect.
16
+ * Opt-in via `gitEnv(env, { transport: true })`; local-only calls stay narrow.
17
+ * `GIT_DIR`/`GIT_WORK_TREE` are deliberately never part of this list.
18
+ */
19
+ export const GIT_TRANSPORT_ENV_KEYS = [
20
+ "SSH_AUTH_SOCK",
21
+ "SSH_AGENT_PID",
22
+ "GIT_SSH",
23
+ "GIT_SSH_COMMAND",
24
+ "GIT_ASKPASS",
25
+ "SSH_ASKPASS",
26
+ "GIT_TERMINAL_PROMPT",
27
+ "GIT_SSL_CAINFO",
28
+ "HTTP_PROXY",
29
+ "HTTPS_PROXY",
30
+ "http_proxy",
31
+ "https_proxy",
32
+ "NO_PROXY",
33
+ "no_proxy",
34
+ "ALL_PROXY",
35
+ ] as const;
36
+
13
37
  export function gitEnv(
14
38
  env: Readonly<Record<string, string | undefined>> = process.env,
39
+ options: { readonly transport?: boolean } = {},
15
40
  ): Record<string, string> {
41
+ const keys = options.transport ? [...GIT_ENV_KEYS, ...GIT_TRANSPORT_ENV_KEYS] : GIT_ENV_KEYS;
16
42
  const out: Record<string, string> = {};
17
- for (const key of GIT_ENV_KEYS) {
43
+ for (const key of keys) {
18
44
  const value = env[key];
19
45
  if (value !== undefined) out[key] = value;
20
46
  }
@@ -271,12 +271,12 @@ export type RunResult = {
271
271
  readonly warnings?: readonly GuardViolation[];
272
272
  };
273
273
 
274
- // Per-root floor: a root whose declared sourceRoots hold zero .ts/.tsx files is a violation; only applies to scope "source".
274
+ // Per-root floor: a root whose declared sourceRoots hold zero .ts/.tsx files is a violation; applies to every scope that includes source ("source", "source+tests").
275
275
  export function checkRootFloor(
276
276
  guard: Pick<AstGuard, "scan">,
277
277
  scans: readonly RootScan[],
278
278
  ): { readonly violatingRoots: readonly string[] } {
279
- if (guard.scan.scope !== "source") return { violatingRoots: [] };
279
+ if (guard.scan.scope === "tests") return { violatingRoots: [] };
280
280
  return {
281
281
  violatingRoots: scans.filter((scan) => scan.sourceSurface === 0).map((scan) => scan.root.name),
282
282
  };
@@ -531,6 +531,7 @@ export function printGuardKitBanner(
531
531
  if (error !== undefined) {
532
532
  console.error(error);
533
533
  process.exit(1);
534
+ return;
534
535
  }
535
536
  console.log(`kumiko-guards ${guardKitVersion()} - ${guardCount} guards registered`);
536
537
  console.log(`Roots: ${roots.map((r) => r.root.name).join(", ")} (${roots.length})`);
@@ -24,7 +24,9 @@ export type ScanSpec =
24
24
  /** Globs relative to the matched source root dir; narrows only. */
25
25
  readonly within?: readonly string[];
26
26
  })
27
- | (ScanSpecBase & { readonly scope: "tests" });
27
+ | (ScanSpecBase & { readonly scope: "tests" })
28
+ // Union of the source and test surface — for guards that correlate source declarations with test coverage.
29
+ | (ScanSpecBase & { readonly scope: "source+tests" });
28
30
 
29
31
  export type RootScan = {
30
32
  readonly root: RepoRoot;
@@ -141,13 +143,21 @@ function keepForSpec(hit: Hit, root: RepoRoot, spec: ScanSpec): boolean {
141
143
 
142
144
  function scanRoot(spec: ScanSpec, root: RepoRoot): RootScan {
143
145
  const sourceSurfaceHits = afterExcludes(sourceHits(root), root.manifest.excludes);
146
+ const testSurfaceHits = () => afterExcludes(testHits(root), root.manifest.excludes);
144
147
  const scopeHits =
145
148
  spec.scope === "source"
146
149
  ? sourceSurfaceHits
147
- : afterExcludes(testHits(root), root.manifest.excludes);
148
- const seen = new Set(scopeHits.map((hit) => hit.repoRel));
150
+ : spec.scope === "tests"
151
+ ? testSurfaceHits()
152
+ : [...sourceSurfaceHits, ...testSurfaceHits()];
153
+ const seen = new Set<string>();
154
+ const uniqueScopeHits = scopeHits.filter((hit) => {
155
+ if (seen.has(hit.repoRel)) return false;
156
+ seen.add(hit.repoRel);
157
+ return true;
158
+ });
149
159
  const additionalHits = afterExcludes(extraHits(root, spec.extraGlobs), root.manifest.excludes);
150
- const files = [...scopeHits, ...additionalHits.filter((hit) => !seen.has(hit.repoRel))]
160
+ const files = [...uniqueScopeHits, ...additionalHits.filter((hit) => !seen.has(hit.repoRel))]
151
161
  .filter((hit) => keepForSpec(hit, root, spec))
152
162
  .map((hit) => join(root.absPath, hit.repoRel))
153
163
  .sort();
@@ -86,10 +86,14 @@ export function parseSecurityBaseline(raw: unknown): SecurityBaseline | undefine
86
86
  }
87
87
 
88
88
  export function loadSecurityBaseline(repo: string, repoDir: string): SecurityBaselineLoad {
89
+ const file = securityBaselinePath(repoDir);
89
90
  if (!REPO_NAME_RE.test(repo)) {
90
- throw new Error(`loadSecurityBaseline: invalid repo name "${repo}"`);
91
+ return {
92
+ kind: "invalid",
93
+ file,
94
+ reason: `repo name "${repo}" from package.json is not a valid lowercase package name`,
95
+ };
91
96
  }
92
- const file = securityBaselinePath(repoDir);
93
97
  if (!existsSync(file)) return { kind: "ok", findings: {}, hardFail: [] };
94
98
  let raw: unknown;
95
99
  try {
@@ -0,0 +1,2 @@
1
+ /** `*.test.ts(x)`, `*.integration.ts(x)` and `*.integration.test.ts(x)`. */
2
+ export const TEST_FILE_RE = /\.(?:test|integration)(?:\.test)?\.tsx?$/;
package/src/changes.json CHANGED
@@ -1,4 +1,48 @@
1
1
  [
2
+ {
3
+ "version": "0.332.0",
4
+ "type": "fix",
5
+ "title": "direct-entity-writes canary counts only real table writes"
6
+ },
7
+ {
8
+ "version": "0.332.0",
9
+ "type": "improvement",
10
+ "title": "gitEnv is exported from the package root"
11
+ },
12
+ {
13
+ "version": "0.332.0",
14
+ "type": "fix",
15
+ "title": "git transport env is kept for the changes-json fetch"
16
+ },
17
+ {
18
+ "version": "0.332.0",
19
+ "type": "improvement",
20
+ "title": "guards resolve aliased imports, literal values, tsx lib files and entityEdit slots precisely",
21
+ "migration": "Neue Violations moeglich: lib-test-coverage erfasst jetzt .tsx und tiefere lib-Ebenen und zaehlt Fixtures unter __tests__ ohne .test-Suffix nicht als Test; test-stack-drift loest Import-Aliase auf; direct-fetch wertet den Laufzeitwert des Literals aus; no-framed-extension-sections behandelt nur slots.header von entityEdit-Screens als Header. Betroffene Stellen mit Test, echtem Import oder Anpassung beheben."
22
+ },
23
+ {
24
+ "version": "0.332.0",
25
+ "type": "improvement",
26
+ "title": "Coverage guards scan test files; admin-api, primitives-discipline and real-provider-isolation resolve roots and workspaces properly",
27
+ "migration": "Neue Violations moeglich: access-denied-test und tenant-escalation zaehlen jetzt auch *.integration.ts und Tests ausserhalb der sourceRoots als Coverage (Scope source+tests); primitives-discipline scannt alle flat-src-App-Roots; check-real-provider-isolation flaggt Scripts, die test:real/e2e:real aufrufen, und prueft alle Workspace-package.json. Betroffene Stellen fixen oder per Security-Baseline neu einfrieren."
28
+ },
29
+ {
30
+ "version": "0.332.0",
31
+ "type": "improvement",
32
+ "title": "secret-literal guard flags more fallbacks and no longer leaks literals",
33
+ "migration": "Neue Violations bei `*/ code`-Zeilen, Code-Zeilen mit fuehrendem `*` und Literalen mit hmac/private-key/signing-key; die Violation-Message nennt Name und Laenge statt der Zeile. Fallbacks entfernen und das Secret hart aus dem validierten Env lesen."
34
+ },
35
+ {
36
+ "version": "0.332.0",
37
+ "type": "improvement",
38
+ "title": "html-escape guard flags unescaped intermediate templates and raw-returning local helpers",
39
+ "migration": "Neue Violations bei ungeescapten Zwischen-Templates und lokalen Helfern, die Input roh zurueckgeben; diese per escapeHtml absichern."
40
+ },
41
+ {
42
+ "version": "0.332.0",
43
+ "type": "improvement",
44
+ "title": "worktree pre-push runs the consumer-CI guards"
45
+ },
2
46
  {
3
47
  "version": "0.331.0",
4
48
  "type": "improvement",
@@ -68,32 +68,66 @@ function scanCiWorkflows(root: RepoRoot, findings: TextLineFinding[]): number {
68
68
  // package.json scripts: a real-provider signal in any script OTHER than the
69
69
  // two designated ones is a leak (e.g. a "test" or "ci" script that pulls in
70
70
  // the real env or filters for a .real. spec by hand).
71
+ const REAL_SCRIPT_CALL = /\b(?:test|e2e):real\b/;
72
+
71
73
  function scriptLeaksRealProvider(name: string, command: string): string | undefined {
72
74
  if (REAL_SCRIPT_NAMES.has(name)) return undefined;
73
75
  if (command.includes(REAL_PROVIDERS_ENV)) {
74
76
  return `script "${name}" sets ${REAL_PROVIDERS_ENV} outside test:real/e2e:real: ${command}`;
75
77
  }
78
+ if (REAL_SCRIPT_CALL.test(command)) {
79
+ return `script "${name}" invokes test:real/e2e:real, which would run real-provider specs from a default script: ${command}`;
80
+ }
76
81
  if (/\.real\.(test|spec)\.ts/.test(command)) {
77
82
  return `script "${name}" references a *.real. spec/test outside test:real/e2e:real: ${command}`;
78
83
  }
79
84
  return undefined;
80
85
  }
81
86
 
82
- function scanPackageJsonScripts(root: RepoRoot, findings: TextLineFinding[]): number {
83
- const path = join(root.absPath, "package.json");
84
- let parsed: { scripts?: Record<string, string> };
87
+ type WorkspaceManifest = {
88
+ scripts?: Record<string, string>;
89
+ workspaces?: string[] | { packages?: string[] };
90
+ };
91
+
92
+ function readManifest(abs: string): WorkspaceManifest | undefined {
85
93
  try {
86
- parsed = JSON.parse(readFileSync(path, "utf8")) as { scripts?: Record<string, string> };
94
+ return JSON.parse(readFileSync(abs, "utf8")) as WorkspaceManifest;
87
95
  } catch {
88
- return 0;
96
+ return undefined;
89
97
  }
90
- const scripts = parsed.scripts ?? {};
98
+ }
99
+
100
+ function workspacePatterns(manifest: WorkspaceManifest): string[] {
101
+ const declared = Array.isArray(manifest.workspaces)
102
+ ? manifest.workspaces
103
+ : (manifest.workspaces?.packages ?? []);
104
+ return declared.filter((pattern) => !pattern.startsWith("!"));
105
+ }
106
+
107
+ // Root package.json plus every workspace package.json the root's `workspaces` globs resolve to.
108
+ function packageJsonRels(root: RepoRoot, rootManifest: WorkspaceManifest): string[] {
109
+ const rels = new Set<string>(["package.json"]);
110
+ for (const pattern of workspacePatterns(rootManifest)) {
111
+ const glob = new Glob(`${pattern.replace(/\/$/, "")}/package.json`);
112
+ for (const rel of glob.scanSync({ cwd: root.absPath })) {
113
+ if (rel.split("/").includes("node_modules")) continue;
114
+ rels.add(rel);
115
+ }
116
+ }
117
+ return [...rels].sort();
118
+ }
119
+
120
+ function scanPackageJsonScripts(root: RepoRoot, findings: TextLineFinding[]): number {
121
+ const rootManifest = readManifest(join(root.absPath, "package.json"));
122
+ if (rootManifest === undefined) return 0;
91
123
  let scanned = 0;
92
- for (const [name, command] of Object.entries(scripts)) {
93
- scanned++;
94
- const message = scriptLeaksRealProvider(name, command);
95
- if (message !== undefined) {
96
- findings.push({ file: "package.json", line: 1, text: message });
124
+ for (const rel of packageJsonRels(root, rootManifest)) {
125
+ const manifest = rel === "package.json" ? rootManifest : readManifest(join(root.absPath, rel));
126
+ if (manifest === undefined) continue;
127
+ for (const [name, command] of Object.entries(manifest.scripts ?? {})) {
128
+ scanned++;
129
+ const message = scriptLeaksRealProvider(name, command);
130
+ if (message !== undefined) findings.push({ file: rel, line: 1, text: message });
97
131
  }
98
132
  }
99
133
  return scanned;
@@ -22,11 +22,12 @@
22
22
  * jedes Repo läuft diesen Check in seiner eigenen CI gegen sich selbst,
23
23
  * statt dass ein zentraler Scan in fremde Sibling-Checkouts greift.
24
24
  */
25
+ import { readFileSync } from "node:fs";
25
26
  import { join } from "node:path";
26
27
  import { Glob } from "bun";
28
+ import { ts } from "ts-morph";
27
29
  import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
28
30
  import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
29
- import { scanLinesForPredicate } from "./_lib/scan-lines";
30
31
 
31
32
  // Server-side only: a declared sourceRoot under a mobile/client app has no server secrets to leak.
32
33
  const CLIENT_SOURCE_ROOT = /(?:^|\/)mobile(?:\/|$)/;
@@ -42,31 +43,94 @@ const DEV_ENTRYPOINT = /(?:^|\/)bin\/server\.ts$/;
42
43
 
43
44
  // A `?? "literal"` / `|| "literal"` nullish/or fallback to a string literal.
44
45
  const STRING_FALLBACK = /(?:\?\?|\|\|)\s*(['"`])([^'"`\n]+)\1/;
45
- // ...on a line that is about a secret (assignee name or the literal itself).
46
- const SECRET_CONTEXT = /secret|password|passphrase|hmac|private[_-]?key|signing[_-]?key/i;
46
+ // A secret-like identifier. Matched only against the text LEFT of the fallback
47
+ // (assignee / property name): `cfg.title ?? "Secret Santa"` is a label, not a secret.
48
+ const SECRET_NAME =
49
+ /[\w$]*(?:secret|password|passphrase|hmac|private[_-]?key|signing[_-]?key)[\w$]*/i;
47
50
  // Short / numeric literals are versions or flags (e.g. `_CURRENT_VERSION ?? "1"`),
48
51
  // never a usable secret.
49
52
  const TRIVIAL_LITERAL = /^[\d.]+$/;
53
+ const MIN_SECRET_LENGTH = 8;
54
+ const IGNORE_TAG = "kumiko-lint-ignore secret-literal";
50
55
 
51
56
  export type SecretLiteralFinding = {
52
57
  readonly file: string;
53
58
  readonly line: number;
54
- readonly text: string;
59
+ /** Assignee / property name left of the fallback. Never the literal. */
60
+ readonly name: string;
61
+ readonly literalLength: number;
55
62
  };
56
63
 
57
- /** Returns the offending literal, or null when the line is clean. */
58
- export function secretLiteralOnLine(line: string): string | null {
59
- // Only the line start counts as a comment (`//`, or a block-comment line
60
- // starting with `*`/`/*`/`*/`): stripping from the first `//` anywhere in
61
- // the line would cut connection-string fallbacks (postgres://, redis://,
62
- // https://token@host/…) off before their closing quote, losing findings.
63
- const code = /^\s*(?:\/\/|\/\*|\*\/|\*)/.test(line) ? "" : line;
64
- if (!SECRET_CONTEXT.test(code)) return null;
65
- const match = STRING_FALLBACK.exec(code);
66
- if (!match) return null;
67
- const literal = match[2];
68
- if (!literal || literal.length < 8 || TRIVIAL_LITERAL.test(literal)) return null;
69
- return literal;
64
+ const isJsDocKind = (kind: ts.SyntaxKind): boolean =>
65
+ kind >= ts.SyntaxKind.FirstJSDocNode && kind <= ts.SyntaxKind.LastJSDocNode;
66
+
67
+ // Blanks every comment with spaces (newlines kept, so line numbers survive).
68
+ // The real TypeScript parser decides what is a comment: a hand-rolled
69
+ // per-line tokenizer misreads regex literals (`/^https?:\/*/`) and template
70
+ // continuation lines as comment starts and then skips code, which would make
71
+ // this security guard fail open. Trivia between two tokens contains only
72
+ // whitespace and comments, so it is masked directly from the token gaps.
73
+ function maskComments(source: string): string {
74
+ const sourceFile = ts.createSourceFile("scan.ts", source, ts.ScriptTarget.Latest, false);
75
+ const chars = [...source];
76
+ const blank = (from: number, to: number): void => {
77
+ for (let i = from; i < to; i++) if (chars[i] !== "\n") chars[i] = " ";
78
+ };
79
+ const visit = (node: ts.Node): void => {
80
+ const children = node.getChildren(sourceFile).filter((child) => !isJsDocKind(child.kind));
81
+ if (children.length > 0) {
82
+ for (const child of children) visit(child);
83
+ return;
84
+ }
85
+ const trivia = source.slice(node.pos, node.getStart(sourceFile));
86
+ for (const m of trivia.matchAll(/\/\/[^\n]*|\/\*[\s\S]*?\*\//g)) {
87
+ blank(node.pos + m.index, node.pos + m.index + m[0].length);
88
+ }
89
+ };
90
+ visit(sourceFile);
91
+ return chars.join("");
92
+ }
93
+
94
+ export type SecretLiteralHit = {
95
+ readonly lineNumber: number;
96
+ readonly name: string;
97
+ readonly literalLength: number;
98
+ };
99
+
100
+ // Literal content alone is enough when it names key material; bare
101
+ // secret/password stays name-only so "Secret Santa" labels are not flagged.
102
+ const SECRET_LITERAL_TOKEN = /hmac|private[_-]?key|signing[_-]?key/i;
103
+ // Assignee / property name directly left of the fallback (`k = …`, `key: …`).
104
+ const LHS_NAME = /([\w$]+)\s*[:=][^:=]*$/;
105
+ const UNNAMED_PLACEHOLDER = "<unnamed>";
106
+
107
+ /**
108
+ * Per-file scan on comment-masked source (see maskComments), so only comment
109
+ * text is skipped and a wrapped code line that starts with `*` is still
110
+ * checked. Opt-out: `// kumiko-lint-ignore secret-literal <reason>` on the
111
+ * line itself or the line above (checked on the raw lines).
112
+ */
113
+ export function scanLinesForSecretLiterals(lines: readonly string[]): SecretLiteralHit[] {
114
+ const hits: SecretLiteralHit[] = [];
115
+ const maskedLines = maskComments(lines.join("\n")).split("\n");
116
+ for (let i = 0; i < lines.length; i++) {
117
+ const raw = lines[i] ?? "";
118
+ const code = maskedLines[i] ?? "";
119
+ const match = STRING_FALLBACK.exec(code);
120
+ if (!match) continue;
121
+ const literal = match[2];
122
+ if (!literal || literal.length < MIN_SECRET_LENGTH || TRIVIAL_LITERAL.test(literal)) continue;
123
+ const left = code.slice(0, match.index);
124
+ const name =
125
+ SECRET_NAME.exec(left)?.[0] ??
126
+ (SECRET_LITERAL_TOKEN.test(literal)
127
+ ? (LHS_NAME.exec(left)?.[1] ?? UNNAMED_PLACEHOLDER)
128
+ : undefined);
129
+ if (name === undefined) continue;
130
+ if (raw.includes(IGNORE_TAG) || (lines[i - 1] ?? "").includes(IGNORE_TAG)) continue;
131
+ hits.push({ lineNumber: i + 1, name, literalLength: literal.length });
132
+ }
133
+ return hits;
70
134
  }
71
135
 
72
136
  async function scanRoot(
@@ -79,9 +143,16 @@ async function scanRoot(
79
143
  if (EXCLUDE_DIR.test(`/${rel}`) || IS_TEST.test(rel) || DEV_ENTRYPOINT.test(rel)) {
80
144
  continue;
81
145
  }
82
- const abs = join(root.absPath, rel);
83
146
  scannedFiles++;
84
- scanLinesForPredicate(abs, rel, (line) => secretLiteralOnLine(line) !== null, findings);
147
+ const lines = readFileSync(join(root.absPath, rel), "utf8").split("\n");
148
+ for (const hit of scanLinesForSecretLiterals(lines)) {
149
+ findings.push({
150
+ file: rel,
151
+ line: hit.lineNumber,
152
+ name: hit.name,
153
+ literalLength: hit.literalLength,
154
+ });
155
+ }
85
156
  }
86
157
  }
87
158
  return { findings, scannedFiles };
@@ -112,7 +183,11 @@ export const check: RepoCheck = {
112
183
  }
113
184
  const { findings, scannedFiles } = await scanSecretLiterals(roots);
114
185
  return {
115
- violations: findings.map((f) => ({ file: f.file, line: f.line, message: f.text })),
186
+ violations: findings.map((f) => ({
187
+ file: f.file,
188
+ line: f.line,
189
+ message: `hardcoded secret fallback for "${f.name}" (${f.literalLength} chars)`,
190
+ })),
116
191
  matchedFiles: scannedFiles,
117
192
  notApplicable: false,
118
193
  };
@@ -27,14 +27,14 @@ import {
27
27
  nameForms,
28
28
  } from "./_lib/handler-name-forms";
29
29
  import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
30
+ import { TEST_FILE_RE } from "./_lib/test-file";
30
31
 
31
32
  const ROOT = process.cwd();
32
33
  const SCAN: ScanSpec = {
33
- scope: "source",
34
+ scope: "source+tests",
34
35
  extensions: ["ts"],
35
36
  frameworkWithin: ["packages/*/src/**", "samples/**"],
36
37
  };
37
- const TEST_FILE = /\.test\.ts$/;
38
38
 
39
39
  // Any role that lets an unauthenticated/unrestricted caller through means
40
40
  // nobody is actually excluded by this handler's roles.
@@ -47,7 +47,7 @@ export function findRoleRestrictedWriteHandlers(
47
47
  ): RoleRestrictedHandler[] {
48
48
  const out: RoleRestrictedHandler[] = [];
49
49
  for (const sf of files) {
50
- if (TEST_FILE.test(sf.getFilePath())) continue;
50
+ if (TEST_FILE_RE.test(sf.getFilePath())) continue;
51
51
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
52
52
  const callee = call.getExpression().getText();
53
53
  if (callee !== "defineWriteHandler" && !/\.writeHandler$/.test(callee)) continue;
@@ -99,7 +99,7 @@ export function findHandlersWithoutAccessDeniedTest(
99
99
  const repoKeyOf = (filePath: string): string => findRepoRootFor(filePath, roots)?.absPath ?? "";
100
100
  const testTextsByRepo = new Map<string, string[]>();
101
101
  for (const sf of files) {
102
- if (!TEST_FILE.test(sf.getFilePath())) continue;
102
+ if (!TEST_FILE_RE.test(sf.getFilePath())) continue;
103
103
  const text = sf.getFullText();
104
104
  if (!ACCESS_DENIED_ASSERTION.test(text)) continue;
105
105
  const repoKey = repoKeyOf(sf.getFilePath());
@@ -21,7 +21,9 @@
21
21
 
22
22
  import * as path from "node:path";
23
23
  import { type CallExpression, type Identifier, type SourceFile, SyntaxKind } from "ts-morph";
24
+ import { findRepoRootFor } from "./_lib/baseline-compare";
24
25
  import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
26
+ import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
25
27
 
26
28
  const ROOT = process.cwd();
27
29
 
@@ -49,10 +51,14 @@ export interface Violation {
49
51
  enclosingFunction: string;
50
52
  }
51
53
 
52
- function collectViolations(sourceFile: SourceFile): Violation[] {
54
+ function collectViolations(sourceFile: SourceFile, roots: readonly RepoRoot[]): Violation[] {
53
55
  const violations: Violation[] = [];
54
- const relativePath = path.relative(ROOT, sourceFile.getFilePath());
55
- if (isAllowed(relativePath)) return violations;
56
+ const filePath = sourceFile.getFilePath();
57
+ // Allowlist is repo-root-relative; the reported path stays cwd-relative for the security baseline.
58
+ const repoRoot = findRepoRootFor(filePath, roots);
59
+ const allowlistPath = path.relative(repoRoot?.absPath ?? ROOT, filePath);
60
+ const relativePath = path.relative(ROOT, filePath);
61
+ if (isAllowed(allowlistPath)) return violations;
56
62
 
57
63
  const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
58
64
  for (const call of calls) {
@@ -112,13 +118,13 @@ export const guard: AstGuard = {
112
118
  // App repos are not exempt — appendRaw/appendRawBatch bypasses the pipeline there too (infra#502).
113
119
  security: true,
114
120
  hint: "Admin API (appendRaw/appendRawBatch) bypasses the pipeline — only allowed in samples/*/migration/ or scripts/migrations/. For domain events: ctx.appendEvent / write handler.",
115
- run(files) {
121
+ run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
116
122
  const violations: Array<{ file: string; line: number; message: string }> = [];
117
123
 
118
124
  for (const sf of files) {
119
125
  const file = sf.getFilePath();
120
126
  if (EXCLUDE.test(file)) continue;
121
- for (const v of collectViolations(sf)) {
127
+ for (const v of collectViolations(sf, roots)) {
122
128
  violations.push({
123
129
  file: v.file,
124
130
  line: v.line,
@@ -287,11 +287,47 @@ function resolveWrite(
287
287
  return undefined;
288
288
  }
289
289
 
290
+ // Callee names whose result is a table definition: `const xTable = <builder>(...)`.
291
+ const TABLE_BUILDER_NAMES: ReadonlySet<string> = new Set([
292
+ "pgTable",
293
+ "table",
294
+ "buildEntityTable",
295
+ "defineUnmanagedTable",
296
+ "deriveEntityTableMeta",
297
+ ]);
298
+
299
+ function isTableDeclaration(identifier: Identifier): boolean {
300
+ const symbol = identifier.getSymbol();
301
+ const resolved = symbol?.getAliasedSymbol() ?? symbol;
302
+ return (resolved?.getDeclarations() ?? []).some((decl) => {
303
+ if (decl.getKind() !== SyntaxKind.VariableDeclaration) return false;
304
+ const init = decl.asKindOrThrow(SyntaxKind.VariableDeclaration).getInitializer();
305
+ if (init?.getKind() !== SyntaxKind.CallExpression) return false;
306
+ const callee = init.asKindOrThrow(SyntaxKind.CallExpression).getExpression();
307
+ const calleeName =
308
+ callee.getKind() === SyntaxKind.PropertyAccessExpression
309
+ ? callee.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName()
310
+ : callee.getText();
311
+ return TABLE_BUILDER_NAMES.has(calleeName);
312
+ });
313
+ }
314
+
315
+ // A write whose table argument resolves to a real table declaration — keeps
316
+ // `map.delete(key)` / `set.delete(id)` from counting as table writes.
317
+ function isTableWriteCall(call: CallExpression): boolean {
318
+ const write = resolveWrite(call);
319
+ return (
320
+ write !== undefined &&
321
+ declIdOf(write.tableArg) !== undefined &&
322
+ isTableDeclaration(write.tableArg)
323
+ );
324
+ }
325
+
290
326
  function hasAnyTableWrite(files: readonly SourceFile[]): boolean {
291
327
  for (const sf of files) {
292
328
  if (EXCLUDE.test(sf.getFilePath())) continue;
293
329
  for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
294
- if (resolveWrite(call)) return true;
330
+ if (isTableWriteCall(call)) return true;
295
331
  }
296
332
  }
297
333
  return false;
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { relative as pathRelative } from "node:path";
23
23
  import { type CallExpression, type Node, type SourceFile, SyntaxKind } from "ts-morph";
24
+ import { literalReasonText } from "./_lib/generic-reason";
24
25
  import {
25
26
  type AstGuard,
26
27
  findRepoRootFor,
@@ -79,13 +80,9 @@ function isSameOriginLiteralArg(expr: CallExpression): boolean {
79
80
  return head.startsWith("/") && !head.startsWith("//") && head.length >= 2;
80
81
  }
81
82
 
82
- if (
83
- arg.getKind() !== SyntaxKind.StringLiteral &&
84
- arg.getKind() !== SyntaxKind.NoSubstitutionTemplateLiteral
85
- ) {
86
- return false;
87
- }
88
- const path = arg.getText().slice(1, -1);
83
+ // Runtime value, not source text: "/\/attacker.example" is "//attacker.example".
84
+ const path = literalReasonText(arg);
85
+ if (path === undefined) return false;
89
86
  // Reject protocol-relative URLs ("//evil.example") — not same-origin.
90
87
  return path.startsWith("/") && !path.startsWith("//");
91
88
  }
@@ -561,7 +561,8 @@ function findUnsafeAllTenantsFindings(
561
561
  line: prop.getStartLineNumber(),
562
562
  rule: "unsafe-all-tenants-outside-declared-scope",
563
563
  message:
564
- 'unsafeAllTenants used outside a declared scope — restrict to a systemScope feature, a .job.ts / r.job(...) job, or declare { escapeHatch: { reason: "..." } } alongside it.',
564
+ 'unsafeAllTenants used outside a declared scope — restrict to a systemScope feature, a .job.ts / r.job(...) job, declare { escapeHatch: { reason: "..." } } alongside it, or call declareEscapeHatch({ reason: "..." }) as a direct body statement of a named hook.' +
565
+ unresolvableDeclareEscapeHatchHint(call),
565
566
  });
566
567
  }
567
568
  return out;
@@ -6,15 +6,16 @@
6
6
  * from CLAUDE.md.
7
7
  *
8
8
  * Coverage rule: a feature counts as covered when an integration test
9
- * imports anything — relative or via the `@cosmicdrift/kumiko-bundled-features`
10
- * package — that resolves into its `packages/bundled-features/src/<name>/`
11
- * directory. That's deliberately broader than "imports feature.ts itself":
12
- * a test that imports a sibling module of the feature (e.g. its resolver,
13
- * its defaults helper) still proves the directory ran, and a barrel import
14
- * of the whole package still proves the feature was composed. Since the
15
- * `<name>-feature.ts` -> `feature.ts` rename (df3f6b5b) the file basename is
16
- * identical for all 56 features — the directory name is the only usable
17
- * identifier.
9
+ * imports anything that resolves into its
10
+ * `packages/bundled-features/src/<name>/` directory — either relatively, or
11
+ * via the subpath `@cosmicdrift/kumiko-bundled-features/<feature>`. That's
12
+ * deliberately broader than "imports feature.ts itself": a test that imports
13
+ * a sibling module of the feature (e.g. its resolver, its defaults helper)
14
+ * still proves the directory ran. Only subpath imports count: a barrel
15
+ * import without subpath (`@cosmicdrift/kumiko-bundled-features`) covers no
16
+ * feature. Since the `<name>-feature.ts` -> `feature.ts` rename (df3f6b5b)
17
+ * the file basename is identical for all 56 features — the directory name
18
+ * is the only usable identifier.
18
19
  *
19
20
  * Usage:
20
21
  * bun guards/guard-feature-integration-tests.ts