





















1+#!/usr/bin/env node
2+3+import { promises as fs } from "node:fs";
4+import { createRequire } from "node:module";
5+import path from "node:path";
6+import {
7+collectTypeScriptFilesFromRoots,
8+getPropertyNameText,
9+resolveRepoRoot,
10+runAsScript,
11+toLine,
12+unwrapExpression,
13+} from "./lib/ts-guard-utils.mjs";
14+15+const require = createRequire(import.meta.url);
16+const ts = require("typescript");
17+18+const repoRoot = resolveRepoRoot(import.meta.url);
19+const sourceRoots = [path.join(repoRoot, "src")];
20+21+const kyselyRawAllowPaths = new Set([
22+"src/infra/kysely-node-sqlite.test.ts",
23+"src/infra/kysely-sync.ts",
24+]);
25+26+const compiledRawAllowPaths = new Set([
27+"src/infra/kysely-node-sqlite.ts",
28+"src/infra/kysely-node-sqlite.test.ts",
29+]);
30+31+const rawSqliteAllowPathGroups = {
32+"native Kysely adapter and sync execution": [
33+"src/infra/kysely-node-sqlite.ts",
34+"src/infra/kysely-sync.ts",
35+],
36+"SQLite database lifecycle, schema, transactions, and pragmas": [
37+"src/infra/node-sqlite.ts",
38+"src/infra/sqlite-integrity.ts",
39+"src/infra/sqlite-pragma.test-support.ts",
40+"src/infra/sqlite-transaction.ts",
41+"src/infra/sqlite-wal.ts",
42+"src/state/openclaw-state-db.ts",
43+"src/state/sqlite-schema-shape.test-support.ts",
44+],
45+"backup snapshot maintenance": ["src/commands/backup-verify.ts", "src/infra/backup-create.ts"],
46+"doctor legacy state migration": ["src/infra/state-migrations.ts"],
47+"Kysely-backed stores that own a DatabaseSync boundary": [
48+"src/acp/event-ledger.ts",
49+"src/agents/subagent-registry.store.ts",
50+"src/cron/run-log.ts",
51+"src/cron/store.ts",
52+"src/infra/outbound/current-conversation-bindings.ts",
53+"src/media/store.ts",
54+"src/plugin-sdk/memory-core-host-engine-storage.ts",
55+"src/plugin-state/plugin-state-store.sqlite.ts",
56+"src/proxy-capture/store.sqlite.ts",
57+"src/tasks/task-flow-registry.store.sqlite.ts",
58+"src/tasks/task-registry.store.sqlite.ts",
59+"src/tui/tui-last-session.ts",
60+],
61+};
62+63+const rawSqliteAllowPathReasons = new Map();
64+for (const [reason, paths] of Object.entries(rawSqliteAllowPathGroups)) {
65+for (const allowedPath of paths) {
66+if (rawSqliteAllowPathReasons.has(allowedPath)) {
67+throw new Error(`Duplicate raw SQLite allowlist path: ${allowedPath}`);
68+}
69+rawSqliteAllowPathReasons.set(allowedPath, reason);
70+}
71+}
72+73+function lineText(sourceFile, node) {
74+const line = toLine(sourceFile, node);
75+return sourceFile.text.split("\n")[line - 1] ?? "";
76+}
77+78+function hasAllowComment(sourceFile, node, token) {
79+const line = lineText(sourceFile, node);
80+if (line.includes(token)) {
81+return true;
82+}
83+const leading = ts.getLeadingCommentRanges(sourceFile.text, node.pos) ?? [];
84+return leading.some((range) => sourceFile.text.slice(range.pos, range.end).includes(token));
85+}
86+87+function importSource(node) {
88+const moduleSpecifier = node.moduleSpecifier;
89+return ts.isStringLiteral(moduleSpecifier) ? moduleSpecifier.text : "";
90+}
91+92+function collectImports(sourceFile) {
93+const kyselySqlNames = new Set();
94+const compiledQueryNames = new Set();
95+const syncHelperNames = new Set();
96+let hasKyselyContext = false;
97+let hasSqliteContext = false;
98+99+for (const statement of sourceFile.statements) {
100+if (!ts.isImportDeclaration(statement)) {
101+continue;
102+}
103+const source = importSource(statement);
104+const clause = statement.importClause;
105+const namedBindings = clause?.namedBindings;
106+107+if (source === "kysely") {
108+hasKyselyContext = true;
109+if (namedBindings && ts.isNamedImports(namedBindings)) {
110+for (const element of namedBindings.elements) {
111+const importedName = element.propertyName?.text ?? element.name.text;
112+if (importedName === "sql") {
113+kyselySqlNames.add(element.name.text);
114+}
115+if (importedName === "CompiledQuery") {
116+compiledQueryNames.add(element.name.text);
117+}
118+}
119+}
120+}
121+122+if (source.endsWith("kysely-sync.js") || source.endsWith("kysely-node-sqlite.js")) {
123+hasKyselyContext = true;
124+if (namedBindings && ts.isNamedImports(namedBindings)) {
125+for (const element of namedBindings.elements) {
126+const importedName = element.propertyName?.text ?? element.name.text;
127+if (
128+importedName === "executeSqliteQuerySync" ||
129+importedName === "executeSqliteQueryTakeFirstSync" ||
130+importedName === "executeSqliteQueryTakeFirstOrThrowSync"
131+) {
132+syncHelperNames.add(element.name.text);
133+}
134+if (importedName === "getNodeSqliteKysely") {
135+hasKyselyContext = true;
136+hasSqliteContext = true;
137+}
138+}
139+}
140+}
141+142+if (
143+source === "node:sqlite" ||
144+source.endsWith("node-sqlite.js") ||
145+source.endsWith("sqlite-transaction.js") ||
146+source.endsWith("sqlite-wal.js") ||
147+source.endsWith("openclaw-state-db.js")
148+) {
149+hasSqliteContext = true;
150+}
151+}
152+153+return {
154+ compiledQueryNames,
155+ hasKyselyContext,
156+ hasSqliteContext,
157+ kyselySqlNames,
158+ syncHelperNames,
159+};
160+}
161+162+function addViolation(violations, sourceFile, node, message) {
163+violations.push({
164+line: toLine(sourceFile, node),
165+ message,
166+});
167+}
168+169+function isIdentifierNamed(node, names) {
170+const unwrapped = unwrapExpression(node);
171+return ts.isIdentifier(unwrapped) && names.has(unwrapped.text);
172+}
173+174+function isTestPath(relativePath) {
175+return (
176+/\.(?:test|spec|e2e)\.ts$/u.test(relativePath) ||
177+relativePath.includes(".test-helpers.") ||
178+relativePath.includes(".test-support.")
179+);
180+}
181+182+function isSqliteStorePath(relativePath) {
183+return relativePath.endsWith(".sqlite.ts") || relativePath.includes(".store.sqlite.ts");
184+}
185+186+function isLikelySqliteReceiver(expression) {
187+const unwrapped = unwrapExpression(expression);
188+if (ts.isIdentifier(unwrapped)) {
189+return /^(?:db|database|legacyDb|stateDb|agentDb)$/u.test(unwrapped.text);
190+}
191+return ts.isPropertyAccessExpression(unwrapped) && getPropertyNameText(unwrapped.name) === "db";
192+}
193+194+function isPersistedRowExpression(expression) {
195+const unwrapped = unwrapExpression(expression);
196+if (ts.isPropertyAccessExpression(unwrapped)) {
197+const owner = unwrapExpression(unwrapped.expression);
198+return ts.isIdentifier(owner) && /^(?:row|record|entry)$/u.test(owner.text);
199+}
200+if (ts.isElementAccessExpression(unwrapped)) {
201+const owner = unwrapExpression(unwrapped.expression);
202+return ts.isIdentifier(owner) && /^(?:row|record|entry)$/u.test(owner.text);
203+}
204+return false;
205+}
206+207+function isPersistedStringCastType(typeText) {
208+return [
209+/\bTaskRecord\["(?:runtime|scopeKind|status|deliveryStatus|notifyPolicy|terminalOutcome)"\]/u,
210+/\bTaskFlowRecord\["(?:status|notifyPolicy)"\]/u,
211+/\bTaskFlowSyncMode\b/u,
212+/\bVirtualAgentFsEntryKind\b/u,
213+/\b[A-Z][A-Za-z0-9]*(?:Status|Kind|Mode|Policy|Runtime|Outcome)\b/u,
214+].some((pattern) => pattern.test(typeText));
215+}
216+217+export function collectKyselyGuardrailViolations(content, relativePath) {
218+const sourceFile = ts.createSourceFile(relativePath, content, ts.ScriptTarget.Latest, true);
219+const imports = collectImports(sourceFile);
220+const violations = [];
221+222+function visit(node) {
223+if (
224+isSqliteStorePath(relativePath) &&
225+(ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) &&
226+isPersistedStringCastType(node.type.getText(sourceFile)) &&
227+isPersistedRowExpression(node.expression) &&
228+!hasAllowComment(sourceFile, node, "sqlite-allow-persisted-cast")
229+) {
230+addViolation(
231+violations,
232+sourceFile,
233+node,
234+"persisted SQLite enum-like values must be parsed through closed validators, not cast",
235+);
236+}
237+238+if (
239+ts.isCallExpression(node) &&
240+ts.isIdentifier(node.expression) &&
241+imports.syncHelperNames.has(node.expression.text) &&
242+node.typeArguments?.length &&
243+!hasAllowComment(sourceFile, node, "kysely-allow-raw")
244+) {
245+addViolation(
246+violations,
247+sourceFile,
248+node,
249+"sync helper row generic at call site; let Kysely infer builder result rows",
250+);
251+}
252+253+if (
254+ts.isTaggedTemplateExpression(node) &&
255+node.typeArguments?.length &&
256+isIdentifierNamed(node.tag, imports.kyselySqlNames) &&
257+!kyselyRawAllowPaths.has(relativePath) &&
258+!hasAllowComment(sourceFile, node, "kysely-allow-raw")
259+) {
260+addViolation(
261+violations,
262+sourceFile,
263+node,
264+"typed raw sql snippet needs a small helper or allowlisted boundary",
265+);
266+}
267+268+if (
269+ts.isCallExpression(node) &&
270+ts.isPropertyAccessExpression(node.expression) &&
271+isIdentifierNamed(node.expression.expression, imports.kyselySqlNames) &&
272+["ref", "table", "id", "raw"].includes(getPropertyNameText(node.expression.name) ?? "") &&
273+!hasAllowComment(sourceFile, node, "kysely-allow-raw")
274+) {
275+addViolation(
276+violations,
277+sourceFile,
278+node,
279+"raw Kysely identifier helper requires a closed-set validator and local allow comment",
280+);
281+}
282+283+if (
284+imports.hasKyselyContext &&
285+ts.isPropertyAccessExpression(node) &&
286+getPropertyNameText(node.name) === "dynamic" &&
287+!hasAllowComment(sourceFile, node, "kysely-allow-raw")
288+) {
289+addViolation(
290+violations,
291+sourceFile,
292+node,
293+"Kysely dynamic refs bypass literal reference checking; use only behind closed unions",
294+);
295+}
296+297+if (
298+ts.isCallExpression(node) &&
299+ts.isPropertyAccessExpression(node.expression) &&
300+isIdentifierNamed(node.expression.expression, imports.compiledQueryNames) &&
301+getPropertyNameText(node.expression.name) === "raw" &&
302+!compiledRawAllowPaths.has(relativePath) &&
303+!hasAllowComment(sourceFile, node, "kysely-allow-raw")
304+) {
305+addViolation(
306+violations,
307+sourceFile,
308+node,
309+"CompiledQuery.raw is only allowed in the native SQLite dialect/test boundary",
310+);
311+}
312+313+if (
314+imports.hasSqliteContext &&
315+!isTestPath(relativePath) &&
316+ts.isCallExpression(node) &&
317+ts.isPropertyAccessExpression(node.expression) &&
318+["prepare", "exec"].includes(getPropertyNameText(node.expression.name) ?? "") &&
319+isLikelySqliteReceiver(node.expression.expression) &&
320+!rawSqliteAllowPathReasons.has(relativePath) &&
321+!hasAllowComment(sourceFile, node, "sqlite-allow-raw")
322+) {
323+addViolation(
324+violations,
325+sourceFile,
326+node,
327+"new raw node:sqlite access requires Kysely or an explicit raw SQLite allowlist entry",
328+);
329+}
330+331+ts.forEachChild(node, visit);
332+}
333+334+visit(sourceFile);
335+return violations;
336+}
337+338+export async function collectKyselyGuardrails() {
339+const files = await collectTypeScriptFilesFromRoots(sourceRoots, { includeTests: true });
340+const violations = [];
341+for (const filePath of files) {
342+const relativePath = path.relative(repoRoot, filePath).split(path.sep).join("/");
343+const content = await fs.readFile(filePath, "utf8");
344+for (const violation of collectKyselyGuardrailViolations(content, relativePath)) {
345+violations.push({ path: relativePath, ...violation });
346+}
347+}
348+return violations;
349+}
350+351+export async function main() {
352+const violations = await collectKyselyGuardrails();
353+if (violations.length === 0) {
354+console.log("Kysely guardrails OK");
355+return;
356+}
357+console.error("Kysely guardrail violations:");
358+for (const violation of violations) {
359+console.error(`- ${violation.path}:${violation.line}: ${violation.message}`);
360+}
361+process.exit(1);
362+}
363+364+runAsScript(import.meta.url, main);
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。