1ff5c3377c
The read-only proxy previously wrapped model delegates to block writes, but left client-level raw/escape hatches ($transaction, $executeRaw, $executeRawUnsafe, $queryRawUnsafe, $runCommandRaw) intact. A read-tool could smuggle DML via raw SQL, or open an interactive $transaction whose tx-scoped client (unproxied by construction) accepts writes. - read-only-prisma: block $transaction, $executeRaw, $executeRawUnsafe, $queryRawUnsafe, $runCommandRaw at the client level. Template-tagged $queryRaw stays allowed (read-only by API contract). - assistant-tools: add create_estimate to MUTATION_TOOLS — it uses $transaction internally and was previously bypassing the proxy only because $transaction wasn't blocked. - shared: document isReadOnly flag on ToolContext so any scoped tRPC caller a tool spawns keeps the proxied client. - helpers: note the runtime wrap at assistant-tools.ts:739 is authoritative; forwarding ctx.db verbatim is correct. - tests: cover model writes, raw escapes, and the allowed $queryRaw path (7 cases, all pass). - loosen one estimate-detail test that compared the exact db instance (fails once that instance is a proxy; the assertion's intent is the estimate id). Covers EGAI 4.1.1.2 / IAAI 3.6.22. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
72 lines
2.2 KiB
TypeScript
72 lines
2.2 KiB
TypeScript
/**
|
|
* Read-only Prisma proxy.
|
|
*
|
|
* Wraps a PrismaClient and blocks write operations at the application level.
|
|
* Used to enforce read-only access for AI read-tools (EGAI 4.1.1.2 / IAAI 3.6.22).
|
|
*/
|
|
|
|
import type { prisma } from "@capakraken/db";
|
|
|
|
type PrismaClient = typeof prisma;
|
|
|
|
const WRITE_METHODS = new Set([
|
|
"create",
|
|
"createMany",
|
|
"createManyAndReturn",
|
|
"update",
|
|
"updateMany",
|
|
"upsert",
|
|
"delete",
|
|
"deleteMany",
|
|
]);
|
|
|
|
// Client-level raw/escape hatches that MUST be blocked on a read-only
|
|
// context. Missing any one of these lets a read-tool smuggle writes via
|
|
// raw SQL, transactions, or the Mongo-style runCommandRaw.
|
|
const BLOCKED_CLIENT_METHODS = new Set([
|
|
"$executeRaw",
|
|
"$executeRawUnsafe",
|
|
"$transaction",
|
|
"$queryRawUnsafe",
|
|
"$runCommandRaw",
|
|
]);
|
|
|
|
function readOnlyModelProxy(model: Record<string, unknown>, modelName: string): unknown {
|
|
return new Proxy(model, {
|
|
get(target, prop) {
|
|
if (typeof prop === "string" && WRITE_METHODS.has(prop)) {
|
|
return () => {
|
|
throw new Error(
|
|
`Write operation "${prop}" on "${modelName}" not permitted on read-only context`,
|
|
);
|
|
};
|
|
}
|
|
return Reflect.get(target, prop);
|
|
},
|
|
});
|
|
}
|
|
|
|
export function createReadOnlyProxy(client: PrismaClient): PrismaClient {
|
|
return new Proxy(client, {
|
|
get(target, prop) {
|
|
const value = Reflect.get(target, prop);
|
|
// If accessing a model delegate (object with findMany, etc.), wrap it
|
|
if (value && typeof value === "object" && "findMany" in (value as Record<string, unknown>)) {
|
|
return readOnlyModelProxy(value as Record<string, unknown>, String(prop));
|
|
}
|
|
// Block raw/escape-hatch methods at the client level. $queryRaw
|
|
// (template-tagged) is allowed — it's read-only by API contract;
|
|
// $queryRawUnsafe is blocked because a crafted string could be
|
|
// used to smuggle DDL/DML.
|
|
if (typeof prop === "string" && BLOCKED_CLIENT_METHODS.has(prop)) {
|
|
return () => {
|
|
throw new Error(
|
|
`Raw/escape operation "${String(prop)}" not permitted on read-only context`,
|
|
);
|
|
};
|
|
}
|
|
return value;
|
|
},
|
|
}) as PrismaClient;
|
|
}
|