#!/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 type { Expression, ImportDeclaration, Node, SourceFile } from "typescript"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { collectTypeScriptFilesFromRoots, getPropertyNameText, runAsScript, toLine, unwrapExpression, } from "./lib/ts-guard-utils.mts"; const require = createRequire(import.meta.url); const ts: typeof import("typescript") = require("typescript"); const repoRoot = resolveRepoRoot(import.meta.url); const sourceRoots = [path.join(repoRoot, "src")]; const nodeSqliteBoundaryRoots = [ path.join(repoRoot, "src"), path.join(repoRoot, "extensions"), path.join(repoRoot, "packages"), ]; const nodeSqliteConstructorOwnerPaths = new Set(["src/infra/node-sqlite.ts"]); const kyselyRawAllowPaths = new Set(["src/infra/kysely-sync.ts"]); const compiledRawAllowPaths = new Set(["src/infra/kysely-node-sqlite.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-schema-contract.ts", "src/infra/sqlite-strict.ts", "src/infra/sqlite-transaction.ts", "src/infra/sqlite-user-version.ts", "src/infra/sqlite-wal.ts", "src/state/openclaw-agent-db-maintenance.ts", "src/state/openclaw-agent-db-registry.ts", "src/state/openclaw-agent-db-registry-listing.ts", "src/state/openclaw-agent-db-schema-helpers.ts", "src/state/openclaw-agent-db-schema.ts", "src/state/openclaw-agent-db-session-nodes-migration.ts", "src/state/openclaw-agent-db-session-migrations.ts", "src/state/openclaw-agent-db-session-provenance.ts", "src/state/openclaw-agent-db.ts", "src/state/openclaw-state-db-audit-migration.ts", "src/state/openclaw-state-db-delivery-queue-backfill.ts", "src/state/openclaw-state-db-legacy-backfills.ts", "src/state/openclaw-state-db-maintenance.ts", "src/state/openclaw-state-db-operator-approval-migration.ts", "src/state/openclaw-state-db-schema-additive.ts", "src/state/openclaw-state-db-schema-helpers.ts", "src/state/openclaw-state-db-schema-repair.ts", "src/state/openclaw-state-db-startup-checkpoint.ts", "src/state/openclaw-state-db.ts", "src/state/openclaw-state-ownership-operations.ts", "src/transcripts/sqlite-schema.ts", "src/state/sqlite-schema-shape.test-support.ts", ], "cross-process SQLite coordination locks": ["src/infra/device-identity-coordinator.ts"], "backup snapshot maintenance": [ "src/commands/backup-verify.ts", "src/infra/backup-create.ts", "src/snapshot/git-backup-codec.ts", "src/snapshot/local-repository.ts", ], "agent auth profile read-only bootstrap": ["src/agents/auth-profiles/sqlite.ts"], "read-only shared state database access": [ "src/claws/package-resume.ts", "src/state/openclaw-agent-db-readonly.ts", "src/state/openclaw-state-db-readonly.ts", ], "cold-process read-only relay lookup avoids the shared state writer lifecycle": [ "src/agents/harness/native-hook-relay-client-store.ts", ], "read-only schema preflight and integrity verification access": [ "src/state/openclaw-database-preflight.ts", "src/state/openclaw-database-verify.worker.ts", "src/state/openclaw-state-ownership.ts", ], "quarantine store must work when other databases are damaged": [ "src/state/openclaw-quarantine-store.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-agent-memory-schema.ts", "src/commands/doctor/cron/legacy-run-log-migration.ts", "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-session-sqlite-recover-report.ts", "src/commands/doctor-state-sqlite-compact.ts", "src/infra/state-migrations.task-sidecar-rows.ts", "src/infra/state-migrations.storage.ts", "src/infra/state-migrations.cron-run-logs.ts", "src/infra/state-migrations.debug-proxy.ts", "src/infra/state-migrations.meeting-transcripts-detection.ts", "src/infra/state-migrations.meeting-transcripts-files.ts", "src/infra/state-migrations.meeting-transcripts-verify.ts", "src/infra/state-migrations.media-persistence.ts", "src/infra/state-migrations.transcript-directives-archives.ts", "src/infra/state-migrations.transcript-directives.ts", ], "shared database stores with direct DatabaseSync access": ["src/proxy-capture/store.sqlite.ts"], "session entry cache connection-local validity counters": [ "src/config/sessions/session-accessor.sqlite-entry-cache.ts", ], "device pairing cache connection-local validity counters": ["src/infra/device-pairing-store.ts"], "Kysely-backed stores that own a DatabaseSync boundary": [ "src/acp/event-ledger.ts", "src/state/user-profiles.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", ], }; type GuardViolation = { line: number; message: string }; 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: SourceFile, node: Node) { const line = toLine(sourceFile, node); return sourceFile.text.split("\n")[line - 1] ?? ""; } function hasAllowComment(sourceFile: SourceFile, node: Node, token: string) { 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: ImportDeclaration) { const moduleSpecifier = node.moduleSpecifier; return ts.isStringLiteral(moduleSpecifier) ? moduleSpecifier.text : ""; } function collectImports(sourceFile: 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: GuardViolation[], sourceFile: SourceFile, node: Node, message: string, ) { violations.push({ line: toLine(sourceFile, node), message, }); } function isIdentifierNamed(node: Expression, names: Set) { const unwrapped = unwrapExpression(node); return ts.isIdentifier(unwrapped) && names.has(unwrapped.text); } function isTestPath(relativePath: string) { return ( /\.(?:test|spec|e2e)\.ts$/u.test(relativePath) || relativePath.includes(".test-helpers.") || relativePath.includes(".test-support.") ); } function isSqliteStorePath(relativePath: string) { return relativePath.endsWith(".sqlite.ts") || relativePath.includes(".store.sqlite.ts"); } function collectNodeSqliteBoundaryViolations(content: string, relativePath: string) { if (isTestPath(relativePath) || nodeSqliteConstructorOwnerPaths.has(relativePath)) { return []; } const sourceFile = ts.createSourceFile(relativePath, content, ts.ScriptTarget.Latest, true); const constructorNames = new Set(); function collectConstructorNames(node: Node) { if (ts.isImportDeclaration(node) && importSource(node) === "node:sqlite") { const namedBindings = node.importClause?.namedBindings; if (namedBindings && ts.isNamedImports(namedBindings)) { for (const element of namedBindings.elements) { if ((element.propertyName?.text ?? element.name.text) === "DatabaseSync") { constructorNames.add(element.name.text); } } } } if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name)) { for (const element of node.name.elements) { if ( !element.dotDotDotToken && ts.isIdentifier(element.name) && (element.propertyName ? getPropertyNameText(element.propertyName) : element.name.text) === "DatabaseSync" ) { constructorNames.add(element.name.text); } } } ts.forEachChild(node, collectConstructorNames); } collectConstructorNames(sourceFile); const violations: GuardViolation[] = []; function visit(node: Node) { if (ts.isNewExpression(node)) { const expression = unwrapExpression(node.expression); const isRawConstructor = (ts.isIdentifier(expression) && constructorNames.has(expression.text)) || (ts.isPropertyAccessExpression(expression) && getPropertyNameText(expression.name) === "DatabaseSync"); if (isRawConstructor) { addViolation( violations, sourceFile, node, "production node:sqlite connections must use openNodeSqliteDatabase", ); } } ts.forEachChild(node, visit); } visit(sourceFile); return violations; } function isLikelySqliteReceiver(expression: 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: 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: string) { 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: string, relativePath: string) { const sourceFile = ts.createSourceFile(relativePath, content, ts.ScriptTarget.Latest, true); const imports = collectImports(sourceFile); const violations: GuardViolation[] = []; function visit(node: 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: Array = []; 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 }); } } const nodeSqliteFiles = await collectTypeScriptFilesFromRoots(nodeSqliteBoundaryRoots, { includeTests: false, }); for (const filePath of nodeSqliteFiles) { const relativePath = path.relative(repoRoot, filePath).split(path.sep).join("/"); const content = await fs.readFile(filePath, "utf8"); for (const violation of collectNodeSqliteBoundaryViolations(content, relativePath)) { violations.push({ path: relativePath, ...violation }); } } return violations; } /** * Runs the Kysely guardrail check. */ 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);