From ac0368918fa945088d489c78dac1a3005bd2500e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 28 Aug 2026 13:23:52 -0700 Subject: [PATCH 1/2] fix(guardrails): route PII validation through app runtime --- .../api/guardrails/pii/validate/route.test.ts | 67 ++++++++++++++++ .../app/api/guardrails/pii/validate/route.ts | 35 ++++++++ apps/sim/lib/api/contracts/hotspots.ts | 46 +++++++++++ .../lib/guardrails/validation-client.test.ts | 80 +++++++++++++++++++ apps/sim/lib/guardrails/validation-client.ts | 43 ++++++++++ .../internal/guardrails/operations.test.ts | 40 +++++++++- .../sim/lib/internal/guardrails/operations.ts | 22 ++--- 7 files changed, 319 insertions(+), 14 deletions(-) create mode 100644 apps/sim/app/api/guardrails/pii/validate/route.test.ts create mode 100644 apps/sim/app/api/guardrails/pii/validate/route.ts create mode 100644 apps/sim/lib/guardrails/validation-client.test.ts create mode 100644 apps/sim/lib/guardrails/validation-client.ts diff --git a/apps/sim/app/api/guardrails/pii/validate/route.test.ts b/apps/sim/app/api/guardrails/pii/validate/route.test.ts new file mode 100644 index 00000000000..8e5fd8dfc5d --- /dev/null +++ b/apps/sim/app/api/guardrails/pii/validate/route.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockValidatePII } = vi.hoisted(() => ({ + mockValidatePII: vi.fn(), +})) + +vi.mock('@/lib/guardrails/validate_pii', () => ({ + validatePII: mockValidatePII, +})) + +import { POST } from '@/app/api/guardrails/pii/validate/route' + +describe('POST /api/guardrails/pii/validate', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ success: true }) + mockValidatePII.mockResolvedValue({ passed: true, detectedEntities: [] }) + }) + + it('authenticates before validating the request body', async () => { + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: false, + error: 'Internal authentication required', + }) + + const response = await POST(createMockRequest('POST', { text: 42 })) + + expect(response.status).toBe(401) + expect(mockValidatePII).not.toHaveBeenCalled() + }) + + it('runs Presidio validation inside the app boundary', async () => { + const request = createMockRequest('POST', { + text: 'email a@b.com', + entityTypes: ['EMAIL_ADDRESS'], + mode: 'mask', + language: 'en', + }) + + const response = await POST(request) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ passed: true, detectedEntities: [] }) + expect(mockValidatePII).toHaveBeenCalledWith({ + text: 'email a@b.com', + entityTypes: ['EMAIL_ADDRESS'], + mode: 'mask', + language: 'en', + customPatterns: undefined, + requestId: 'mock-request-id', + abortSignal: request.signal, + }) + }) + + it('rejects malformed input before calling Presidio', async () => { + const response = await POST( + createMockRequest('POST', { text: 'claim', entityTypes: [], mode: 'invalid' }) + ) + + expect(response.status).toBe(400) + expect(mockValidatePII).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/guardrails/pii/validate/route.ts b/apps/sim/app/api/guardrails/pii/validate/route.ts new file mode 100644 index 00000000000..a8120ac9f4f --- /dev/null +++ b/apps/sim/app/api/guardrails/pii/validate/route.ts @@ -0,0 +1,35 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { guardrailsPiiValidateContract } from '@/lib/api/contracts' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { validatePII } from '@/lib/guardrails/validate_pii' + +/** + * App-container capability boundary for single-text PII validation. Presidio is + * intentionally ECS-internal, so remote workflow runtimes authenticate here + * instead of importing its client and attempting to reach `PII_URL` directly. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const auth = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(guardrailsPiiValidateContract, request, {}) + if (!parsed.success) return parsed.response + + const { text, entityTypes, mode, language, customPatterns } = parsed.data.body + const result = await validatePII({ + text, + entityTypes, + mode, + language, + customPatterns, + requestId: generateRequestId(), + abortSignal: request.signal, + }) + + return NextResponse.json(guardrailsPiiValidateContract.response.schema.parse(result)) +}) diff --git a/apps/sim/lib/api/contracts/hotspots.ts b/apps/sim/lib/api/contracts/hotspots.ts index b2ec180bb40..2fbac828582 100644 --- a/apps/sim/lib/api/contracts/hotspots.ts +++ b/apps/sim/lib/api/contracts/hotspots.ts @@ -20,6 +20,35 @@ const guardrailsMaskBatchResponseSchema = z.object({ masked: z.array(z.string()), }) +export const guardrailsPiiValidateBodySchema = z + .object({ + text: z.string().max(10_000_000, 'Text is too long'), + entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(200), + mode: z.enum(['block', 'mask']), + language: z.string().min(1, 'Language cannot be empty').max(20).optional(), + customPatterns: z.array(customPatternSchema).max(20).optional(), + }) + .strict() + +export const detectedPiiEntitySchema = z + .object({ + type: z.string().min(1, 'Entity type cannot be empty').max(100), + start: z.number().int().nonnegative(), + end: z.number().int().nonnegative(), + score: z.number().min(0).max(1), + text: z.string().max(10_000_000, 'Detected text is too long'), + }) + .strict() + +export const guardrailsPiiValidateResponseSchema = z + .object({ + passed: z.boolean(), + error: z.string().max(1_000).optional(), + detectedEntities: z.array(detectedPiiEntitySchema), + maskedText: z.string().max(10_000_000, 'Masked text is too long').optional(), + }) + .strict() + /** * Internal batch PII masking. Called server-to-server (internal JWT) from the * log-redaction persist path so Presidio always runs in the app container, @@ -38,6 +67,23 @@ export const guardrailsMaskBatchContract = defineRouteContract({ export type GuardrailsMaskBatchBody = z.input export type GuardrailsMaskBatchResult = z.output +/** + * Internal single-text PII validation. The workflow executor can run outside + * the app network, while only the app task can reach the Presidio service. + */ +export const guardrailsPiiValidateContract = defineRouteContract({ + method: 'POST', + path: '/api/guardrails/pii/validate', + body: guardrailsPiiValidateBodySchema, + response: { + mode: 'json', + schema: guardrailsPiiValidateResponseSchema, + }, +}) + +export type GuardrailsPiiValidateBody = z.input +export type GuardrailsPiiValidateResult = z.output + const chatMessageSchema = z.object({ role: z.enum(['user', 'assistant', 'system']), content: z.string(), diff --git a/apps/sim/lib/guardrails/validation-client.test.ts b/apps/sim/lib/guardrails/validation-client.test.ts new file mode 100644 index 00000000000..663e3e9dc22 --- /dev/null +++ b/apps/sim/lib/guardrails/validation-client.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ +import { resetUrlsMock, urlsMockFns } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +afterAll(resetUrlsMock) + +const { mockToken } = vi.hoisted(() => ({ + mockToken: vi.fn(), +})) + +vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockToken })) + +import { validatePIIViaHttp } from '@/lib/guardrails/validation-client' + +describe('validatePIIViaHttp', () => { + const mockBaseUrl = urlsMockFns.mockGetInternalApiBaseUrl + let fetchMock: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + mockToken.mockResolvedValue('internal-token') + mockBaseUrl.mockReturnValue('https://app.example.com') + fetchMock = vi.fn(async () => + Response.json({ passed: true, detectedEntities: [], maskedText: 'clean' }) + ) + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('calls the authenticated app capability with the caller signal', async () => { + const controller = new AbortController() + + await expect( + validatePIIViaHttp( + { + text: 'clean', + entityTypes: ['EMAIL_ADDRESS'], + mode: 'mask', + language: 'en', + }, + controller.signal + ) + ).resolves.toEqual({ passed: true, detectedEntities: [], maskedText: 'clean' }) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://app.example.com/api/guardrails/pii/validate', + expect.objectContaining({ + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer internal-token', + }, + signal: controller.signal, + }) + ) + }) + + it('fails on an HTTP error without retrying', async () => { + fetchMock.mockResolvedValueOnce(new Response('unavailable', { status: 503 })) + + await expect( + validatePIIViaHttp({ text: 'claim', entityTypes: [], mode: 'block' }) + ).rejects.toThrow('PII validation request failed (503): unavailable') + expect(fetchMock).toHaveBeenCalledOnce() + }) + + it('fails when the endpoint returns an invalid success body', async () => { + fetchMock.mockResolvedValueOnce(Response.json({ passed: true })) + + await expect( + validatePIIViaHttp({ text: 'claim', entityTypes: [], mode: 'block' }) + ).rejects.toThrow() + expect(fetchMock).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/lib/guardrails/validation-client.ts b/apps/sim/lib/guardrails/validation-client.ts new file mode 100644 index 00000000000..0bca6bbb0a0 --- /dev/null +++ b/apps/sim/lib/guardrails/validation-client.ts @@ -0,0 +1,43 @@ +import type { GuardrailsPiiValidateBody, GuardrailsPiiValidateResult } from '@/lib/api/contracts' +import { + guardrailsPiiValidateBodySchema, + guardrailsPiiValidateContract, + guardrailsPiiValidateResponseSchema, +} from '@/lib/api/contracts' +import { generateInternalToken } from '@/lib/auth/internal' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' + +/** + * Validates one string through the app-container PII capability boundary. + * + * Workflow tool operations execute both in the app task and in Trigger.dev + * workers, but only the app network can reach the ECS-internal Presidio + * service. Always using this boundary keeps manual and scheduled verdicts on + * one path and prevents the worker bundle from importing the Presidio client. + */ +export async function validatePIIViaHttp( + input: GuardrailsPiiValidateBody, + signal?: AbortSignal +): Promise { + const body = guardrailsPiiValidateBodySchema.parse(input) + const token = await generateInternalToken() + const url = `${getInternalApiBaseUrl()}${guardrailsPiiValidateContract.path}` + + // boundary-raw-fetch: cross-process capability call to the authenticated app-container PII endpoint + const response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + signal, + }) + + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error(`PII validation request failed (${response.status}): ${detail.slice(0, 200)}`) + } + + return guardrailsPiiValidateResponseSchema.parse(await response.json()) +} diff --git a/apps/sim/lib/internal/guardrails/operations.test.ts b/apps/sim/lib/internal/guardrails/operations.test.ts index b64a6e41e24..bde1a0b1315 100644 --- a/apps/sim/lib/internal/guardrails/operations.test.ts +++ b/apps/sim/lib/internal/guardrails/operations.test.ts @@ -19,7 +19,7 @@ const mocks = vi.hoisted(() => ({ requireBillingAttribution: vi.fn(), validateHallucination: vi.fn(), validateJson: vi.fn(), - validatePII: vi.fn(), + validatePIIViaHttp: vi.fn(), validateRegex: vi.fn(), })) @@ -41,8 +41,10 @@ vi.mock('@/lib/guardrails/validate_hallucination', () => ({ validateHallucination: mocks.validateHallucination, })) vi.mock('@/lib/guardrails/validate_json', () => ({ validateJson: mocks.validateJson })) -vi.mock('@/lib/guardrails/validate_pii', () => ({ validatePII: mocks.validatePII })) vi.mock('@/lib/guardrails/validate_regex', () => ({ validateRegex: mocks.validateRegex })) +vi.mock('@/lib/guardrails/validation-client', () => ({ + validatePIIViaHttp: mocks.validatePIIViaHttp, +})) vi.mock('@/ee/access-control/utils/permission-check', () => ({ assertPermissionsAllowed: mocks.assertPermissionsAllowed, ModelNotAllowedError: class ModelNotAllowedError extends Error {}, @@ -78,7 +80,7 @@ describe('executeGuardrailsValidation', () => { mocks.validateHallucination.mockResolvedValue({ passed: true, score: 8 }) mocks.validateJson.mockReturnValue({ passed: true }) mocks.validateRegex.mockReturnValue({ passed: true }) - mocks.validatePII.mockResolvedValue({ passed: true, detectedEntities: [] }) + mocks.validatePIIViaHttp.mockResolvedValue({ passed: true, detectedEntities: [] }) }) it('runs hallucination work once with authorized scope, billing, provenance, and signal', async () => { @@ -146,6 +148,38 @@ describe('executeGuardrailsValidation', () => { expect(mocks.requireBillingAttribution).not.toHaveBeenCalled() }) + it('routes PII validation through the app-container capability boundary', async () => { + const controller = new AbortController() + + const result = await executeGuardrailsValidation( + { + validationType: 'pii', + input: 'email a@b.com', + piiEntityTypes: ['EMAIL_ADDRESS'], + piiMode: 'mask', + piiLanguage: 'en', + }, + { + actorUserId: 'user-1', + headers: new Headers(), + requestId: 'request-1', + signal: controller.signal, + } + ) + + expect(result.output.passed).toBe(true) + expect(mocks.validatePIIViaHttp).toHaveBeenCalledWith( + { + text: 'email a@b.com', + entityTypes: ['EMAIL_ADDRESS'], + mode: 'mask', + language: 'en', + customPatterns: undefined, + }, + controller.signal + ) + }) + it('conceals inaccessible workflow validation as a failed verdict without provider work', async () => { workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: false, diff --git a/apps/sim/lib/internal/guardrails/operations.ts b/apps/sim/lib/internal/guardrails/operations.ts index 18eda1d2461..0c11654d3ff 100644 --- a/apps/sim/lib/internal/guardrails/operations.ts +++ b/apps/sim/lib/internal/guardrails/operations.ts @@ -12,11 +12,10 @@ import { import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing' import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' import { inspectModelInputProvenanceRequest } from '@/lib/execution/model-input-provenance' -import type { CustomPiiPattern } from '@/lib/guardrails/pii-entities' import { validateHallucination } from '@/lib/guardrails/validate_hallucination' import { validateJson } from '@/lib/guardrails/validate_json' -import { validatePII } from '@/lib/guardrails/validate_pii' import { validateRegex } from '@/lib/guardrails/validate_regex' +import { validatePIIViaHttp } from '@/lib/guardrails/validation-client' import { GuardrailsOperationError } from '@/lib/internal/guardrails/errors' import type { GuardrailsValidationInput } from '@/lib/internal/guardrails/input' import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' @@ -250,15 +249,16 @@ async function executeValidation( }) } if (input.validationType === 'pii') { - return validatePII({ - text: inputString, - entityTypes: input.piiEntityTypes || [], - mode: input.piiMode === 'mask' ? 'mask' : 'block', - language: input.piiLanguage || 'en', - customPatterns: input.piiCustomPatterns as CustomPiiPattern[] | undefined, - requestId: context.requestId, - abortSignal: context.signal, - }) + return validatePIIViaHttp( + { + text: inputString, + entityTypes: input.piiEntityTypes || [], + mode: input.piiMode === 'mask' ? 'mask' : 'block', + language: input.piiLanguage || 'en', + customPatterns: input.piiCustomPatterns, + }, + context.signal + ) } return { passed: false, error: 'Unknown validation type' } } From 0985becfcc9808635b34832c3abc3fd152a858fd Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 28 Aug 2026 13:39:21 -0700 Subject: [PATCH 2/2] fix(guardrails): bound PII validation results --- apps/sim/lib/api/contracts/hotspots.ts | 15 +- apps/sim/lib/guardrails/pii-limits.ts | 8 ++ apps/sim/lib/guardrails/validate_pii.test.ts | 72 ++++++++++ apps/sim/lib/guardrails/validate_pii.ts | 136 ++++++++++++++++-- .../lib/guardrails/validation-client.test.ts | 14 ++ apps/sim/lib/guardrails/validation-client.ts | 19 ++- .../internal/guardrails/operations.test.ts | 24 ++++ .../sim/lib/internal/guardrails/operations.ts | 30 ++-- 8 files changed, 292 insertions(+), 26 deletions(-) create mode 100644 apps/sim/lib/guardrails/pii-limits.ts diff --git a/apps/sim/lib/api/contracts/hotspots.ts b/apps/sim/lib/api/contracts/hotspots.ts index 2fbac828582..e030d94f9b0 100644 --- a/apps/sim/lib/api/contracts/hotspots.ts +++ b/apps/sim/lib/api/contracts/hotspots.ts @@ -8,6 +8,10 @@ import { import { defineRouteContract } from '@/lib/api/contracts/types' import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages' import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' +import { + MAX_PII_VALIDATION_DETECTED_ENTITIES, + MAX_PII_VALIDATION_TEXT_CHARACTERS, +} from '@/lib/guardrails/pii-limits' const guardrailsMaskBatchBodySchema = z.object({ texts: z.array(z.string()).max(100_000), @@ -22,7 +26,7 @@ const guardrailsMaskBatchResponseSchema = z.object({ export const guardrailsPiiValidateBodySchema = z .object({ - text: z.string().max(10_000_000, 'Text is too long'), + text: z.string().max(MAX_PII_VALIDATION_TEXT_CHARACTERS, 'Text is too long'), entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(200), mode: z.enum(['block', 'mask']), language: z.string().min(1, 'Language cannot be empty').max(20).optional(), @@ -36,7 +40,7 @@ export const detectedPiiEntitySchema = z start: z.number().int().nonnegative(), end: z.number().int().nonnegative(), score: z.number().min(0).max(1), - text: z.string().max(10_000_000, 'Detected text is too long'), + text: z.string().max(MAX_PII_VALIDATION_TEXT_CHARACTERS, 'Detected text is too long'), }) .strict() @@ -44,8 +48,11 @@ export const guardrailsPiiValidateResponseSchema = z .object({ passed: z.boolean(), error: z.string().max(1_000).optional(), - detectedEntities: z.array(detectedPiiEntitySchema), - maskedText: z.string().max(10_000_000, 'Masked text is too long').optional(), + detectedEntities: z.array(detectedPiiEntitySchema).max(MAX_PII_VALIDATION_DETECTED_ENTITIES), + maskedText: z + .string() + .max(MAX_PII_VALIDATION_TEXT_CHARACTERS, 'Masked text is too long') + .optional(), }) .strict() diff --git a/apps/sim/lib/guardrails/pii-limits.ts b/apps/sim/lib/guardrails/pii-limits.ts new file mode 100644 index 00000000000..c61ca64807b --- /dev/null +++ b/apps/sim/lib/guardrails/pii-limits.ts @@ -0,0 +1,8 @@ +/** Maximum characters accepted or returned by single-text PII validation. */ +export const MAX_PII_VALIDATION_TEXT_CHARACTERS = 10_000_000 + +/** Maximum detected spans materialized into one guardrail verdict. */ +export const MAX_PII_VALIDATION_DETECTED_ENTITIES = 10_000 + +/** Maximum bytes read or serialized for one PII validation result. */ +export const MAX_PII_VALIDATION_RESPONSE_BYTES = 10 * 1024 * 1024 diff --git a/apps/sim/lib/guardrails/validate_pii.test.ts b/apps/sim/lib/guardrails/validate_pii.test.ts index d427951bd43..f49bae82a84 100644 --- a/apps/sim/lib/guardrails/validate_pii.test.ts +++ b/apps/sim/lib/guardrails/validate_pii.test.ts @@ -2,6 +2,10 @@ * @vitest-environment node */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + MAX_PII_VALIDATION_DETECTED_ENTITIES, + MAX_PII_VALIDATION_RESPONSE_BYTES, +} from '@/lib/guardrails/pii-limits' import { maskPIIBatch, validatePII } from '@/lib/guardrails/validate_pii' interface Span { @@ -177,5 +181,73 @@ describe('validate_pii (Presidio service)', () => { expect(res.passed).toBe(true) expect(res.detectedEntities).toHaveLength(0) }) + + it('fails closed before materializing too many detected entities', async () => { + const spans = Array.from({ length: MAX_PII_VALIDATION_DETECTED_ENTITIES + 1 }, () => ({ + entity_type: 'CUSTOM_0', + start: 0, + end: 1, + score: 0.8, + })) + fetchMock.mockResolvedValueOnce(Response.json(spans)) + + const res = await validatePII({ + text: 'claim', + entityTypes: [], + mode: 'block', + requestId: 'entity-limit', + }) + + expect(res).toMatchObject({ passed: false, detectedEntities: [] }) + expect(res.error).toContain( + `more than ${MAX_PII_VALIDATION_DETECTED_ENTITIES} detected entities` + ) + expect(fetchMock).toHaveBeenCalledOnce() + }) + + it('fails closed before parsing an oversized anonymizer response', async () => { + fetchMock + .mockResolvedValueOnce( + Response.json([{ entity_type: 'EMAIL_ADDRESS', start: 0, end: 1, score: 0.9 }]) + ) + .mockResolvedValueOnce( + new Response('{"text":"masked"}', { + headers: { 'content-length': String(MAX_PII_VALIDATION_RESPONSE_BYTES + 1) }, + }) + ) + + const res = await validatePII({ + text: 'a', + entityTypes: [], + mode: 'mask', + requestId: 'output-limit', + }) + + expect(res).toMatchObject({ passed: false, detectedEntities: [] }) + expect(res.error).toContain('PII anonymizer response exceeds maximum size') + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('fails closed before materializing oversized detected-entity text', async () => { + const text = 'x'.repeat(6_000) + const spans = Array.from({ length: 2_000 }, () => ({ + entity_type: 'CUSTOM_0', + start: 0, + end: text.length, + score: 0.8, + })) + fetchMock.mockResolvedValueOnce(Response.json(spans)) + + const res = await validatePII({ + text, + entityTypes: [], + mode: 'block', + requestId: 'entity-byte-limit', + }) + + expect(res).toMatchObject({ passed: false, detectedEntities: [] }) + expect(res.error).toContain('detected entities exceed the validation response size limit') + expect(fetchMock).toHaveBeenCalledOnce() + }) }) }) diff --git a/apps/sim/lib/guardrails/validate_pii.ts b/apps/sim/lib/guardrails/validate_pii.ts index 8b77841dabd..2f6169ea676 100644 --- a/apps/sim/lib/guardrails/validate_pii.ts +++ b/apps/sim/lib/guardrails/validate_pii.ts @@ -1,9 +1,20 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' import { env } from '@/lib/core/config/env' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + readResponseJsonWithLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' import { chunkIndicesByBudget } from '@/lib/guardrails/pii-batching' import type { CustomPiiPattern } from '@/lib/guardrails/pii-entities' +import { + MAX_PII_VALIDATION_DETECTED_ENTITIES, + MAX_PII_VALIDATION_RESPONSE_BYTES, + MAX_PII_VALIDATION_TEXT_CHARACTERS, +} from '@/lib/guardrails/pii-limits' import { isAbortError } from '@/providers/streaming-tool-loop-shared' const logger = createLogger('PIIValidator') @@ -79,6 +90,77 @@ interface AnalyzerSpan { score: number } +function parseAnalyzerSpans(value: unknown, textLength: number): AnalyzerSpan[] { + if (!Array.isArray(value)) throw new Error('PII analyzer returned an invalid result') + if (value.length > MAX_PII_VALIDATION_DETECTED_ENTITIES) { + throw new Error( + `PII analyzer returned more than ${MAX_PII_VALIDATION_DETECTED_ENTITIES} detected entities` + ) + } + + return value.map((span, index) => { + if (!span || typeof span !== 'object' || Array.isArray(span)) { + throw new Error(`PII analyzer returned an invalid entity at index ${index}`) + } + const record = span as Record + if ( + typeof record.entity_type !== 'string' || + !record.entity_type || + record.entity_type.length > 100 || + typeof record.start !== 'number' || + typeof record.end !== 'number' || + !Number.isInteger(record.start) || + !Number.isInteger(record.end) || + record.start < 0 || + record.end < record.start || + record.end > textLength || + typeof record.score !== 'number' || + record.score < 0 || + record.score > 1 + ) { + throw new Error(`PII analyzer returned an invalid entity at index ${index}`) + } + return { + entity_type: record.entity_type, + start: record.start, + end: record.end, + score: record.score, + } + }) +} + +function assertDetectedEntityBudget( + text: string, + spans: AnalyzerSpan[], + patterns?: CustomPiiPattern[] +): void { + let estimatedBytes = 2 + for (const span of spans) { + const type = displayEntityType(span.entity_type, patterns) + estimatedBytes += + 128 + + Buffer.byteLength(type, 'utf8') + + Buffer.byteLength(text.slice(span.start, span.end), 'utf8') + if (estimatedBytes > MAX_PII_VALIDATION_RESPONSE_BYTES) { + throw new Error('PII detected entities exceed the validation response size limit') + } + } +} + +function assertValidationResultBudget(result: PIIValidationResult): PIIValidationResult { + if ( + result.maskedText !== undefined && + result.maskedText.length > MAX_PII_VALIDATION_TEXT_CHARACTERS + ) { + throw new Error('PII masked text exceeds the validation response size limit') + } + const responseBytes = Buffer.byteLength(JSON.stringify(result), 'utf8') + if (responseBytes > MAX_PII_VALIDATION_RESPONSE_BYTES) { + throw new Error('PII validation result exceeds the response size limit') + } + return result +} + /** * Detect PII spans via the Presidio analyzer. An empty `entityTypes` ⇒ detect all. * Throws on transport/HTTP failure so callers can apply their own fail-safe. @@ -109,10 +191,19 @@ async function analyze( signal, }) if (!response.ok) { - const detail = await response.text().catch(() => '') + const detail = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'PII analyzer error response', + signal, + }).catch(() => '') throw new Error(`Presidio analyze failed (${response.status}): ${detail.slice(0, 200)}`) } - return (await response.json()) as AnalyzerSpan[] + const result = await readResponseJsonWithLimit(response, { + maxBytes: MAX_PII_VALIDATION_RESPONSE_BYTES, + label: 'PII analyzer response', + signal, + }) + return parseAnalyzerSpans(result, text.length) } /** @@ -251,11 +342,26 @@ async function anonymize( signal, }) if (!response.ok) { - const detail = await response.text().catch(() => '') + const detail = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'PII anonymizer error response', + signal, + }).catch(() => '') throw new Error(`Presidio anonymize failed (${response.status}): ${detail.slice(0, 200)}`) } - const data = (await response.json()) as { text: string } - return data.text + const data = await readResponseJsonWithLimit(response, { + maxBytes: MAX_PII_VALIDATION_RESPONSE_BYTES, + label: 'PII anonymizer response', + signal, + }) + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('PII anonymizer returned an invalid result') + } + const maskedText = (data as Record).text + if (typeof maskedText !== 'string') { + throw new Error('PII anonymizer returned an invalid result') + } + return maskedText } /** @@ -279,6 +385,7 @@ export async function validatePII(input: PIIValidationInput): Promise ({ type: displayEntityType(s.entity_type, customPatterns), @@ -290,7 +397,11 @@ export async function validatePII(input: PIIValidationInput): Promise` (or the @@ -315,13 +430,14 @@ export async function validatePII(input: PIIValidationInput): Promise ({ vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockToken })) +import { MAX_PII_VALIDATION_RESPONSE_BYTES } from '@/lib/guardrails/pii-limits' import { validatePIIViaHttp } from '@/lib/guardrails/validation-client' describe('validatePIIViaHttp', () => { @@ -77,4 +78,17 @@ describe('validatePIIViaHttp', () => { ).rejects.toThrow() expect(fetchMock).toHaveBeenCalledOnce() }) + + it('fails before parsing an oversized success body', async () => { + fetchMock.mockResolvedValueOnce( + new Response('{"passed":true}', { + headers: { 'content-length': String(MAX_PII_VALIDATION_RESPONSE_BYTES + 1) }, + }) + ) + + await expect( + validatePIIViaHttp({ text: 'claim', entityTypes: [], mode: 'block' }) + ).rejects.toThrow('PII validation response exceeds maximum size') + expect(fetchMock).toHaveBeenCalledOnce() + }) }) diff --git a/apps/sim/lib/guardrails/validation-client.ts b/apps/sim/lib/guardrails/validation-client.ts index 0bca6bbb0a0..51e2a8c0f3b 100644 --- a/apps/sim/lib/guardrails/validation-client.ts +++ b/apps/sim/lib/guardrails/validation-client.ts @@ -5,7 +5,13 @@ import { guardrailsPiiValidateResponseSchema, } from '@/lib/api/contracts' import { generateInternalToken } from '@/lib/auth/internal' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + readResponseJsonWithLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { MAX_PII_VALIDATION_RESPONSE_BYTES } from '@/lib/guardrails/pii-limits' /** * Validates one string through the app-container PII capability boundary. @@ -35,9 +41,18 @@ export async function validatePIIViaHttp( }) if (!response.ok) { - const detail = await response.text().catch(() => '') + const detail = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'PII validation error response', + signal, + }).catch(() => '') throw new Error(`PII validation request failed (${response.status}): ${detail.slice(0, 200)}`) } - return guardrailsPiiValidateResponseSchema.parse(await response.json()) + const result = await readResponseJsonWithLimit(response, { + maxBytes: MAX_PII_VALIDATION_RESPONSE_BYTES, + label: 'PII validation response', + signal, + }) + return guardrailsPiiValidateResponseSchema.parse(result) } diff --git a/apps/sim/lib/internal/guardrails/operations.test.ts b/apps/sim/lib/internal/guardrails/operations.test.ts index bde1a0b1315..b60fc466e6d 100644 --- a/apps/sim/lib/internal/guardrails/operations.test.ts +++ b/apps/sim/lib/internal/guardrails/operations.test.ts @@ -180,6 +180,30 @@ describe('executeGuardrailsValidation', () => { ) }) + it('preserves PII verdict metadata when the capability fails', async () => { + mocks.validatePIIViaHttp.mockRejectedValueOnce(new Error('capability unavailable')) + + const result = await executeGuardrailsValidation( + { + validationType: 'pii', + input: 'email a@b.com', + }, + { + actorUserId: 'user-1', + headers: new Headers(), + requestId: 'request-1', + } + ) + + expect(result.output).toMatchObject({ + passed: false, + validationType: 'pii', + input: 'email a@b.com', + error: 'PII validation failed: capability unavailable', + detectedEntities: [], + }) + }) + it('conceals inaccessible workflow validation as a failed verdict without provider work', async () => { workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ allowed: false, diff --git a/apps/sim/lib/internal/guardrails/operations.ts b/apps/sim/lib/internal/guardrails/operations.ts index 0c11654d3ff..79f67f463c0 100644 --- a/apps/sim/lib/internal/guardrails/operations.ts +++ b/apps/sim/lib/internal/guardrails/operations.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' import { authorizeCredentialUseForAuth } from '@/lib/auth/credential-access' import { AuthType } from '@/lib/auth/hybrid' import { @@ -249,16 +250,25 @@ async function executeValidation( }) } if (input.validationType === 'pii') { - return validatePIIViaHttp( - { - text: inputString, - entityTypes: input.piiEntityTypes || [], - mode: input.piiMode === 'mask' ? 'mask' : 'block', - language: input.piiLanguage || 'en', - customPatterns: input.piiCustomPatterns, - }, - context.signal - ) + try { + return await validatePIIViaHttp( + { + text: inputString, + entityTypes: input.piiEntityTypes || [], + mode: input.piiMode === 'mask' ? 'mask' : 'block', + language: input.piiLanguage || 'en', + customPatterns: input.piiCustomPatterns, + }, + context.signal + ) + } catch (error) { + if (isAbortError(error) || context.signal?.aborted) throw error + return { + passed: false, + error: `PII validation failed: ${truncate(getErrorMessage(error), 950)}`, + detectedEntities: [], + } + } } return { passed: false, error: 'Unknown validation type' } }