#!/usr/bin/env node // Enforces Kysely and SQLite guardrails in infrastructure code. import { promises as fs } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { collectTypeScriptFilesFromRoots, getPropertyNameText, resolveRepoRoot, runAsScript, toLine, unwrapExpression, } from "./lib/ts-guard-utils.mjs"; const require = createRequire(import.meta.url); const ts = require("typescript"); const repoRoot = resolveRepoRoot(import.meta.url); const sourceRoots = [path.join(repoRoot, "src")]; const kyselyRawAllowPaths = new Set([ "src/infra/kysely-node-sqlite.test.ts", "src/infra/kysely-sync.ts", ]); const compiledRawAllowPaths = new Set([ "src/infra/kysely-node-sqlite.ts", "src/infra/kysely-node-sqlite.test.ts", ]); const rawSqliteAllowPathGroups = { "native Kysely adapter and sync execution": [ "src/infra/kysely-node-sqlite.ts", "src/infra/kysely-sync.ts", ], "SQLite database lifecycle, schema, transactions, and pragmas": [ "src/infra/node-sqlite.ts", "src/infra/sqlite-index-schema.ts", "src/infra/sqlite-integrity.ts", "src/infra/sqlite-pragma.test-support.ts", "src/infra/sqlite-transaction.ts", "src/infra/sqlite-user-version.ts", "src/infra/sqlite-wal.ts", "src/state/openclaw-agent-db.ts", "src/state/openclaw-state-db.ts", "src/state/sqlite-schema-shape.test-support.ts", ], "backup snapshot maintenance": ["src/commands/backup-verify.ts", "src/infra/backup-create.ts"], "agent auth profile read-only bootstrap": ["src/agents/auth-profiles/sqlite.ts"], "read-only SQLite status probes": [ "src/commands/doctor-db-bloat.ts", "src/commands/status.scan.shared.ts", ], "doctor SQLite maintenance and legacy state migration": [ "src/commands/doctor/cron/migration-ledger.ts", "src/commands/doctor-sqlite-compact.ts", "src/commands/doctor-session-sqlite.ts", "src/commands/doctor-session-sqlite-readers.ts", "src/commands/doctor-state-sqlite-compact.ts", "src/infra/state-migrations.ts", "src/infra/state-migrations.debug-proxy.ts", ], "shared database stores with direct DatabaseSync access": ["src/proxy-capture/store.sqlite.ts"], "Kysely-backed stores that own a DatabaseSync boundary": [ "src/acp/event-ledger.ts", "src/agents/subagent-registry.store.ts", "src/cron/run-log.ts", "src/cron/run-log/sqlite-store.ts", "src/cron/store.ts", "src/infra/outbound/current-conversation-bindings.ts", "src/media/store.ts", "src/plugin-sdk/memory-core-host-engine-storage.ts", "src/plugins/installed-plugin-index-record-reader.ts", "src/plugins/installed-plugin-index-store.ts", "src/plugin-state/plugin-state-store.sqlite.ts", "src/tasks/task-flow-registry.store.sqlite.ts", "src/tasks/task-registry.store.sqlite.ts", "src/tui/tui-last-session.ts", ], }; const rawSqliteAllowPathReasons = new Map(); for (const [reason, paths] of Object.entries(rawSqliteAllowPathGroups)) { for (const allowedPath of paths) { if (rawSqliteAllowPathReasons.has(allowedPath)) { throw new Error(`Duplicate raw SQLite allowlist path: ${allowedPath}`); } rawSqliteAllowPathReasons.set(allowedPath, reason); } } function lineText(sourceFile, node) { const line = toLine(sourceFile, node); return sourceFile.text.split("\n")[line - 1] ?? ""; } function hasAllowComment(sourceFile, node, token) { const line = lineText(sourceFile, node); if (line.includes(token)) { return true; } const leading = ts.getLeadingCommentRanges(sourceFile.text, node.pos) ?? []; return leading.some((range) => sourceFile.text.slice(range.pos, range.end).includes(token)); } function importSource(node) { const moduleSpecifier = node.moduleSpecifier; return ts.isStringLiteral(moduleSpecifier) ? moduleSpecifier.text : ""; } function collectImports(sourceFile) { const kyselySqlNames = new Set(); const compiledQueryNames = new Set(); const syncHelperNames = new Set(); let hasKyselyContext = false; let hasSqliteContext = false; for (const statement of sourceFile.statements) { if (!ts.isImportDeclaration(statement)) { continue; } const source = importSource(statement); const clause = statement.importClause; const namedBindings = clause?.namedBindings; if (source === "kysely") { hasKyselyContext = true; if (namedBindings && ts.isNamedImports(namedBindings)) { for (const element of namedBindings.elements) { const importedName = element.propertyName?.text ?? element.name.text; if (importedName === "sql") { kyselySqlNames.add(element.name.text); } if (importedName === "CompiledQuery") { compiledQueryNames.add(element.name.text); } } } } if (source.endsWith("kysely-sync.js") || source.endsWith("kysely-node-sqlite.js")) { hasKyselyContext = true; if (namedBindings && ts.isNamedImports(namedBindings)) { for (const element of namedBindings.elements) { const importedName = element.propertyName?.text ?? element.name.text; if ( importedName === "executeSqliteQuerySync" || importedName === "executeSqliteQueryTakeFirstSync" ) { syncHelperNames.add(element.name.text); } if (importedName === "getNodeSqliteKysely") { hasKyselyContext = true; hasSqliteContext = true; } } } } if ( source === "node:sqlite" || source.endsWith("node-sqlite.js") || source.endsWith("sqlite-transaction.js") || source.endsWith("sqlite-wal.js") || source.endsWith("openclaw-state-db.js") ) { hasSqliteContext = true; } } return { compiledQueryNames, hasKyselyContext, hasSqliteContext, kyselySqlNames, syncHelperNames, }; } function addViolation(violations, sourceFile, node, message) { violations.push({ line: toLine(sourceFile, node), message, }); } function isIdentifierNamed(node, names) { const unwrapped = unwrapExpression(node); return ts.isIdentifier(unwrapped) && names.has(unwrapped.text); } function isTestPath(relativePath) { return ( /\.(?:test|spec|e2e)\.ts$/u.test(relativePath) || relativePath.includes(".test-helpers.") || relativePath.includes(".test-support.") ); } function isSqliteStorePath(relativePath) { return relativePath.endsWith(".sqlite.ts") || relativePath.includes(".store.sqlite.ts"); } function isLikelySqliteReceiver(expression) { const unwrapped = unwrapExpression(expression); if (ts.isIdentifier(unwrapped)) { return /^(?:db|database|legacyDb|stateDb|agentDb)$/u.test(unwrapped.text); } return ts.isPropertyAccessExpression(unwrapped) && getPropertyNameText(unwrapped.name) === "db"; } function isPersistedRowExpression(expression) { const unwrapped = unwrapExpression(expression); if (ts.isPropertyAccessExpression(unwrapped)) { const owner = unwrapExpression(unwrapped.expression); return ts.isIdentifier(owner) && /^(?:row|record|entry)$/u.test(owner.text); } if (ts.isElementAccessExpression(unwrapped)) { const owner = unwrapExpression(unwrapped.expression); return ts.isIdentifier(owner) && /^(?:row|record|entry)$/u.test(owner.text); } return false; } function isPersistedStringCastType(typeText) { return [ /\bTaskRecord\["(?:runtime|scopeKind|status|deliveryStatus|notifyPolicy|terminalOutcome)"\]/u, /\bTaskFlowRecord\["(?:status|notifyPolicy)"\]/u, /\bTaskFlowSyncMode\b/u, /\bVirtualAgentFsEntryKind\b/u, /\b[A-Z][A-Za-z0-9]*(?:Status|Kind|Mode|Policy|Runtime|Outcome)\b/u, ].some((pattern) => pattern.test(typeText)); } /** * Collects Kysely/raw SQLite violations from one source file. */ function collectKyselyGuardrailViolations(content, relativePath) { const sourceFile = ts.createSourceFile(relativePath, content, ts.ScriptTarget.Latest, true); const imports = collectImports(sourceFile); const violations = []; function visit(node) { if ( isSqliteStorePath(relativePath) && (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) && isPersistedStringCastType(node.type.getText(sourceFile)) && isPersistedRowExpression(node.expression) && !hasAllowComment(sourceFile, node, "sqlite-allow-persisted-cast") ) { addViolation( violations, sourceFile, node, "persisted SQLite enum-like values must be parsed through closed validators, not cast", ); } if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && imports.syncHelperNames.has(node.expression.text) && node.typeArguments?.length && !hasAllowComment(sourceFile, node, "kysely-allow-raw") ) { addViolation( violations, sourceFile, node, "sync helper row generic at call site; let Kysely infer builder result rows", ); } if ( ts.isTaggedTemplateExpression(node) && node.typeArguments?.length && isIdentifierNamed(node.tag, imports.kyselySqlNames) && !kyselyRawAllowPaths.has(relativePath) && !hasAllowComment(sourceFile, node, "kysely-allow-raw") ) { addViolation( violations, sourceFile, node, "typed raw sql snippet needs a small helper or allowlisted boundary", ); } if ( ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && isIdentifierNamed(node.expression.expression, imports.kyselySqlNames) && ["ref", "table", "id", "raw"].includes(getPropertyNameText(node.expression.name) ?? "") && !hasAllowComment(sourceFile, node, "kysely-allow-raw") ) { addViolation( violations, sourceFile, node, "raw Kysely identifier helper requires a closed-set validator and local allow comment", ); } if ( imports.hasKyselyContext && ts.isPropertyAccessExpression(node) && getPropertyNameText(node.name) === "dynamic" && !hasAllowComment(sourceFile, node, "kysely-allow-raw") ) { addViolation( violations, sourceFile, node, "Kysely dynamic refs bypass literal reference checking; use only behind closed unions", ); } if ( ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && isIdentifierNamed(node.expression.expression, imports.compiledQueryNames) && getPropertyNameText(node.expression.name) === "raw" && !compiledRawAllowPaths.has(relativePath) && !hasAllowComment(sourceFile, node, "kysely-allow-raw") ) { addViolation( violations, sourceFile, node, "CompiledQuery.raw is only allowed in the native SQLite dialect/test boundary", ); } if ( imports.hasSqliteContext && !isTestPath(relativePath) && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ["prepare", "exec"].includes(getPropertyNameText(node.expression.name) ?? "") && isLikelySqliteReceiver(node.expression.expression) && !rawSqliteAllowPathReasons.has(relativePath) && !hasAllowComment(sourceFile, node, "sqlite-allow-raw") ) { addViolation( violations, sourceFile, node, "new raw node:sqlite access requires Kysely or an explicit raw SQLite allowlist entry", ); } ts.forEachChild(node, visit); } visit(sourceFile); return violations; } /** * Collects Kysely guardrail violations across configured source roots. */ async function collectKyselyGuardrails() { const files = await collectTypeScriptFilesFromRoots(sourceRoots, { includeTests: true }); const violations = []; for (const filePath of files) { const relativePath = path.relative(repoRoot, filePath).split(path.sep).join("/"); const content = await fs.readFile(filePath, "utf8"); for (const violation of collectKyselyGuardrailViolations(content, relativePath)) { violations.push({ path: relativePath, ...violation }); } } return violations; } /** * Runs the Kysely guardrail check. */ export async function main() { const violations = await collectKyselyGuardrails(); if (violations.length === 0) { console.log("Kysely guardrails OK"); return; } console.error("Kysely guardrail violations:"); for (const violation of violations) { console.error(`- ${violation.path}:${violation.line}: ${violation.message}`); } process.exit(1); } runAsScript(import.meta.url, main);