Skip to content

Commit ac03689

Browse files
fix(guardrails): route PII validation through app runtime
1 parent 59b3f37 commit ac03689

7 files changed

Lines changed: 319 additions & 14 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest, hybridAuthMockFns } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockValidatePII } = vi.hoisted(() => ({
8+
mockValidatePII: vi.fn(),
9+
}))
10+
11+
vi.mock('@/lib/guardrails/validate_pii', () => ({
12+
validatePII: mockValidatePII,
13+
}))
14+
15+
import { POST } from '@/app/api/guardrails/pii/validate/route'
16+
17+
describe('POST /api/guardrails/pii/validate', () => {
18+
beforeEach(() => {
19+
vi.clearAllMocks()
20+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ success: true })
21+
mockValidatePII.mockResolvedValue({ passed: true, detectedEntities: [] })
22+
})
23+
24+
it('authenticates before validating the request body', async () => {
25+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
26+
success: false,
27+
error: 'Internal authentication required',
28+
})
29+
30+
const response = await POST(createMockRequest('POST', { text: 42 }))
31+
32+
expect(response.status).toBe(401)
33+
expect(mockValidatePII).not.toHaveBeenCalled()
34+
})
35+
36+
it('runs Presidio validation inside the app boundary', async () => {
37+
const request = createMockRequest('POST', {
38+
text: 'email a@b.com',
39+
entityTypes: ['EMAIL_ADDRESS'],
40+
mode: 'mask',
41+
language: 'en',
42+
})
43+
44+
const response = await POST(request)
45+
46+
expect(response.status).toBe(200)
47+
await expect(response.json()).resolves.toEqual({ passed: true, detectedEntities: [] })
48+
expect(mockValidatePII).toHaveBeenCalledWith({
49+
text: 'email a@b.com',
50+
entityTypes: ['EMAIL_ADDRESS'],
51+
mode: 'mask',
52+
language: 'en',
53+
customPatterns: undefined,
54+
requestId: 'mock-request-id',
55+
abortSignal: request.signal,
56+
})
57+
})
58+
59+
it('rejects malformed input before calling Presidio', async () => {
60+
const response = await POST(
61+
createMockRequest('POST', { text: 'claim', entityTypes: [], mode: 'invalid' })
62+
)
63+
64+
expect(response.status).toBe(400)
65+
expect(mockValidatePII).not.toHaveBeenCalled()
66+
})
67+
})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { guardrailsPiiValidateContract } from '@/lib/api/contracts'
3+
import { parseRequest } from '@/lib/api/server'
4+
import { checkInternalAuth } from '@/lib/auth/hybrid'
5+
import { generateRequestId } from '@/lib/core/utils/request'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { validatePII } from '@/lib/guardrails/validate_pii'
8+
9+
/**
10+
* App-container capability boundary for single-text PII validation. Presidio is
11+
* intentionally ECS-internal, so remote workflow runtimes authenticate here
12+
* instead of importing its client and attempting to reach `PII_URL` directly.
13+
*/
14+
export const POST = withRouteHandler(async (request: NextRequest) => {
15+
const auth = await checkInternalAuth(request, { requireWorkflowId: false })
16+
if (!auth.success) {
17+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
18+
}
19+
20+
const parsed = await parseRequest(guardrailsPiiValidateContract, request, {})
21+
if (!parsed.success) return parsed.response
22+
23+
const { text, entityTypes, mode, language, customPatterns } = parsed.data.body
24+
const result = await validatePII({
25+
text,
26+
entityTypes,
27+
mode,
28+
language,
29+
customPatterns,
30+
requestId: generateRequestId(),
31+
abortSignal: request.signal,
32+
})
33+
34+
return NextResponse.json(guardrailsPiiValidateContract.response.schema.parse(result))
35+
})

apps/sim/lib/api/contracts/hotspots.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,35 @@ const guardrailsMaskBatchResponseSchema = z.object({
2020
masked: z.array(z.string()),
2121
})
2222

23+
export const guardrailsPiiValidateBodySchema = z
24+
.object({
25+
text: z.string().max(10_000_000, 'Text is too long'),
26+
entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(200),
27+
mode: z.enum(['block', 'mask']),
28+
language: z.string().min(1, 'Language cannot be empty').max(20).optional(),
29+
customPatterns: z.array(customPatternSchema).max(20).optional(),
30+
})
31+
.strict()
32+
33+
export const detectedPiiEntitySchema = z
34+
.object({
35+
type: z.string().min(1, 'Entity type cannot be empty').max(100),
36+
start: z.number().int().nonnegative(),
37+
end: z.number().int().nonnegative(),
38+
score: z.number().min(0).max(1),
39+
text: z.string().max(10_000_000, 'Detected text is too long'),
40+
})
41+
.strict()
42+
43+
export const guardrailsPiiValidateResponseSchema = z
44+
.object({
45+
passed: z.boolean(),
46+
error: z.string().max(1_000).optional(),
47+
detectedEntities: z.array(detectedPiiEntitySchema),
48+
maskedText: z.string().max(10_000_000, 'Masked text is too long').optional(),
49+
})
50+
.strict()
51+
2352
/**
2453
* Internal batch PII masking. Called server-to-server (internal JWT) from the
2554
* log-redaction persist path so Presidio always runs in the app container,
@@ -38,6 +67,23 @@ export const guardrailsMaskBatchContract = defineRouteContract({
3867
export type GuardrailsMaskBatchBody = z.input<typeof guardrailsMaskBatchBodySchema>
3968
export type GuardrailsMaskBatchResult = z.output<typeof guardrailsMaskBatchResponseSchema>
4069

70+
/**
71+
* Internal single-text PII validation. The workflow executor can run outside
72+
* the app network, while only the app task can reach the Presidio service.
73+
*/
74+
export const guardrailsPiiValidateContract = defineRouteContract({
75+
method: 'POST',
76+
path: '/api/guardrails/pii/validate',
77+
body: guardrailsPiiValidateBodySchema,
78+
response: {
79+
mode: 'json',
80+
schema: guardrailsPiiValidateResponseSchema,
81+
},
82+
})
83+
84+
export type GuardrailsPiiValidateBody = z.input<typeof guardrailsPiiValidateBodySchema>
85+
export type GuardrailsPiiValidateResult = z.output<typeof guardrailsPiiValidateResponseSchema>
86+
4187
const chatMessageSchema = z.object({
4288
role: z.enum(['user', 'assistant', 'system']),
4389
content: z.string(),
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { resetUrlsMock, urlsMockFns } from '@sim/testing'
5+
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
afterAll(resetUrlsMock)
8+
9+
const { mockToken } = vi.hoisted(() => ({
10+
mockToken: vi.fn(),
11+
}))
12+
13+
vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockToken }))
14+
15+
import { validatePIIViaHttp } from '@/lib/guardrails/validation-client'
16+
17+
describe('validatePIIViaHttp', () => {
18+
const mockBaseUrl = urlsMockFns.mockGetInternalApiBaseUrl
19+
let fetchMock: ReturnType<typeof vi.fn>
20+
21+
beforeEach(() => {
22+
vi.clearAllMocks()
23+
mockToken.mockResolvedValue('internal-token')
24+
mockBaseUrl.mockReturnValue('https://app.example.com')
25+
fetchMock = vi.fn(async () =>
26+
Response.json({ passed: true, detectedEntities: [], maskedText: 'clean' })
27+
)
28+
vi.stubGlobal('fetch', fetchMock)
29+
})
30+
31+
afterEach(() => {
32+
vi.unstubAllGlobals()
33+
})
34+
35+
it('calls the authenticated app capability with the caller signal', async () => {
36+
const controller = new AbortController()
37+
38+
await expect(
39+
validatePIIViaHttp(
40+
{
41+
text: 'clean',
42+
entityTypes: ['EMAIL_ADDRESS'],
43+
mode: 'mask',
44+
language: 'en',
45+
},
46+
controller.signal
47+
)
48+
).resolves.toEqual({ passed: true, detectedEntities: [], maskedText: 'clean' })
49+
50+
expect(fetchMock).toHaveBeenCalledWith(
51+
'https://app.example.com/api/guardrails/pii/validate',
52+
expect.objectContaining({
53+
method: 'POST',
54+
headers: {
55+
'content-type': 'application/json',
56+
authorization: 'Bearer internal-token',
57+
},
58+
signal: controller.signal,
59+
})
60+
)
61+
})
62+
63+
it('fails on an HTTP error without retrying', async () => {
64+
fetchMock.mockResolvedValueOnce(new Response('unavailable', { status: 503 }))
65+
66+
await expect(
67+
validatePIIViaHttp({ text: 'claim', entityTypes: [], mode: 'block' })
68+
).rejects.toThrow('PII validation request failed (503): unavailable')
69+
expect(fetchMock).toHaveBeenCalledOnce()
70+
})
71+
72+
it('fails when the endpoint returns an invalid success body', async () => {
73+
fetchMock.mockResolvedValueOnce(Response.json({ passed: true }))
74+
75+
await expect(
76+
validatePIIViaHttp({ text: 'claim', entityTypes: [], mode: 'block' })
77+
).rejects.toThrow()
78+
expect(fetchMock).toHaveBeenCalledOnce()
79+
})
80+
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { GuardrailsPiiValidateBody, GuardrailsPiiValidateResult } from '@/lib/api/contracts'
2+
import {
3+
guardrailsPiiValidateBodySchema,
4+
guardrailsPiiValidateContract,
5+
guardrailsPiiValidateResponseSchema,
6+
} from '@/lib/api/contracts'
7+
import { generateInternalToken } from '@/lib/auth/internal'
8+
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
9+
10+
/**
11+
* Validates one string through the app-container PII capability boundary.
12+
*
13+
* Workflow tool operations execute both in the app task and in Trigger.dev
14+
* workers, but only the app network can reach the ECS-internal Presidio
15+
* service. Always using this boundary keeps manual and scheduled verdicts on
16+
* one path and prevents the worker bundle from importing the Presidio client.
17+
*/
18+
export async function validatePIIViaHttp(
19+
input: GuardrailsPiiValidateBody,
20+
signal?: AbortSignal
21+
): Promise<GuardrailsPiiValidateResult> {
22+
const body = guardrailsPiiValidateBodySchema.parse(input)
23+
const token = await generateInternalToken()
24+
const url = `${getInternalApiBaseUrl()}${guardrailsPiiValidateContract.path}`
25+
26+
// boundary-raw-fetch: cross-process capability call to the authenticated app-container PII endpoint
27+
const response = await fetch(url, {
28+
method: 'POST',
29+
headers: {
30+
'content-type': 'application/json',
31+
authorization: `Bearer ${token}`,
32+
},
33+
body: JSON.stringify(body),
34+
signal,
35+
})
36+
37+
if (!response.ok) {
38+
const detail = await response.text().catch(() => '')
39+
throw new Error(`PII validation request failed (${response.status}): ${detail.slice(0, 200)}`)
40+
}
41+
42+
return guardrailsPiiValidateResponseSchema.parse(await response.json())
43+
}

apps/sim/lib/internal/guardrails/operations.test.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const mocks = vi.hoisted(() => ({
1919
requireBillingAttribution: vi.fn(),
2020
validateHallucination: vi.fn(),
2121
validateJson: vi.fn(),
22-
validatePII: vi.fn(),
22+
validatePIIViaHttp: vi.fn(),
2323
validateRegex: vi.fn(),
2424
}))
2525

@@ -41,8 +41,10 @@ vi.mock('@/lib/guardrails/validate_hallucination', () => ({
4141
validateHallucination: mocks.validateHallucination,
4242
}))
4343
vi.mock('@/lib/guardrails/validate_json', () => ({ validateJson: mocks.validateJson }))
44-
vi.mock('@/lib/guardrails/validate_pii', () => ({ validatePII: mocks.validatePII }))
4544
vi.mock('@/lib/guardrails/validate_regex', () => ({ validateRegex: mocks.validateRegex }))
45+
vi.mock('@/lib/guardrails/validation-client', () => ({
46+
validatePIIViaHttp: mocks.validatePIIViaHttp,
47+
}))
4648
vi.mock('@/ee/access-control/utils/permission-check', () => ({
4749
assertPermissionsAllowed: mocks.assertPermissionsAllowed,
4850
ModelNotAllowedError: class ModelNotAllowedError extends Error {},
@@ -78,7 +80,7 @@ describe('executeGuardrailsValidation', () => {
7880
mocks.validateHallucination.mockResolvedValue({ passed: true, score: 8 })
7981
mocks.validateJson.mockReturnValue({ passed: true })
8082
mocks.validateRegex.mockReturnValue({ passed: true })
81-
mocks.validatePII.mockResolvedValue({ passed: true, detectedEntities: [] })
83+
mocks.validatePIIViaHttp.mockResolvedValue({ passed: true, detectedEntities: [] })
8284
})
8385

8486
it('runs hallucination work once with authorized scope, billing, provenance, and signal', async () => {
@@ -146,6 +148,38 @@ describe('executeGuardrailsValidation', () => {
146148
expect(mocks.requireBillingAttribution).not.toHaveBeenCalled()
147149
})
148150

151+
it('routes PII validation through the app-container capability boundary', async () => {
152+
const controller = new AbortController()
153+
154+
const result = await executeGuardrailsValidation(
155+
{
156+
validationType: 'pii',
157+
input: 'email a@b.com',
158+
piiEntityTypes: ['EMAIL_ADDRESS'],
159+
piiMode: 'mask',
160+
piiLanguage: 'en',
161+
},
162+
{
163+
actorUserId: 'user-1',
164+
headers: new Headers(),
165+
requestId: 'request-1',
166+
signal: controller.signal,
167+
}
168+
)
169+
170+
expect(result.output.passed).toBe(true)
171+
expect(mocks.validatePIIViaHttp).toHaveBeenCalledWith(
172+
{
173+
text: 'email a@b.com',
174+
entityTypes: ['EMAIL_ADDRESS'],
175+
mode: 'mask',
176+
language: 'en',
177+
customPatterns: undefined,
178+
},
179+
controller.signal
180+
)
181+
})
182+
149183
it('conceals inaccessible workflow validation as a failed verdict without provider work', async () => {
150184
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
151185
allowed: false,

apps/sim/lib/internal/guardrails/operations.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@ import {
1212
import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing'
1313
import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context'
1414
import { inspectModelInputProvenanceRequest } from '@/lib/execution/model-input-provenance'
15-
import type { CustomPiiPattern } from '@/lib/guardrails/pii-entities'
1615
import { validateHallucination } from '@/lib/guardrails/validate_hallucination'
1716
import { validateJson } from '@/lib/guardrails/validate_json'
18-
import { validatePII } from '@/lib/guardrails/validate_pii'
1917
import { validateRegex } from '@/lib/guardrails/validate_regex'
18+
import { validatePIIViaHttp } from '@/lib/guardrails/validation-client'
2019
import { GuardrailsOperationError } from '@/lib/internal/guardrails/errors'
2120
import type { GuardrailsValidationInput } from '@/lib/internal/guardrails/input'
2221
import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types'
@@ -250,15 +249,16 @@ async function executeValidation(
250249
})
251250
}
252251
if (input.validationType === 'pii') {
253-
return validatePII({
254-
text: inputString,
255-
entityTypes: input.piiEntityTypes || [],
256-
mode: input.piiMode === 'mask' ? 'mask' : 'block',
257-
language: input.piiLanguage || 'en',
258-
customPatterns: input.piiCustomPatterns as CustomPiiPattern[] | undefined,
259-
requestId: context.requestId,
260-
abortSignal: context.signal,
261-
})
252+
return validatePIIViaHttp(
253+
{
254+
text: inputString,
255+
entityTypes: input.piiEntityTypes || [],
256+
mode: input.piiMode === 'mask' ? 'mask' : 'block',
257+
language: input.piiLanguage || 'en',
258+
customPatterns: input.piiCustomPatterns,
259+
},
260+
context.signal
261+
)
262262
}
263263
return { passed: false, error: 'Unknown validation type' }
264264
}

0 commit comments

Comments
 (0)