security: sanitise Prisma error leaks in AI-tool helpers (#53)
Five helper error mappers (timeline / project-creation / resource-creation
/ vacation-creation / task-action-execution) fell through to
`return { error: error.message }` for BAD_REQUEST and CONFLICT cases. When
the TRPCError wrapped a Prisma error, the message contained column names,
relation paths, and the offending unique-constraint value — all of which
would reach the LLM in chat context and, via audit_log.changes JSONB, the DB.
Add `sanitizeAssistantErrorMessage()` that regex-detects Prisma and raw
Postgres signatures (P2002/P2003/P2025, not-null, FK, check-constraint,
duplicate-key) and replaces them with a generic "Invalid input". Also caps
messages at 500 chars to defend against stack-trace-like payloads. Wire
the helper into all five call-sites; the developer-constructed
`AssistantVisibleError` branch in `normalizeAssistantExecutionError` is
left untouched since those strings are hand-written.
Coverage: 11 new tests in assistant-tools-error-sanitiser.test.ts; existing
vacation / task-action / resource-creation / project-creation error tests
(12 tests, 5 files) all remain green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,43 @@ export class AssistantVisibleError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Signatures of raw Prisma / database errors that must never reach the LLM.
|
||||
// We'd rather surface a generic "Invalid input" than leak column names, FK
|
||||
// relation paths, or the offending value from a unique-constraint failure
|
||||
// (which can include user PII on a second write attempt).
|
||||
const PRISMA_LEAK_SIGNATURES = [
|
||||
/Invalid\s+`prisma\./i,
|
||||
/Unique constraint failed on the fields?:/i,
|
||||
/Foreign key constraint failed on the field/i,
|
||||
/An operation failed because it depends on one or more records/i,
|
||||
/The column\s+`[^`]+`\s+does not exist/i,
|
||||
/relation\s+"[^"]+"\s+does not exist/i,
|
||||
/duplicate key value violates unique constraint/i,
|
||||
/null value in column\s+"/i,
|
||||
/violates (?:check|not-null|foreign key) constraint/i,
|
||||
];
|
||||
|
||||
const SAFE_ERROR_FALLBACK = "Invalid input";
|
||||
const MAX_ASSISTANT_ERROR_LENGTH = 500;
|
||||
|
||||
/**
|
||||
* Sanitises a TRPCError / downstream error message before it's handed back
|
||||
* to the LLM. Hand-written BAD_REQUEST / CONFLICT messages in routers are
|
||||
* user-safe, but a subset of error paths pass raw Prisma text straight
|
||||
* through — that would leak schema details (column names, relation paths,
|
||||
* offending values) into chat context and, transitively, into audit JSONB.
|
||||
*
|
||||
* Strategy: regex-detect Prisma-flavoured signatures and replace with a
|
||||
* generic fallback. Also hard-cap length as a belt-and-suspenders defence
|
||||
* against stack-trace-like payloads.
|
||||
*/
|
||||
export function sanitizeAssistantErrorMessage(message: string): string {
|
||||
if (!message) return SAFE_ERROR_FALLBACK;
|
||||
if (message.length > MAX_ASSISTANT_ERROR_LENGTH) return SAFE_ERROR_FALLBACK;
|
||||
if (PRISMA_LEAK_SIGNATURES.some((re) => re.test(message))) return SAFE_ERROR_FALLBACK;
|
||||
return message;
|
||||
}
|
||||
|
||||
export function assertPermission(ctx: ToolContext, perm: PermissionKey): void {
|
||||
if (!ctx.permissions.has(perm)) {
|
||||
throw new AssistantVisibleError(
|
||||
@@ -293,7 +330,7 @@ export function toAssistantTimelineMutationError(
|
||||
}
|
||||
|
||||
if (error.code === "BAD_REQUEST" || error.code === "CONFLICT") {
|
||||
return { error: error.message };
|
||||
return { error: sanitizeAssistantErrorMessage(error.message) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +406,7 @@ export function toAssistantProjectCreationError(
|
||||
}
|
||||
|
||||
if (error.code === "BAD_REQUEST" || error.code === "UNPROCESSABLE_CONTENT") {
|
||||
return { error: error.message };
|
||||
return { error: sanitizeAssistantErrorMessage(error.message) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,7 +649,7 @@ export function toAssistantResourceCreationError(error: unknown): AssistantToolE
|
||||
}
|
||||
|
||||
if (error.code === "BAD_REQUEST" || error.code === "UNPROCESSABLE_CONTENT") {
|
||||
return { error: error.message };
|
||||
return { error: sanitizeAssistantErrorMessage(error.message) };
|
||||
}
|
||||
|
||||
if (error.code === "NOT_FOUND") {
|
||||
@@ -770,7 +807,7 @@ export function toAssistantVacationCreationError(error: unknown): AssistantToolE
|
||||
}
|
||||
|
||||
if (error.code === "BAD_REQUEST") {
|
||||
return { error: error.message };
|
||||
return { error: sanitizeAssistantErrorMessage(error.message) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1219,7 +1256,7 @@ export function toAssistantTaskActionError(error: unknown): AssistantToolErrorRe
|
||||
if (error.message === "Assignment is already CONFIRMED") {
|
||||
return { error: "Assignment is already confirmed." };
|
||||
}
|
||||
return { error: error.message };
|
||||
return { error: sanitizeAssistantErrorMessage(error.message) };
|
||||
}
|
||||
|
||||
if (error instanceof TRPCError && error.code === "FORBIDDEN") {
|
||||
|
||||
Reference in New Issue
Block a user