Require production paths to use PostgreSQL-aware stores and prevent new unrestricted database access. - Add a checked allowlist that bans production getDatabase() calls by default. - Route Quality plugin persistence through an async PostgreSQL-aware store and add Drizzle ORM. - Document backend-safe plugin storage patterns and cover guarded access behavior. Files changed: docs/PLUGIN_AUTHORING.md | 20 +++ package.json | 6 +- .../src/__tests__/agent-logs-backend-mode.test.ts | 7 + packages/core/src/store.ts | 7 +- packages/core/src/task-store/remaining-ops-5.ts | 8 +- plugins/fusion-plugin-quality/package.json | 1 + .../src/__tests__/async-quality-store.pg.test.ts | 36 +++++ .../src/__tests__/cancel-and-plans.test.ts | 8 +- .../src/__tests__/experimental-gate.test.ts | 1 + .../src/routes/create-routes.ts | 50 +++---- .../src/runner/command-runner.ts | 17 ++- .../src/store/async-quality-store.ts | 34 +++++ pnpm-lock.yaml | 3 + scripts/__tests__/check-no-getdatabase.test.mjs | 90 ++++++++++++ scripts/check-no-getdatabase.mjs | 159 +++++++++++++++++++++ scripts/lib/getdatabase-allowlist.json | 18 +++ 16 files changed, 422 insertions(+), 43 deletions(-) Fusion-Task-Id: FN-8103 Fusion-Task-Lineage: ff17bcb2-5341-4c6c-a5c4-993580539676 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
160 lines
7.6 KiB
JavaScript
160 lines
7.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
FNXC:PostgresOnlyDataAccess 2026-07-16-10:00:
|
|
Production plugin, dashboard, engine, and core access must use PostgreSQL through
|
|
AsyncDataLayer, after a Quality route reached SQLite in backend mode and crashed.
|
|
This scanner bans executable getDatabase() calls. Exceptions live only in the dated,
|
|
invocation-pinned JSON allowlist keyed by file+line+snippet: no file-level,
|
|
identical-line, or inline-marker bypass exists. Template ${...} expressions remain
|
|
executable and are scanned while literal template text is ignored.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const ALLOWLIST_PATH = "scripts/lib/getdatabase-allowlist.json";
|
|
export const SCAN_ROOTS = ["plugins", "packages/dashboard", "packages/engine", "packages/core"];
|
|
|
|
function listTrackedTargets() {
|
|
const result = spawnSync("git", ["ls-files", "--", ...SCAN_ROOTS], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
if (result.status !== 0) throw new Error(result.stderr?.trim() || "git ls-files failed");
|
|
return result.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
}
|
|
|
|
export function validateAllowlistEntries(entries) {
|
|
if (!Array.isArray(entries)) throw new Error(`${ALLOWLIST_PATH} must contain an entries array`);
|
|
return entries.map((entry, index) => {
|
|
const prefix = `${ALLOWLIST_PATH} entries[${index}]`;
|
|
if (!entry || typeof entry.file !== "string" || !entry.file.trim()) throw new Error(`${prefix} must include a non-empty file`);
|
|
if (!Number.isInteger(entry.line) || entry.line < 1) throw new Error(`${prefix} must include a 1-based integer line`);
|
|
if (typeof entry.snippet !== "string" || !entry.snippet.trim()) throw new Error(`${prefix} must include a non-empty snippet`);
|
|
if (typeof entry.reason !== "string" || !entry.reason.trim()) throw new Error(`${prefix} must include a non-empty reason`);
|
|
if (typeof entry.allowlistedAt !== "string" || Number.isNaN(Date.parse(entry.allowlistedAt))) throw new Error(`${prefix} must include an ISO-8601 allowlistedAt date`);
|
|
return entry;
|
|
});
|
|
}
|
|
|
|
function loadAllowlistEntries(path = ALLOWLIST_PATH) {
|
|
let parsed;
|
|
try { parsed = JSON.parse(readFileSync(path, "utf8")); }
|
|
catch (error) { throw new Error(`Failed to read ${path}: ${error instanceof Error ? error.message : String(error)}`); }
|
|
return validateAllowlistEntries(parsed.entries);
|
|
}
|
|
|
|
/** Replace non-code with spaces while retaining line/column offsets. */
|
|
function codeMask(source) {
|
|
const out = source.split("");
|
|
const blank = (start, end) => { for (let i = start; i < end; i++) if (out[i] !== "\n" && out[i] !== "\r") out[i] = " "; };
|
|
const scan = (start, end, templateExpression = false) => {
|
|
for (let i = start; i < end;) {
|
|
if (source.startsWith("//", i)) { const close = source.indexOf("\n", i + 2); blank(i, close < 0 ? end : close); i = close < 0 ? end : close; continue; }
|
|
if (source.startsWith("/*", i)) { const close = source.indexOf("*/", i + 2); const until = close < 0 ? end : close + 2; blank(i, until); i = until; continue; }
|
|
const quote = source[i];
|
|
if (quote === "'" || quote === '"') {
|
|
let j = i + 1;
|
|
while (j < end) { if (source[j] === "\\") { j += 2; continue; } if (source[j] === quote) { j++; break; } j++; }
|
|
blank(i, j); i = j; continue;
|
|
}
|
|
if (quote === "`") {
|
|
let j = i + 1; blank(i, i + 1);
|
|
while (j < end) {
|
|
if (source[j] === "\\") { blank(j, Math.min(j + 2, end)); j += 2; continue; }
|
|
if (source[j] === "`") { blank(j, j + 1); j++; break; }
|
|
if (source[j] === "$" && source[j + 1] === "{") {
|
|
blank(j, j + 2); let depth = 1; const exprStart = j + 2; j += 2;
|
|
while (j < end && depth) { if (source[j] === "{") depth++; else if (source[j] === "}") depth--; j++; }
|
|
const exprEnd = depth === 0 ? j - 1 : end;
|
|
scan(exprStart, exprEnd, true); if (depth === 0) blank(j - 1, j); continue;
|
|
}
|
|
blank(j, j + 1); j++;
|
|
}
|
|
i = j;
|
|
continue;
|
|
}
|
|
i++;
|
|
}
|
|
};
|
|
scan(0, source.length);
|
|
return out.join("");
|
|
}
|
|
|
|
function closingParen(source, open) {
|
|
let depth = 0;
|
|
for (let i = open; i < source.length; i++) {
|
|
if (source[i] === "(") depth++;
|
|
else if (source[i] === ")" && --depth === 0) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
function isDeclaration(mask, index, openParen) {
|
|
const before = mask.slice(Math.max(0, index - 100), index);
|
|
if (/\.\s*$/.test(before)) return false;
|
|
const close = closingParen(mask, openParen);
|
|
if (close < 0) return false;
|
|
const after = mask.slice(close + 1, Math.min(mask.length, close + 240));
|
|
const precedingDeclarationToken = /\b(?:function|public|private|protected|static|async|abstract|readonly|declare)\s*$/.test(before);
|
|
const signature = /^\s*(?:\??\s*)?(?::[^\n{;=]+)?\s*(?:\{|;)/.test(after);
|
|
return precedingDeclarationToken || signature;
|
|
}
|
|
|
|
function lineAt(content, index) {
|
|
const lineNumber = content.slice(0, index).split("\n").length;
|
|
const start = content.lastIndexOf("\n", index - 1) + 1;
|
|
const end = content.indexOf("\n", index);
|
|
return { lineNumber, line: content.slice(start, end < 0 ? content.length : end) };
|
|
}
|
|
|
|
export function scanFileContent(content, filePath, options = {}) {
|
|
const entries = validateAllowlistEntries(options.allowlistEntries ?? []);
|
|
const mask = codeMask(content);
|
|
const matches = [];
|
|
const pinned = new Set();
|
|
const pattern = /\bgetDatabase\s*\(/g;
|
|
for (let found; (found = pattern.exec(mask));) {
|
|
const openParen = mask.indexOf("(", found.index);
|
|
if (isDeclaration(mask, found.index, openParen)) continue;
|
|
const { lineNumber, line } = lineAt(content, found.index);
|
|
const exact = entries.find((entry) => entry.file === filePath && entry.line === lineNumber && entry.snippet === line.trim());
|
|
if (exact) { pinned.add(exact); continue; }
|
|
matches.push({ type: "invocation", filePath, lineNumber, line });
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.file === filePath && !pinned.has(entry)) matches.push({ type: "stale-allowlist", filePath, lineNumber: entry.line, line: entry.snippet });
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
export function scanTrackedFiles(files = listTrackedTargets(), options = {}) {
|
|
const entries = validateAllowlistEntries(options.allowlistEntries ?? loadAllowlistEntries(options.allowlistPath));
|
|
const readFile = options.readFile ?? readFileSync;
|
|
const matches = [];
|
|
const seen = new Set(files);
|
|
for (const entry of entries) if (!seen.has(entry.file)) matches.push({ type: "stale-allowlist", filePath: entry.file, lineNumber: entry.line, line: entry.snippet });
|
|
for (const filePath of files) {
|
|
let content;
|
|
try { content = readFile(filePath, "utf8"); }
|
|
catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") continue; throw error; }
|
|
matches.push(...scanFileContent(content, filePath, { allowlistEntries: entries }));
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
export function formatFailureMessage(matches) {
|
|
return [
|
|
"[check-no-getdatabase] found non-PostgreSQL durable-data access or a stale exemption.",
|
|
"Use ctx.taskStore.getAsyncLayer() with an async store; see docs/PLUGIN_AUTHORING.md.",
|
|
`Legitimate transitional exemptions must be invocation-pinned in ${ALLOWLIST_PATH} by file+line+snippet (all three required).`,
|
|
...matches.map(({ type, filePath, lineNumber, line }) => `${type}: ${filePath}:${lineNumber}: ${line.trim()}`),
|
|
].join("\n");
|
|
}
|
|
|
|
export function main() {
|
|
const matches = scanTrackedFiles();
|
|
if (!matches.length) return 0;
|
|
console.error(formatFailureMessage(matches));
|
|
return 1;
|
|
}
|
|
|
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) process.exitCode = main();
|