security: block raw/tx escape hatches on read-only AI DB proxy (#47)

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>
This commit is contained in:
2026-04-17 08:38:05 +02:00
parent 3c5d1d37f7
commit 1ff5c3377c
6 changed files with 127 additions and 4 deletions
+17 -3
View File
@@ -20,6 +20,17 @@ const WRITE_METHODS = new Set([
"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) {
@@ -43,11 +54,14 @@ export function createReadOnlyProxy(client: PrismaClient): PrismaClient {
if (value && typeof value === "object" && "findMany" in (value as Record<string, unknown>)) {
return readOnlyModelProxy(value as Record<string, unknown>, String(prop));
}
// Block $executeRaw and $executeRawUnsafe at the client level
if (prop === "$executeRaw" || prop === "$executeRawUnsafe") {
// 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 write operation "${String(prop)}" not permitted on read-only context`,
`Raw/escape operation "${String(prop)}" not permitted on read-only context`,
);
};
}