import { fail } from "../utils.js";
// Credential and identity tables are deliberately off-limits to the generic
// agent DB tools. They contain OAuth tokens, encrypted API keys, sessions, or
// auth identity data; use the framework stores/actions instead.
const SENSITIVE_FRAMEWORK_TABLE_RE =
/\b(app_secrets|oauth_tokens|user|users|session|sessions|account|accounts|verification|jwks|organization|member|invitation|org_members|org_invitations|pg_catalog|information_schema|pg_class|pg_proc|pg_namespace|pg_user|pg_roles|pg_authid|pg_shadow)\b/i;
function stripSqlNonIdentifiers(sql: string): string {
let out = "";
let state: "normal" | "single" | "line-comment" | "block-comment" = "normal";
for (let i = 0; i < sql.length; i++) {
const ch = sql[i];
const next = sql[i + 1];
if (state === "line-comment") {
if (ch === "\n") {
out += " ";
state = "normal";
}
continue;
}
if (state === "block-comment") {
if (ch === "*" && next === "/") {
i++;
out += " ";
state = "normal";
}
continue;
}
if (state === "single") {
if (ch === "'" && next === "'") {
i++;
} else if (ch === "'") {
out += " ";
state = "normal";
}
continue;
}
if (ch === "-" && next === "-") {
i++;
state = "line-comment";
continue;
}
if (ch === "/" && next === "*") {
i++;
state = "block-comment";
continue;
}
if (ch === "'") {
state = "single";
continue;
}
out += ch;
}
return out;
}
export function assertNoSensitiveFrameworkTables(
sql: string,
operation: "read" | "write" | "patch",
): void {
const cleanSql = stripSqlNonIdentifiers(sql);
const match = cleanSql.match(SENSITIVE_FRAMEWORK_TABLE_RE);
if (!match) return;
const verb =
operation === "read"
? "readable"
: operation === "write"
? "writable"
: "patchable";
fail(
`Sensitive framework table "${match[1]}" is not ${verb} through raw DB tools. Use the framework auth, secrets, or OAuth APIs instead.`,
);
}
// Schema/database-qualified table references (e.g. `public.notes`, `main.notes`,
// `pg_temp.notes`) BYPASS the per-user/per-org temporary views that scope
// db-query / db-exec, because those views only shadow UNQUALIFIED table names.
// A qualified reference resolves straight to the real base table, defeating the
// owner_email / org_id scoping and exposing (or letting writes touch) every
// tenant's rows. db-patch already rejects dotted identifiers via
// isValidIdentifier; db-query / db-exec must reject them too.
//
// Two complementary detectors run on the comment/string-stripped SQL:
// 1. The schemas that actually HOLD base tables and so defeat scoping when
// named explicitly: `public` (Neon Postgres prod), `main` (SQLite desktop),
// and the Postgres system catalogs. This fires in ANY position, so it also
// catches comma-joins (`FROM notes, public.other`) and `USING public.x`.
// `temp` / `pg_temp` are intentionally NOT listed — temporary objects (our
// scoping views) live there, so `temp.notes` resolves to the *scoped* view,
// not a bypass, and `temp` is a common table alias we must not reject.
// The schema may be bare or double-quoted (`"public"."notes"`).
// 2. Any dotted reference in table position (FROM/JOIN/INTO/UPDATE, incl.
// ONLY/LATERAL), which also catches non-standard schema names. Column /
// alias references like `f.id` sit in select/where/on position, not table
// position, so they do not match — no false positives on ordinary joins.
const DANGEROUS_SCHEMA_QUALIFIER_RE =
/(?:\b|")(?:main|public|pg_catalog|pg_toast|information_schema)"?\s*\.\s*(?:"|`|\[|[A-Za-z_])/i;
const TABLE_POSITION_QUALIFIED_RE =
/\b(?:FROM|JOIN|INTO|UPDATE)\s+(?:ONLY\s+|LATERAL\s+)?(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][A-Za-z0-9_$]*)\s*\.\s*(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][A-Za-z0-9_$]*)/i;
export function assertNoSchemaQualifiedTables(
sql: string,
operation: "read" | "write",
): void {
const cleanSql = stripSqlNonIdentifiers(sql);
if (
!DANGEROUS_SCHEMA_QUALIFIER_RE.test(cleanSql) &&
!TABLE_POSITION_QUALIFIED_RE.test(cleanSql)
) {
return;
}
const verb = operation === "read" ? "queried" : "written";
fail(
`Schema-qualified table references (e.g. "public.
" or "main.") cannot be ${verb} through raw DB tools — a qualified name bypasses the per-user data scoping that isolates each tenant's rows. Use the bare table name; the current user's scoping is applied automatically.`,
);
}
const ACCESS_CONTROL_TABLE_TOKENS = new Set([
"acl",
"access",
"admin",
"admins",
"grant",
"grants",
"invitation",
"invitations",
"invite",
"invites",
"member",
"members",
"permission",
"permissions",
"privilege",
"privileges",
"role",
"roles",
"user",
"users",
]);
const ACCESS_CONTROL_COLUMN_TOKENS = new Set([
"access",
"access_level",
"acl",
"admin",
"admins",
"grant",
"grants",
"is_admin",
"is_owner",
"member",
"members",
"owner",
"owner_email",
"permission",
"permissions",
"privilege",
"privileges",
"role",
"roles",
]);
function normalizeIdentifier(value: string): string {
return value
.trim()
.replace(/^["'`[]/, "")
.replace(/["'`\]]$/, "")
.toLowerCase();
}
function identifierTokens(identifier: string): Set {
const normalized = normalizeIdentifier(identifier);
const tokens = new Set([normalized]);
for (const token of normalized.split(/[^a-z0-9]+/).filter(Boolean)) {
tokens.add(token);
}
return tokens;
}
function hasSensitiveToken(
identifier: string,
sensitiveTokens: Set,
): string | null {
for (const token of identifierTokens(identifier)) {
if (sensitiveTokens.has(token)) return token;
}
return null;
}
function tableNameFromWriteSql(sql: string): string | null {
const match = sql.match(
/^\s*(?:INSERT(?:\s+OR\s+\w+)?\s+INTO|REPLACE(?:\s+OR\s+\w+)?\s+INTO|UPDATE|DELETE\s+FROM)\s+((?:"[^"]+"|'[^']+'|`[^`]+`|[\w]+)(?:\s*\.\s*(?:"[^"]+"|'[^']+'|`[^`]+`|[\w]+))?)/i,
);
if (!match) return null;
return normalizeIdentifier(match[1].split(".").pop() ?? match[1]);
}
function splitColumnList(columns: string): string[] {
return columns
.split(",")
.map((column) => normalizeIdentifier(column))
.filter(Boolean);
}
function insertColumnsFromSql(sql: string): string[] {
const match = sql.match(
/^\s*(?:INSERT(?:\s+OR\s+\w+)?\s+INTO|REPLACE(?:\s+OR\s+\w+)?\s+INTO)\s+(?:"[^"]+"|'[^']+'|`[^`]+`|[\w]+)(?:\s*\.\s*(?:"[^"]+"|'[^']+'|`[^`]+`|[\w]+))?\s*\(([^)]+)\)/i,
);
return match ? splitColumnList(match[1]) : [];
}
function updateColumnsFromSql(sql: string): string[] {
const setMatch = /\bSET\b/i.exec(sql);
if (!setMatch) return [];
const tail = sql.slice(setMatch.index + setMatch[0].length);
const endMatch = /\b(?:WHERE|RETURNING)\b/i.exec(tail);
const setClause = endMatch ? tail.slice(0, endMatch.index) : tail;
const columns: string[] = [];
const columnRe =
/(?:^|,)\s*(?:"([^"]+)"|'([^']+)'|`([^`]+)`|([A-Za-z_][A-Za-z0-9_]*))\s*=/g;
let match: RegExpExecArray | null;
while ((match = columnRe.exec(setClause)) !== null) {
columns.push(
normalizeIdentifier(match[1] ?? match[2] ?? match[3] ?? match[4]),
);
}
return columns;
}
function writeColumnsFromSql(sql: string): string[] {
const upper = sql.trim().toUpperCase();
if (upper.startsWith("UPDATE")) return updateColumnsFromSql(sql);
if (upper.startsWith("INSERT") || upper.startsWith("REPLACE")) {
return insertColumnsFromSql(sql);
}
return [];
}
export function assertNoRawDbAccessControlWrite(sql: string): void {
const tableName = tableNameFromWriteSql(sql);
if (tableName) {
const tableToken = hasSensitiveToken(
tableName,
ACCESS_CONTROL_TABLE_TOKENS,
);
if (tableToken) {
fail(
`Sensitive identity/access-control table "${tableName}" is not writable through raw DB tools. Use a dedicated app action or implement the permission change in reviewed code.`,
);
}
}
for (const column of writeColumnsFromSql(sql)) {
const columnToken = hasSensitiveToken(column, ACCESS_CONTROL_COLUMN_TOKENS);
if (!columnToken) continue;
fail(
`Sensitive identity/access-control column "${column}" is not writable through raw DB tools. Use a dedicated app action or implement the permission change in reviewed code.`,
);
}
}
export function assertNoRawDbAccessControlPatchTarget(
table: string,
column: string,
): void {
const tableName = normalizeIdentifier(table);
if (hasSensitiveToken(tableName, ACCESS_CONTROL_TABLE_TOKENS)) {
fail(
`Sensitive identity/access-control table "${tableName}" is not patchable through raw DB tools. Use a dedicated app action or implement the permission change in reviewed code.`,
);
}
const columnName = normalizeIdentifier(column);
if (hasSensitiveToken(columnName, ACCESS_CONTROL_COLUMN_TOKENS)) {
fail(
`Sensitive identity/access-control column "${columnName}" is not patchable through raw DB tools. Use a dedicated app action or implement the permission change in reviewed code.`,
);
}
}