Files
CapaKraken/packages/api/src/router/assistant-tools/shared.ts
T
Hartmut 1ff5c3377c 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>
2026-04-17 08:38:05 +02:00

56 lines
1.7 KiB
TypeScript

import type { prisma } from "@capakraken/db";
import type { PermissionKey, SystemRole } from "@capakraken/shared";
import type { z } from "zod";
import type { TRPCContext } from "../../trpc.js";
export type ToolContext = {
db: typeof prisma;
userId: string;
userRole: string;
permissions: Set<PermissionKey>;
session?: TRPCContext["session"];
dbUser?: TRPCContext["dbUser"];
roleDefaults?: TRPCContext["roleDefaults"];
/**
* If true, the ctx.db passed in is already wrapped by
* `createReadOnlyProxy` and any scoped tRPC caller the tool spawns
* MUST also receive the proxied client — otherwise a read-only tool
* can smuggle writes through a tRPC caller that bypasses the proxy.
*/
isReadOnly?: boolean;
};
export interface ToolAccessRequirements {
requiredPermissions?: PermissionKey[];
allowedSystemRoles?: SystemRole[];
requiresPlanningRead?: boolean;
requiresCostView?: boolean;
requiresAdvancedAssistant?: boolean;
requiresResourceOverview?: boolean;
}
export interface ToolDef {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
access?: ToolAccessRequirements;
/** EGAI 4.3.1.2 — optional Zod schema to validate tool results before returning to the AI */
resultSchema?: z.ZodType;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ToolExecutor = (params: any, ctx: ToolContext) => Promise<unknown>;
export function withToolAccess(
tools: ToolDef[],
accessByName: Partial<Record<string, ToolAccessRequirements>>,
): ToolDef[] {
return tools.map((tool) => ({
...tool,
...(accessByName[tool.function.name] ? { access: accessByName[tool.function.name] } : {}),
}));
}