Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cb28b11
feat(lambda): add AWS Lambda integration with 50 operations (#7216)
waleedlatif1 Aug 28, 2026
9f594c9
improvement(tools): retire direct execution (#7207)
icecrasher321 Aug 28, 2026
6818425
fix(pricing): correct free-tier credits as one-time, not monthly (#7218)
TheodoreSpeaks Aug 28, 2026
b094b4d
improvement(usage): add a period-labelled chart to the workspace dril…
icecrasher321 Aug 28, 2026
60cb6f5
feat(observability): record Redis connection state on failed slot ope…
waleedlatif1 Aug 28, 2026
eadf30a
fix(enterprise): verify invited owner on acceptance (#7221)
icecrasher321 Aug 28, 2026
03a76b8
fix(workflow): stop requiring a human subject on actorless runs (#7220)
TheodoreSpeaks Aug 28, 2026
0f3d5e5
fix(canvas): show full Agent prompt tooltips (#6961)
BillLeoutsakosvl346 Aug 28, 2026
cc5affa
feat(integrations): add Dynamics 365 CRM (#6714)
BillLeoutsakosvl346 Aug 28, 2026
2bda859
fix(logs): prevent dashboard horizontal scrolling (#7223)
waleedlatif1 Aug 28, 2026
59b3f37
improvement(ui): consolidate fade-only overflow (#7225)
waleedlatif1 Aug 28, 2026
836b87f
fix(knowledge): classify rejected BYOK embedding keys (#7226)
waleedlatif1 Aug 28, 2026
34a5300
feat(secrets): reveal visible values to members (#7231)
icecrasher321 Aug 28, 2026
8a31757
fix(execution): treat an undetermined lease as a fallback, not a deni…
waleedlatif1 Aug 28, 2026
1ade0f4
fix(guardrails): route PII validation through app runtime (#7227)
TheodoreSpeaks Aug 28, 2026
acc4f1d
fix(executor): preserve actors for actorless tool calls (#7230)
TheodoreSpeaks Aug 28, 2026
d90e5ed
improvement(ui): soften overflow fade (#7234)
waleedlatif1 Aug 28, 2026
4bb46be
chore(skills): name directExecution as retired in the add-integration…
waleedlatif1 Aug 28, 2026
e9c343d
fix(ui): prevent competing sidebar tooltips (#7235)
waleedlatif1 Aug 28, 2026
6ca5b52
fix(execution): release the lease when abort races an undetermined ac…
waleedlatif1 Aug 28, 2026
44a3e44
fix(dataverse): harden OAuth connection preflight (#7238)
waleedlatif1 Aug 28, 2026
128e9d2
chore(docs): refresh introduction screenshot (#7239)
waleedlatif1 Aug 28, 2026
60776ba
feat(credential-groups): add seventeen OAuth providers (#7201)
TheodoreSpeaks Aug 28, 2026
e4c0a9f
docs(library): update best-ai-agent-builder-2026 (#7242)
icecrasher321 Aug 29, 2026
4aa1c04
fix(lambda): close the remaining contract validation gaps (#7241)
waleedlatif1 Aug 29, 2026
a38492b
fix(integrations): validate runtime values the type system only claim…
waleedlatif1 Aug 29, 2026
d673548
feat(library): Best AI Agents for Slack (#7245)
icecrasher321 Aug 29, 2026
3077553
fix(secrets): preserve caret when revealing values (#7246)
waleedlatif1 Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(guardrails): route PII validation through app runtime (#7227)
* fix(guardrails): route PII validation through app runtime

* fix(guardrails): bound PII validation results
  • Loading branch information
TheodoreSpeaks authored Aug 28, 2026
commit 1ade0f4b96162a355b9269cdead66c7897ec9bd8
67 changes: 67 additions & 0 deletions apps/sim/app/api/guardrails/pii/validate/route.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
35 changes: 35 additions & 0 deletions apps/sim/app/api/guardrails/pii/validate/route.ts
Original file line number Diff line number Diff line change
@@ -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))
})
53 changes: 53 additions & 0 deletions apps/sim/lib/api/contracts/hotspots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -20,6 +24,38 @@ const guardrailsMaskBatchResponseSchema = z.object({
masked: z.array(z.string()),
})

export const guardrailsPiiValidateBodySchema = z
.object({
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(),
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(MAX_PII_VALIDATION_TEXT_CHARACTERS, '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).max(MAX_PII_VALIDATION_DETECTED_ENTITIES),
maskedText: z
.string()
.max(MAX_PII_VALIDATION_TEXT_CHARACTERS, '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,
Expand All @@ -38,6 +74,23 @@ export const guardrailsMaskBatchContract = defineRouteContract({
export type GuardrailsMaskBatchBody = z.input<typeof guardrailsMaskBatchBodySchema>
export type GuardrailsMaskBatchResult = z.output<typeof guardrailsMaskBatchResponseSchema>

/**
* 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<typeof guardrailsPiiValidateBodySchema>
export type GuardrailsPiiValidateResult = z.output<typeof guardrailsPiiValidateResponseSchema>

const chatMessageSchema = z.object({
role: z.enum(['user', 'assistant', 'system']),
content: z.string(),
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/lib/guardrails/pii-limits.ts
Original file line number Diff line number Diff line change
@@ -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
72 changes: 72 additions & 0 deletions apps/sim/lib/guardrails/validate_pii.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
})
})
})
Loading
Loading