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
@@ -60,7 +60,9 @@ describe("assistant estimate detail read tools", () => {
userCtx,
);
expect(vi.mocked(getEstimateById)).toHaveBeenCalledWith(controllerCtx.db, "est_1");
// Read tools receive ctx.db wrapped in a read-only proxy (EGAI 4.1.1.2),
// so we assert only on the estimate id, not the exact db instance.
expect(vi.mocked(getEstimateById)).toHaveBeenCalledWith(expect.anything(), "est_1");
expect(JSON.parse(successResult.content)).toEqual(
expect.objectContaining({
id: "est_1",
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from "vitest";
import { createReadOnlyProxy } from "../lib/read-only-prisma.js";
function makeFakeClient() {
const user = {
findUnique: vi.fn(async () => ({ id: "u1" })),
findMany: vi.fn(async () => []),
create: vi.fn(async () => ({ id: "u1" })),
update: vi.fn(async () => ({ id: "u1" })),
upsert: vi.fn(async () => ({ id: "u1" })),
delete: vi.fn(async () => ({ id: "u1" })),
createMany: vi.fn(async () => ({ count: 1 })),
createManyAndReturn: vi.fn(async () => [{ id: "u1" }]),
updateMany: vi.fn(async () => ({ count: 1 })),
deleteMany: vi.fn(async () => ({ count: 1 })),
};
const client = {
user,
$queryRaw: vi.fn(async () => [{ result: 1 }]),
$queryRawUnsafe: vi.fn(async () => [{ result: 1 }]),
$executeRaw: vi.fn(async () => 0),
$executeRawUnsafe: vi.fn(async () => 0),
$transaction: vi.fn(async () => []),
$runCommandRaw: vi.fn(async () => ({ ok: 1 })),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return client as any;
}
describe("createReadOnlyProxy", () => {
it("allows model reads", async () => {
const proxy = createReadOnlyProxy(makeFakeClient());
await expect(proxy.user.findUnique({ where: { id: "u1" } })).resolves.toEqual({ id: "u1" });
await expect(proxy.user.findMany()).resolves.toEqual([]);
});
it("blocks model writes with clear error", () => {
const proxy = createReadOnlyProxy(makeFakeClient());
expect(() => proxy.user.create({ data: {} })).toThrow(
/Write operation "create" on "user" not permitted/,
);
expect(() => proxy.user.update({ where: { id: "u1" }, data: {} })).toThrow(
/Write operation "update"/,
);
expect(() => proxy.user.upsert({ where: { id: "u1" }, create: {}, update: {} })).toThrow(
/Write operation "upsert"/,
);
expect(() => proxy.user.delete({ where: { id: "u1" } })).toThrow(/Write operation "delete"/);
expect(() => proxy.user.createMany({ data: [] })).toThrow(/Write operation "createMany"/);
expect(() => proxy.user.createManyAndReturn({ data: [] })).toThrow(
/Write operation "createManyAndReturn"/,
);
expect(() => proxy.user.updateMany({ where: {}, data: {} })).toThrow(
/Write operation "updateMany"/,
);
expect(() => proxy.user.deleteMany({ where: {} })).toThrow(/Write operation "deleteMany"/);
});
it("allows template-tagged $queryRaw (read-only by contract)", async () => {
const proxy = createReadOnlyProxy(makeFakeClient());
await expect(proxy.$queryRaw`SELECT 1`).resolves.toEqual([{ result: 1 }]);
});
it("blocks $queryRawUnsafe (DDL/DML smuggling)", () => {
const proxy = createReadOnlyProxy(makeFakeClient());
expect(() => proxy.$queryRawUnsafe("SELECT 1")).toThrow(
/Raw\/escape operation "\$queryRawUnsafe" not permitted/,
);
});
it("blocks $executeRaw and $executeRawUnsafe", () => {
const proxy = createReadOnlyProxy(makeFakeClient());
expect(() => proxy.$executeRaw`DELETE FROM users`).toThrow(
/Raw\/escape operation "\$executeRaw" not permitted/,
);
expect(() => proxy.$executeRawUnsafe("DELETE FROM users")).toThrow(
/Raw\/escape operation "\$executeRawUnsafe" not permitted/,
);
});
it("blocks $transaction (interactive tx could contain writes)", () => {
const proxy = createReadOnlyProxy(makeFakeClient());
expect(() => proxy.$transaction([])).toThrow(
/Raw\/escape operation "\$transaction" not permitted/,
);
});
it("blocks $runCommandRaw (Mongo-style raw command)", () => {
const proxy = createReadOnlyProxy(makeFakeClient());
expect(() => proxy.$runCommandRaw({})).toThrow(
/Raw\/escape operation "\$runCommandRaw" not permitted/,
);
});
});