chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
# Guardrails Validators
Validation scripts for the Guardrails block.
## Validators
- **JSON Validation** - Validates if content is valid JSON (TypeScript)
- **Regex Validation** - Validates content against regex patterns (TypeScript)
- **Hallucination Detection** - Validates LLM output against knowledge base using RAG + LLM scoring (TypeScript)
- **PII Detection** - Detects personally identifiable information using Microsoft Presidio (Python)
## Setup
### TypeScript Validators (JSON, Regex, Hallucination)
No additional setup required! These validators work out of the box.
For **hallucination detection**, you'll need:
- A knowledge base with documents
- An LLM provider API key (or use hosted models)
### PII Detection (Presidio service)
PII detection runs against a **standalone Presidio service** — a combined analyzer + anonymizer
(built from `docker/pii.Dockerfile`, source in `apps/pii/server.py`) that constructs a warm
`AnalyzerEngine` + `AnonymizerEngine` once and exposes `/analyze`, `/anonymize`, and `/health` on a
single port. In deployment it is its **own ECS service** (a dedicated task/service, not a sidecar in
the app task), reached over the network via `PII_URL` and scaled independently of the app. The app
(both the Next.js server and the trigger.dev runtime) is a thin HTTP client (`validate_pii.ts`) — no
Python, no local venv.
Locally, build and run it as a container:
```bash
docker build -f docker/pii.Dockerfile -t sim-pii .
docker run -d -p 5001:5001 sim-pii
```
Point the app at it with `PII_URL`:
- **Local**: `PII_URL=http://localhost:5001` (the default)
- **Deployed**: `PII_URL` points to the Presidio ECS service's internal endpoint (service-discovery
DNS / internal load balancer) — never `localhost`, since the service runs in a separate task
The image bakes in the recognizers itself — a check-digit-validated **VIN** recognizer and
multi-language NLP models (en/es/it/pl/fi). The redaction language is configured per rule (Data
Retention) and defaults to English.
> **Deploy requirement:** the execution-altering redaction stages (workflow input + block outputs)
> fail-fast and abort a run if the Presidio service is unreachable. Every environment that can run
> workflows must have a reachable Presidio service at `PII_URL`.
## Usage
### JSON & Regex Validation
These are implemented in TypeScript and work out of the box - no additional dependencies needed.
### Hallucination Detection
The hallucination detector uses a modern RAG + LLM confidence scoring approach:
1. **RAG Query** - Calls the knowledge base search API to retrieve relevant chunks
2. **LLM Confidence Scoring** - Uses an LLM to score how well the user input is supported by the retrieved context on a 0-10 confidence scale:
- 0-2: Full hallucination - completely unsupported by context, contradicts the context
- 3-4: Low confidence - mostly unsupported, significant claims not in context
- 5-6: Medium confidence - partially supported, some claims not in context
- 7-8: High confidence - mostly supported, minor details not in context
- 9-10: Very high confidence - fully supported by context, all claims verified
3. **Threshold Check** - Compares the confidence score against your threshold (default: 3)
4. **Result** - Returns `passed: true/false` with confidence score and reasoning
**Configuration:**
- `knowledgeBaseId` (required): Select from dropdown of available knowledge bases
- `threshold` (optional): Confidence threshold 0-10, default 3 (scores below 3 fail)
- `topK` (optional): Number of chunks to retrieve, default 10
- `model` (required): Select from dropdown of available LLM models, default `gpt-4o-mini`
- `apiKey` (conditional): API key for the LLM provider (hidden for hosted models and Ollama)
### PII Detection
The PII detector uses Microsoft Presidio to identify personally identifiable information:
1. **Analysis** - Scans text for PII entities using pattern matching, NER, and context
2. **Detection** - Identifies PII types like names, emails, phone numbers, SSNs, credit cards, etc.
3. **Action** - Either blocks the request or masks the PII based on mode
**Modes:**
- **Block Mode** (default): Fails validation if any PII is detected
- **Mask Mode**: Passes validation and returns text with PII replaced by `<ENTITY_TYPE>` placeholders
**Configuration:**
- `piiEntityTypes` (optional): Array of PII types to detect (empty = detect all)
- `piiMode` (optional): `block` or `mask`, default `block`
- `piiLanguage` (optional): Language code, default `en`
**Supported PII Types:**
- **Common**: Person name, Email, Phone, Credit card, Location, IP address, Date/time, URL
- **USA**: SSN, Passport, Driver license, Bank account, ITIN
- **UK**: NHS number, National Insurance Number
- **Other**: Spanish NIF/NIE, Italian fiscal code, Polish PESEL, Singapore NRIC, Australian ABN/TFN, Indian Aadhaar/PAN, and more
See [Presidio documentation](https://microsoft.github.io/presidio/supported_entities/) for full list.
## Files
- `validate_json.ts` - JSON validation (TypeScript)
- `validate_regex.ts` - Regex validation (TypeScript)
- `validate_hallucination.ts` - Hallucination detection with RAG + LLM scoring (TypeScript)
- `validate_pii.ts` - PII detection client: calls the Presidio service's /analyze + /anonymize (TypeScript)
- `pii-entities.ts` - Client-safe PII entity + language catalog (shared by the block and Data Retention)
- `mask-client.ts` - Internal HTTP client for batch PII masking from the log-redaction persist path
- `validate.test.ts` - Test suite for JSON and regex validators
@@ -0,0 +1,67 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockToken, mockBaseUrl } = vi.hoisted(() => ({
mockToken: vi.fn(),
mockBaseUrl: vi.fn(),
}))
vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockToken }))
vi.mock('@/lib/core/utils/urls', () => ({ getInternalApiBaseUrl: mockBaseUrl }))
import { maskPIIBatchViaHttp } from '@/lib/guardrails/mask-client'
describe('maskPIIBatchViaHttp', () => {
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockToken.mockResolvedValue('tok')
mockBaseUrl.mockReturnValue('http://app.internal:3000')
fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
const { texts } = JSON.parse(init.body) as { texts: string[] }
return new Response(JSON.stringify({ masked: texts.map((t) => `M(${t})`) }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
})
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('masks a small batch in a single request', async () => {
const out = await maskPIIBatchViaHttp(['a', 'b', 'c'], ['EMAIL_ADDRESS'])
expect(out).toEqual(['M(a)', 'M(b)', 'M(c)'])
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('splits by count into multiple requests, preserving global order', async () => {
const texts = Array.from({ length: 5000 }, (_, i) => `t${i}`)
const out = await maskPIIBatchViaHttp(texts, [])
expect(out).toHaveLength(5000)
expect(out[0]).toBe('M(t0)')
expect(out[4999]).toBe('M(t4999)')
expect(fetchMock).toHaveBeenCalledTimes(3) // 2000-per-request cap
})
it('throws on a non-2xx response so the caller can scrub', async () => {
fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 }))
await expect(maskPIIBatchViaHttp(['a'], [])).rejects.toThrow(/mask-batch request failed/)
})
it('returns [] without any request for empty input', async () => {
const out = await maskPIIBatchViaHttp([], [])
expect(out).toEqual([])
expect(fetchMock).not.toHaveBeenCalled()
})
})
+88
View File
@@ -0,0 +1,88 @@
import type { GuardrailsMaskBatchResult } from '@/lib/api/contracts'
import { generateInternalToken } from '@/lib/auth/internal'
import { env } from '@/lib/core/config/env'
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { chunkIndicesByBudget } from '@/lib/guardrails/pii-batching'
/**
* Max in-flight mask-batch requests per call. Each request is a CPU-heavy NER
* batch — default 64, sized to saturate the load-balanced Presidio fleet behind
* the internal ALB (which spreads each request across tasks). Effective throughput
* is capped by fleet worker capacity, so past that this just queues; tune via
* `PII_MASK_CHUNK_CONCURRENCY` to the fleet size (and lower to 1 for a single
* self-hosted instance). No request timeout: masking a large batch is slow and the
* (scaled) Presidio service is expected to eventually respond; an unreachable
* service still rejects fast (connection refused) so the caller scrubs.
*/
const CHUNK_CONCURRENCY = env.PII_MASK_CHUNK_CONCURRENCY ?? 64
/**
* Mask PII across many strings via the internal app-container endpoint.
*
* Only the app task reaches the Presidio service (it holds `PII_URL`), but the
* log-redaction persist path also runs inside the trigger.dev runtime — so
* redaction always routes through HTTP, the same way the guardrails tool does.
* Strings are grouped into byte/count-budgeted chunks (keeping each request far
* under the 10MB Next body limit) and the chunks are sent with bounded
* concurrency, so a large payload fans out rather than serializing; order is
* preserved, so the returned array matches `texts` length.
*
* Rejects on any non-2xx, timeout, or shape mismatch so the caller can apply
* its own fail-safe (scrubbing rather than leaking).
*/
export async function maskPIIBatchViaHttp(
texts: string[],
entityTypes: string[],
language?: string
): Promise<string[]> {
if (texts.length === 0) return []
const url = `${getInternalApiBaseUrl()}/api/guardrails/mask-batch`
const masked = new Array<string>(texts.length)
await mapWithConcurrency(chunkIndicesByBudget(texts), CHUNK_CONCURRENCY, async (indices) => {
const chunk = indices.map((i) => texts[i])
const out = await postChunk(url, chunk, entityTypes, language)
if (out.length !== chunk.length) {
throw new Error('PII mask-batch returned an unexpected result')
}
indices.forEach((originalIndex, k) => {
masked[originalIndex] = out[k]
})
})
return masked
}
async function postChunk(
url: string,
texts: string[],
entityTypes: string[],
language: string | undefined
): Promise<string[]> {
// Mint per request: a single token (5min TTL) can expire mid-batch when a
// large execution fans out into many sequential chunk requests.
const token = await generateInternalToken()
// boundary-raw-fetch: internal server-to-server call to the app container (internal JWT auth, configurable base URL)
const response = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ texts, entityTypes, language }),
})
if (!response.ok) {
const detail = await response.text().catch(() => '')
throw new Error(`PII mask-batch request failed (${response.status}): ${detail.slice(0, 200)}`)
}
const data = (await response.json()) as GuardrailsMaskBatchResult
if (!Array.isArray(data.masked)) {
throw new Error('PII mask-batch returned an unexpected result')
}
return data.masked
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Per-request bounds shared by both Presidio hops: the app→route HTTP call
* (`mask-client`) and the route→service call (`validate_pii`). Keeping a single
* source of truth ensures every request stays far under the 10MB Next body limit
* and small enough for one short spaCy NER pass per Presidio request.
*/
/** Max UTF-8 bytes of text per Presidio request. ~40× under the 10MB Next limit. */
export const PII_REQUEST_MAX_BYTES = 256 * 1024
/** Max strings per request; caps per-item overhead and stays well under the contract's 100k-entry cap. */
export const PII_REQUEST_MAX_COUNT = 2_000
/**
* Group `texts` into chunks of original indices, flushing a chunk when adding the
* next string would exceed {@link PII_REQUEST_MAX_BYTES} or {@link PII_REQUEST_MAX_COUNT}.
* A single string larger than the byte budget still gets its own chunk — strings
* are never dropped, since an unredacted leaf would persist PII. Order is preserved
* across and within chunks.
*/
export function chunkIndicesByBudget(texts: string[]): number[][] {
const chunks: number[][] = []
let current: number[] = []
let bytes = 0
for (let i = 0; i < texts.length; i++) {
const size = Buffer.byteLength(texts[i], 'utf8')
if (
current.length > 0 &&
(current.length >= PII_REQUEST_MAX_COUNT || bytes + size > PII_REQUEST_MAX_BYTES)
) {
chunks.push(current)
current = []
bytes = 0
}
current.push(i)
bytes += size
}
if (current.length > 0) chunks.push(current)
return chunks
}
+341
View File
@@ -0,0 +1,341 @@
/**
* Client-safe catalog of Microsoft Presidio PII entity types. Single source of
* truth shared by the server-only validator (`validate_pii.ts`) and client
* settings UI — keep no node-only imports here.
*/
export const SUPPORTED_PII_ENTITIES = {
// Common/Global
CREDIT_CARD: 'Credit card number',
CRYPTO: 'Cryptocurrency wallet address',
DATE_TIME: 'Date or time',
EMAIL_ADDRESS: 'Email address',
IBAN_CODE: 'International Bank Account Number',
IP_ADDRESS: 'IP address',
NRP: 'Nationality, religious or political group',
LOCATION: 'Location',
PERSON: 'Person name',
PHONE_NUMBER: 'Phone number',
MEDICAL_LICENSE: 'Medical license number',
URL: 'URL',
VIN: 'Vehicle Identification Number',
// USA
US_BANK_NUMBER: 'US bank account number',
US_DRIVER_LICENSE: 'US driver license',
US_ITIN: 'US Individual Taxpayer Identification Number',
US_PASSPORT: 'US passport number',
US_SSN: 'US Social Security Number',
// UK
UK_NHS: 'UK NHS number',
UK_NINO: 'UK National Insurance Number',
// Other countries
ES_NIF: 'Spanish NIF number',
ES_NIE: 'Spanish NIE number',
IT_FISCAL_CODE: 'Italian fiscal code',
IT_DRIVER_LICENSE: 'Italian driver license',
IT_VAT_CODE: 'Italian VAT code',
IT_PASSPORT: 'Italian passport',
IT_IDENTITY_CARD: 'Italian identity card',
PL_PESEL: 'Polish PESEL number',
SG_NRIC_FIN: 'Singapore NRIC/FIN',
SG_UEN: 'Singapore Unique Entity Number',
AU_ABN: 'Australian Business Number',
AU_ACN: 'Australian Company Number',
AU_TFN: 'Australian Tax File Number',
AU_MEDICARE: 'Australian Medicare number',
IN_PAN: 'Indian Permanent Account Number',
IN_AADHAAR: 'Indian Aadhaar number',
IN_VEHICLE_REGISTRATION: 'Indian vehicle registration',
IN_VOTER: 'Indian voter ID',
IN_PASSPORT: 'Indian passport',
FI_PERSONAL_IDENTITY_CODE: 'Finnish Personal Identity Code',
} as const
export type PIIEntityType = keyof typeof SUPPORTED_PII_ENTITIES
/** Flat `{ value, label }` options for entity-type pickers, in catalog order. */
export const PII_ENTITY_OPTIONS: ReadonlyArray<{ value: PIIEntityType; label: string }> =
Object.entries(SUPPORTED_PII_ENTITIES).map(([value, label]) => ({
value: value as PIIEntityType,
label,
}))
/** Entity types grouped by region, for a grouped checkbox picker. */
export const PII_ENTITY_GROUPS: ReadonlyArray<{
label: string
entities: ReadonlyArray<{ value: PIIEntityType; label: string }>
}> = [
{
label: 'Common',
entities: [
'PERSON',
'EMAIL_ADDRESS',
'PHONE_NUMBER',
'CREDIT_CARD',
'IP_ADDRESS',
'LOCATION',
'DATE_TIME',
'URL',
'IBAN_CODE',
'CRYPTO',
'NRP',
'MEDICAL_LICENSE',
'VIN',
],
},
{
label: 'United States',
entities: ['US_SSN', 'US_PASSPORT', 'US_DRIVER_LICENSE', 'US_BANK_NUMBER', 'US_ITIN'],
},
{ label: 'United Kingdom', entities: ['UK_NHS', 'UK_NINO'] },
{
label: 'Other regions',
entities: [
'ES_NIF',
'ES_NIE',
'IT_FISCAL_CODE',
'IT_DRIVER_LICENSE',
'IT_VAT_CODE',
'IT_PASSPORT',
'IT_IDENTITY_CARD',
'PL_PESEL',
'SG_NRIC_FIN',
'SG_UEN',
'AU_ABN',
'AU_ACN',
'AU_TFN',
'AU_MEDICARE',
'IN_PAN',
'IN_AADHAAR',
'IN_VEHICLE_REGISTRATION',
'IN_VOTER',
'IN_PASSPORT',
'FI_PERSONAL_IDENTITY_CODE',
],
},
].map((group) => ({
label: group.label,
entities: group.entities.map((value) => ({
value: value as PIIEntityType,
label: SUPPORTED_PII_ENTITIES[value as PIIEntityType],
})),
}))
/**
* Languages the Presidio image has NLP models for. The analyzer only recognizes a
* language's entities when its model is loaded, so this set must match the image.
*/
export const PII_LANGUAGES = [
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Spanish' },
{ value: 'it', label: 'Italian' },
{ value: 'pl', label: 'Polish' },
{ value: 'fi', label: 'Finnish' },
] as const
export type PIILanguage = (typeof PII_LANGUAGES)[number]['value']
/** Non-empty tuple of language codes for schema/enum use. */
export const PII_LANGUAGE_CODES = PII_LANGUAGES.map((l) => l.value) as [
PIILanguage,
...PIILanguage[],
]
/** Default redaction language when a rule doesn't set one. */
export const DEFAULT_PII_LANGUAGE: PIILanguage = 'en'
/**
* Narrow a loosely-typed (stored/legacy) language to a supported code. Unknown or
* stale values (e.g. a dropped locale) return `undefined` so callers fall back to
* the default rather than forwarding an unsupported language to Presidio.
*/
export function coercePiiLanguage(value: string | undefined): PIILanguage | undefined {
return value && (PII_LANGUAGE_CODES as readonly string[]).includes(value)
? (value as PIILanguage)
: undefined
}
/**
* Entity types every served language recognizes: Presidio's global pattern
* recognizers, the spaCy NER entities (PERSON/LOCATION/NRP), and the native VIN
* recognizer (registered under every language in `apps/pii/server.py`).
*/
const GLOBAL_PII_ENTITIES: readonly PIIEntityType[] = [
'PERSON',
'LOCATION',
'NRP',
'CREDIT_CARD',
'CRYPTO',
'DATE_TIME',
'EMAIL_ADDRESS',
'IBAN_CODE',
'IP_ADDRESS',
'PHONE_NUMBER',
'URL',
'MEDICAL_LICENSE',
'VIN',
]
/**
* Entity types each language recognizes, mirroring the recognizer registration in
* `apps/pii/server.py`: globals + NER + VIN everywhere, plus the locale-specific
* id recognizers under the language they're registered for (US/UK/AU/IN/SG ids
* are English; es/it/pl/fi carry only their own national ids). Keep in sync with
* the image — a stale entry only no-ops (redaction fails safe), it never leaks.
* `/supportedentities` is the authoritative source if this ever needs to go live.
*/
export const PII_ENTITIES_BY_LANGUAGE: Record<PIILanguage, ReadonlySet<PIIEntityType>> = {
en: new Set<PIIEntityType>([
...GLOBAL_PII_ENTITIES,
'US_SSN',
'US_PASSPORT',
'US_DRIVER_LICENSE',
'US_BANK_NUMBER',
'US_ITIN',
'UK_NHS',
'UK_NINO',
'AU_ABN',
'AU_ACN',
'AU_TFN',
'AU_MEDICARE',
'IN_PAN',
'IN_AADHAAR',
'IN_VEHICLE_REGISTRATION',
'IN_VOTER',
'IN_PASSPORT',
'SG_NRIC_FIN',
'SG_UEN',
]),
es: new Set<PIIEntityType>([...GLOBAL_PII_ENTITIES, 'ES_NIF', 'ES_NIE']),
it: new Set<PIIEntityType>([
...GLOBAL_PII_ENTITIES,
'IT_FISCAL_CODE',
'IT_DRIVER_LICENSE',
'IT_VAT_CODE',
'IT_PASSPORT',
'IT_IDENTITY_CARD',
]),
pl: new Set<PIIEntityType>([...GLOBAL_PII_ENTITIES, 'PL_PESEL']),
fi: new Set<PIIEntityType>([...GLOBAL_PII_ENTITIES, 'FI_PERSONAL_IDENTITY_CODE']),
}
/** True when the entity has a recognizer for the given language. */
export function isEntitySupportedForLanguage(
entity: PIIEntityType,
language: PIILanguage
): boolean {
return PII_ENTITIES_BY_LANGUAGE[language].has(entity)
}
/** {@link PII_ENTITY_GROUPS} filtered to entities the language recognizes (empty groups dropped). */
export function getEntityGroupsForLanguage(language: PIILanguage) {
return PII_ENTITY_GROUPS.map((group) => ({
label: group.label,
entities: group.entities.filter((e) => isEntitySupportedForLanguage(e.value, language)),
})).filter((group) => group.entities.length > 0)
}
/** The PII redaction stages, in execution order. */
export const PII_STAGES = ['input', 'blockOutputs', 'logs'] as const
export type PiiStageKey = (typeof PII_STAGES)[number]
/** Per-stage redaction policy. `enabled: false` makes the stage a no-op. */
export interface PiiStagePolicy {
enabled: boolean
entityTypes: string[]
language: PIILanguage
}
export type PiiStages = Record<PiiStageKey, PiiStagePolicy>
/**
* Stage catalog driving the settings UI, in display order (Logs first — the
* safe, observability-only default). The execution-altering caveat for the
* input/blockOutputs stages is folded into their descriptions.
*/
export const PII_STAGE_META: ReadonlyArray<{
key: PiiStageKey
label: string
description: string
}> = [
{
key: 'logs',
label: 'Logs',
description: 'Redact workflow logs when they are persisted.',
},
{
key: 'input',
label: 'Workflow input',
description:
'Redact the workflow input before execution. Data is redacted during runtime and may affect workflow output.',
},
{
key: 'blockOutputs',
label: 'Block outputs',
description:
'Mask every block output before the next block reads it. Data is redacted during runtime and may affect workflow output and execution performance.',
},
]
/** Recognizers that over-redact (loose, no checksum); surfaced as UI guidance. */
export const RISKY_PII_ENTITIES: ReadonlySet<PIIEntityType> = new Set<PIIEntityType>([
'US_SSN',
'US_BANK_NUMBER',
'DATE_TIME',
])
/** A fully-disabled stage policy for new drafts. */
export function emptyStagePolicy(): PiiStagePolicy {
return { enabled: false, entityTypes: [], language: DEFAULT_PII_LANGUAGE }
}
/** A fully-disabled stage set for new drafts. */
export function emptyPiiStages(): PiiStages {
return {
input: emptyStagePolicy(),
blockOutputs: emptyStagePolicy(),
logs: emptyStagePolicy(),
}
}
/**
* Hydrate a stored rule into the per-stage shape. A legacy flat rule (no
* `stages`) becomes `logs` enabled with its entity types, the two new stages
* disabled — exactly its pre-stages behavior.
*/
export function normalizeRuleStages(rule: {
stages?: Partial<Record<PiiStageKey, Partial<PiiStagePolicy> | undefined>>
entityTypes?: string[]
language?: string
}): PiiStages {
const sanitize = (policy: Partial<PiiStagePolicy> | undefined): PiiStagePolicy => ({
enabled: Boolean(policy?.enabled),
entityTypes: Array.isArray(policy?.entityTypes)
? policy.entityTypes.filter((t): t is string => typeof t === 'string')
: [],
language: coercePiiLanguage(policy?.language) ?? DEFAULT_PII_LANGUAGE,
})
if (rule.stages) {
return {
input: sanitize(rule.stages.input),
blockOutputs: sanitize(rule.stages.blockOutputs),
logs: sanitize(rule.stages.logs),
}
}
const entityTypes = Array.isArray(rule.entityTypes)
? rule.entityTypes.filter((t): t is string => typeof t === 'string')
: []
return {
input: emptyStagePolicy(),
blockOutputs: emptyStagePolicy(),
logs: {
enabled: entityTypes.length > 0,
entityTypes,
language: coercePiiLanguage(rule.language) ?? DEFAULT_PII_LANGUAGE,
},
}
}
@@ -0,0 +1,317 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { executeProviderRequest } from '@/providers'
import { getProviderFromModel } from '@/providers/utils'
const logger = createLogger('HallucinationValidator')
export interface HallucinationValidationResult {
passed: boolean
error?: string
score?: number
reasoning?: string
/** Billable LLM cost (dollars) for the scoring call; 0 for BYOK/non-hosted. */
cost?: number
}
export interface HallucinationValidationInput {
userInput: string
knowledgeBaseId: string
threshold: number // 0-10 confidence scale, default 3 (scores below 3 fail)
topK: number // Number of chunks to retrieve, default 10
model: string
apiKey?: string
providerCredentials?: {
azureEndpoint?: string
azureApiVersion?: string
vertexProject?: string
vertexLocation?: string
vertexCredential?: string
bedrockAccessKeyId?: string
bedrockSecretKey?: string
bedrockRegion?: string
}
workflowId?: string
workspaceId?: string
authHeaders?: {
cookie?: string
authorization?: string
}
requestId: string
}
/**
* Query knowledge base to get relevant context chunks using the search API
*/
async function queryKnowledgeBase(
knowledgeBaseId: string,
query: string,
topK: number,
requestId: string,
workflowId?: string,
authHeaders?: { cookie?: string; authorization?: string }
): Promise<string[]> {
try {
// Call the knowledge base search API directly
const searchUrl = `${getInternalApiBaseUrl()}/api/knowledge/search`
const response = await fetch(searchUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(authHeaders?.cookie ? { Cookie: authHeaders.cookie } : {}),
...(authHeaders?.authorization ? { Authorization: authHeaders.authorization } : {}),
},
body: JSON.stringify({
knowledgeBaseIds: [knowledgeBaseId],
query,
topK,
workflowId,
}),
})
if (!response.ok) {
logger.error(`[${requestId}] Knowledge base query failed`, {
status: response.status,
})
return []
}
const result = await response.json()
const results = result.data?.results || []
const chunks = results.map((r: any) => r.content || '').filter((c: string) => c.length > 0)
return chunks
} catch (error: any) {
logger.error(`[${requestId}] Error querying knowledge base`, {
error: error.message,
})
return []
}
}
/**
* Use an LLM to score confidence based on RAG context
* Returns a confidence score from 0-10 where:
* - 0 = full hallucination (completely unsupported)
* - 10 = fully grounded (completely supported)
*/
async function scoreHallucinationWithLLM(
userInput: string,
ragContext: string[],
model: string,
apiKey: string | undefined,
providerCredentials: HallucinationValidationInput['providerCredentials'],
workspaceId: string | undefined,
requestId: string
): Promise<{ score: number; reasoning: string; cost: number }> {
try {
const contextText = ragContext.join('\n\n---\n\n')
const systemPrompt = `You are a confidence scoring system. Your job is to evaluate how well a user's input is supported by the provided reference context from a knowledge base.
Score the input on a confidence scale from 0 to 10:
- 0-2: Full hallucination - completely unsupported by context, contradicts the context
- 3-4: Low confidence - mostly unsupported, significant claims not in context
- 5-6: Medium confidence - partially supported, some claims not in context
- 7-8: High confidence - mostly supported, minor details not in context
- 9-10: Very high confidence - fully supported by context, all claims verified
Respond ONLY with valid JSON in this exact format:
{
"score": <number between 0-10>,
"reasoning": "<brief explanation of your score>"
}
Do not include any other text, markdown formatting, or code blocks. Only output the raw JSON object. Be strict - only give high scores (7+) if the input is well-supported by the context.`
const userPrompt = `Reference Context:
${contextText}
User Input to Evaluate:
${userInput}
Evaluate the consistency and provide your score and reasoning in JSON format.`
logger.info(`[${requestId}] Calling LLM for hallucination scoring`, {
model,
contextChunks: ragContext.length,
})
const providerId = getProviderFromModel(model)
let finalApiKey: string | undefined = apiKey
if (providerId === 'vertex' && providerCredentials?.vertexCredential) {
const credential = await db.query.account.findFirst({
where: eq(account.id, providerCredentials.vertexCredential),
})
if (credential) {
const { accessToken } = await refreshTokenIfNeeded(
requestId,
credential,
providerCredentials.vertexCredential
)
if (accessToken) {
finalApiKey = accessToken
}
}
}
const response = await executeProviderRequest(providerId, {
model,
systemPrompt,
messages: [
{
role: 'user',
content: userPrompt,
},
],
temperature: 0.1, // Low temperature for consistent scoring
apiKey: finalApiKey,
azureEndpoint: providerCredentials?.azureEndpoint,
azureApiVersion: providerCredentials?.azureApiVersion,
vertexProject: providerCredentials?.vertexProject,
vertexLocation: providerCredentials?.vertexLocation,
bedrockAccessKeyId: providerCredentials?.bedrockAccessKeyId,
bedrockSecretKey: providerCredentials?.bedrockSecretKey,
bedrockRegion: providerCredentials?.bedrockRegion,
workspaceId,
})
if (response instanceof ReadableStream || ('stream' in response && 'execution' in response)) {
throw new Error('Unexpected streaming response from LLM')
}
// executeProviderRequest already zeroes cost for BYOK / non-hosted models,
// so this is the billable amount as-is.
const cost = typeof response.cost?.total === 'number' ? response.cost.total : 0
const content = response.content.trim()
let jsonContent = content
if (content.includes('```')) {
const jsonMatch = content.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/)
if (jsonMatch) {
jsonContent = jsonMatch[1]
}
}
const result = JSON.parse(jsonContent)
if (typeof result.score !== 'number' || result.score < 0 || result.score > 10) {
throw new Error('Invalid score format from LLM')
}
logger.info(`[${requestId}] Confidence score: ${result.score}/10`, {
reasoning: result.reasoning,
})
return {
score: result.score,
reasoning: result.reasoning || 'No reasoning provided',
cost,
}
} catch (error: any) {
logger.error(`[${requestId}] Error scoring with LLM`, {
error: error.message,
})
throw new Error(`Failed to score confidence: ${error.message}`)
}
}
/**
* Validate user input against knowledge base using RAG + LLM scoring
*/
export async function validateHallucination(
input: HallucinationValidationInput
): Promise<HallucinationValidationResult> {
const {
userInput,
knowledgeBaseId,
threshold,
topK,
model,
apiKey,
providerCredentials,
workflowId,
workspaceId,
authHeaders,
requestId,
} = input
try {
if (!userInput || userInput.trim().length === 0) {
return {
passed: false,
error: 'User input is required',
}
}
if (!knowledgeBaseId) {
return {
passed: false,
error: 'Knowledge base ID is required',
}
}
// Step 1: Query knowledge base with RAG
const ragContext = await queryKnowledgeBase(
knowledgeBaseId,
userInput,
topK,
requestId,
workflowId,
authHeaders
)
if (ragContext.length === 0) {
return {
passed: false,
error: 'No relevant context found in knowledge base',
}
}
// Step 2: Use LLM to score confidence
const { score, reasoning, cost } = await scoreHallucinationWithLLM(
userInput,
ragContext,
model,
apiKey,
providerCredentials,
workspaceId,
requestId
)
logger.info(`[${requestId}] Confidence score: ${score}`, {
reasoning,
threshold,
})
// Step 3: Check against threshold. Lower scores = less confidence = fail validation
const passed = score >= threshold
return {
passed,
score,
reasoning,
cost,
error: passed
? undefined
: `Low confidence: score ${score}/10 is below threshold ${threshold}`,
}
} catch (error: any) {
logger.error(`[${requestId}] Hallucination validation error`, {
error: error.message,
})
return {
passed: false,
error: `Validation error: ${error.message}`,
}
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Validate if input is valid JSON
*/
export interface ValidationResult {
passed: boolean
error?: string
}
export function validateJson(inputStr: string): ValidationResult {
try {
JSON.parse(inputStr)
return { passed: true }
} catch (error: any) {
if (error instanceof SyntaxError) {
return { passed: false, error: `Invalid JSON: ${error.message}` }
}
return { passed: false, error: `Validation error: ${error.message}` }
}
}
@@ -0,0 +1,162 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { maskPIIBatch, validatePII } from '@/lib/guardrails/validate_pii'
interface Span {
entity_type: string
start: number
end: number
score: number
}
/** Mimic the Presidio anonymizer's default `replace`: each span → `<ENTITY_TYPE>`. */
function applyReplace(text: string, results: Span[]): string {
let out = text
for (const s of [...results].sort((a, b) => b.start - a.start)) {
out = `${out.slice(0, s.start)}<${s.entity_type}>${out.slice(s.end)}`
}
return out
}
/** Analyzer mock: flags `a@b.com` as EMAIL_ADDRESS when that entity is in scope. */
function emailSpans(text: string, entities: string[] | undefined): Span[] {
if (entities && !entities.includes('EMAIL_ADDRESS')) return []
const idx = text.indexOf('a@b.com')
return idx === -1 ? [] : [{ entity_type: 'EMAIL_ADDRESS', start: idx, end: idx + 7, score: 0.9 }]
}
describe('validate_pii (Presidio service)', () => {
let analyzeBodies: Array<{ text: string; language: string; entities?: string[] }>
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
analyzeBodies = []
fetchMock = vi.fn(async (url: string, init: { body: string }) => {
const body = JSON.parse(init.body)
if (url.includes('/redact_batch')) {
for (const text of body.texts as string[]) {
analyzeBodies.push({ text, language: body.language, entities: body.entities })
}
const texts = (body.texts as string[]).map((t) =>
applyReplace(t, emailSpans(t, body.entities))
)
return new Response(JSON.stringify({ texts }), { status: 200 })
}
if (url.includes('/analyze_batch')) {
for (const text of body.texts as string[]) {
analyzeBodies.push({ text, language: body.language, entities: body.entities })
}
const spans = (body.texts as string[]).map((t) => emailSpans(t, body.entities))
return new Response(JSON.stringify(spans), { status: 200 })
}
if (url.includes('/anonymize_batch')) {
const texts = (body.items as Array<{ text: string; analyzer_results: Span[] }>).map((i) =>
applyReplace(i.text, i.analyzer_results)
)
return new Response(JSON.stringify({ texts }), { status: 200 })
}
if (url.includes('/analyze')) {
analyzeBodies.push({ text: body.text, language: body.language, entities: body.entities })
return new Response(JSON.stringify(emailSpans(body.text, body.entities)), { status: 200 })
}
// /anonymize
return new Response(
JSON.stringify({ text: applyReplace(body.text, body.analyzer_results) }),
{
status: 200,
}
)
})
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => vi.unstubAllGlobals())
describe('maskPIIBatch', () => {
it('masks detected entities, preserving input order', async () => {
const out = await maskPIIBatch(['email a@b.com', 'nothing here'], [])
expect(out[0]).toBe('email <EMAIL_ADDRESS>')
expect(out[1]).toBe('nothing here')
})
it('forwards entityTypes (and language) to the analyzer; empty ⇒ omitted (all)', async () => {
await maskPIIBatch(['mail a@b.com'], ['EMAIL_ADDRESS', 'PERSON'], 'es')
expect(analyzeBodies[0].entities).toEqual(['EMAIL_ADDRESS', 'PERSON'])
expect(analyzeBodies[0].language).toBe('es')
analyzeBodies.length = 0
await maskPIIBatch(['mail a@b.com'], [])
expect(analyzeBodies[0].entities).toBeUndefined()
})
it('returns [] for empty input and leaves empty strings untouched', async () => {
expect(await maskPIIBatch([], [])).toEqual([])
expect(await maskPIIBatch([''], [])).toEqual([''])
})
it('throws on a service failure so the caller can scrub', async () => {
fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 }))
await expect(maskPIIBatch(['email a@b.com'], [])).rejects.toThrow(
/Presidio redact_batch failed/
)
})
// Runs last: a 404 permanently flips the module's combined-endpoint flag off.
it('falls back to legacy analyze+anonymize when /redact_batch is absent (404)', async () => {
fetchMock.mockImplementation(async (url: string, init: { body: string }) => {
const body = JSON.parse(init.body)
if (url.includes('/redact_batch')) return new Response('Not Found', { status: 404 })
if (url.includes('/analyze_batch')) {
const spans = (body.texts as string[]).map((t) => emailSpans(t, body.entities))
return new Response(JSON.stringify(spans), { status: 200 })
}
// /anonymize_batch
const texts = (body.items as Array<{ text: string; analyzer_results: Span[] }>).map((i) =>
applyReplace(i.text, i.analyzer_results)
)
return new Response(JSON.stringify({ texts }), { status: 200 })
})
const out = await maskPIIBatch(['email a@b.com', 'clean'], [])
expect(out).toEqual(['email <EMAIL_ADDRESS>', 'clean'])
})
})
describe('validatePII', () => {
it('block mode fails with a summary when PII is detected', async () => {
const res = await validatePII({
text: 'reach me at a@b.com',
entityTypes: [],
mode: 'block',
requestId: 'r1',
})
expect(res.passed).toBe(false)
expect(res.error).toContain('EMAIL_ADDRESS')
expect(res.detectedEntities).toHaveLength(1)
})
it('mask mode returns masked text', async () => {
const res = await validatePII({
text: 'mail a@b.com',
entityTypes: [],
mode: 'mask',
requestId: 'r2',
})
expect(res.passed).toBe(true)
expect(res.maskedText).toBe('mail <EMAIL_ADDRESS>')
})
it('passes clean text', async () => {
const res = await validatePII({
text: 'nothing to see',
entityTypes: [],
mode: 'block',
requestId: 'r3',
})
expect(res.passed).toBe(true)
expect(res.detectedEntities).toHaveLength(0)
})
})
})
+327
View File
@@ -0,0 +1,327 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { env } from '@/lib/core/config/env'
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
import { chunkIndicesByBudget } from '@/lib/guardrails/pii-batching'
const logger = createLogger('PIIValidator')
/**
* Concurrent chunk requests in flight from a single mask-batch call. Each chunk is
* itself a batched service call (spaCy `nlp.pipe` over many strings). Default 4;
* raise via `PII_SERVICE_CHUNK_CONCURRENCY` for a scaled Presidio fleet (this is
* the route → Presidio fan-out, inner to the app → route `PII_MASK_CHUNK_CONCURRENCY`).
*/
const CHUNK_CONCURRENCY = env.PII_SERVICE_CHUNK_CONCURRENCY ?? 4
/** Presidio service serving /analyze, /anonymize, and combined /redact (VIN is native there). */
const PII_URL = env.PII_URL || 'http://localhost:5001'
export interface PIIValidationInput {
text: string
entityTypes: string[] // e.g., ["PERSON", "EMAIL_ADDRESS", "CREDIT_CARD"]
mode: 'block' | 'mask' // block = fail if PII found, mask = return masked text
language?: string // default: "en"
requestId: string
}
interface DetectedPIIEntity {
type: string
start: number
end: number
score: number
text: string
}
export interface PIIValidationResult {
passed: boolean
error?: string
detectedEntities: DetectedPIIEntity[]
maskedText?: string
}
interface AnalyzerSpan {
entity_type: string
start: number
end: number
score: number
}
/**
* 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.
*/
async function analyze(
text: string,
entityTypes: string[],
language: string
): Promise<AnalyzerSpan[]> {
const entities = entityTypes.length > 0 ? entityTypes : undefined
// boundary-raw-fetch: internal call to the Presidio analyzer service via PII_URL
const response = await fetch(`${PII_URL}/analyze`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text, language, ...(entities ? { entities } : {}) }),
})
if (!response.ok) {
const detail = await response.text().catch(() => '')
throw new Error(`Presidio analyze failed (${response.status}): ${detail.slice(0, 200)}`)
}
return (await response.json()) as AnalyzerSpan[]
}
/**
* Detect PII spans for many texts in a single analyzer pass (spaCy `nlp.pipe`),
* the batched counterpart to {@link analyze}. Returns one span array per input,
* in order. An empty `entityTypes` ⇒ detect all. Throws on transport/HTTP failure.
*/
async function analyzeBatch(
texts: string[],
entityTypes: string[],
language: string
): Promise<AnalyzerSpan[][]> {
const entities = entityTypes.length > 0 ? entityTypes : undefined
// boundary-raw-fetch: internal call to the Presidio analyzer service via PII_URL
const response = await fetch(`${PII_URL}/analyze_batch`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ texts, language, ...(entities ? { entities } : {}) }),
})
if (!response.ok) {
const detail = await response.text().catch(() => '')
throw new Error(`Presidio analyze failed (${response.status}): ${detail.slice(0, 200)}`)
}
return (await response.json()) as AnalyzerSpan[][]
}
interface AnonymizeBatchItem {
text: string
analyzer_results: AnalyzerSpan[]
}
/**
* Mask many texts in a single anonymizer pass, the batched counterpart to
* {@link anonymize}. Each item carries its own detected spans; callers must omit
* items with no spans (those texts pass through unchanged). Returns masked text
* per item, in order. Throws on failure.
*/
async function anonymizeBatch(items: AnonymizeBatchItem[]): Promise<string[]> {
if (items.length === 0) return []
// boundary-raw-fetch: internal call to the Presidio anonymizer service via PII_URL
const response = await fetch(`${PII_URL}/anonymize_batch`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ items }),
})
if (!response.ok) {
const detail = await response.text().catch(() => '')
throw new Error(`Presidio anonymize failed (${response.status}): ${detail.slice(0, 200)}`)
}
const data = (await response.json()) as { texts: string[] }
return data.texts
}
/**
* Flips to `false` the first time `/redact_batch` returns 404 — an older Presidio
* image without the combined endpoint — so subsequent chunks skip straight to the
* legacy analyze+anonymize path instead of re-probing. Reset on process restart
* (a deploy), so a newly-rolled Presidio is picked up.
*/
let combinedRedactAvailable = true
/**
* Analyze + anonymize a batch in ONE round-trip via `/redact_batch`, returning
* masked texts in request order. Halves the app↔service round-trips (and avoids
* shipping the text back up for anonymize) vs {@link analyzeBatch} + {@link anonymizeBatch}.
*
* Returns `null` when the endpoint is absent (404 — an older Presidio image), so
* the caller falls back to the legacy two-call path. Any other non-2xx or a
* length mismatch throws so the caller applies its fail-safe (never leaks).
*/
async function redactBatch(
texts: string[],
entityTypes: string[],
language: string
): Promise<string[] | null> {
const entities = entityTypes.length > 0 ? entityTypes : undefined
// boundary-raw-fetch: internal call to the Presidio combined redact service via PII_URL
const response = await fetch(`${PII_URL}/redact_batch`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ texts, language, ...(entities ? { entities } : {}) }),
})
if (response.status === 404) return null
if (!response.ok) {
const detail = await response.text().catch(() => '')
throw new Error(`Presidio redact_batch failed (${response.status}): ${detail.slice(0, 200)}`)
}
const data = (await response.json()) as { texts: string[] }
if (data.texts.length !== texts.length) {
throw new Error(
`Presidio redact_batch returned ${data.texts.length} result(s) for ${texts.length} input(s)`
)
}
return data.texts
}
/**
* Mask spans via the Presidio anonymizer service. Omitting `anonymizers` uses the
* default `replace` operator, which yields `<ENTITY_TYPE>`. Throws on failure.
*/
async function anonymize(text: string, spans: AnalyzerSpan[]): Promise<string> {
if (spans.length === 0) return text
// boundary-raw-fetch: internal call to the Presidio anonymizer service via PII_URL
const response = await fetch(`${PII_URL}/anonymize`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text, analyzer_results: spans }),
})
if (!response.ok) {
const detail = await response.text().catch(() => '')
throw new Error(`Presidio anonymize failed (${response.status}): ${detail.slice(0, 200)}`)
}
const data = (await response.json()) as { text: string }
return data.text
}
/**
* Validate text for PII using the Presidio service.
*
* - block: fails validation if any PII is detected
* - mask: passes and returns masked text with PII replaced by `<ENTITY_TYPE>`
*/
export async function validatePII(input: PIIValidationInput): Promise<PIIValidationResult> {
const { text, entityTypes, mode, language = 'en', requestId } = input
logger.info(`[${requestId}] Starting PII validation`, {
textLength: text.length,
entityTypes,
mode,
language,
})
try {
const spans = await analyze(text, entityTypes, language)
const detectedEntities: DetectedPIIEntity[] = spans.map((s) => ({
type: s.entity_type,
start: s.start,
end: s.end,
score: s.score,
text: text.slice(s.start, s.end),
}))
if (spans.length === 0) {
logger.info(`[${requestId}] PII validation completed`, { passed: true, detectedCount: 0 })
return { passed: true, detectedEntities: [], maskedText: mode === 'mask' ? text : undefined }
}
if (mode === 'block') {
const counts = new Map<string, number>()
for (const e of detectedEntities) counts.set(e.type, (counts.get(e.type) ?? 0) + 1)
const summary = Array.from(counts.entries())
.map(([type, count]) => `${count} ${type}`)
.join(', ')
logger.info(`[${requestId}] PII validation completed`, {
passed: false,
detectedCount: detectedEntities.length,
})
return { passed: false, error: `PII detected: ${summary}`, detectedEntities }
}
// mask mode: the anonymizer replaces every span with `<ENTITY_TYPE>`.
const maskedText = await anonymize(text, spans)
logger.info(`[${requestId}] PII validation completed`, {
passed: true,
detectedCount: detectedEntities.length,
hasMaskedText: true,
})
return { passed: true, detectedEntities, maskedText }
} catch (error) {
logger.error(`[${requestId}] PII validation failed`, { error: getErrorMessage(error) })
return {
passed: false,
error: `PII validation failed: ${getErrorMessage(error)}`,
detectedEntities: [],
}
}
}
/**
* Mask PII across many strings via the Presidio service, preserving input order.
*
* Strings are grouped into byte/count-budgeted chunks (see {@link chunkIndicesByBudget}),
* and each chunk is masked via the combined `/redact_batch` endpoint (one analyze +
* anonymize round-trip). Against an older Presidio without that endpoint, it falls
* back to the legacy `analyze_batch` + `anonymize_batch` pair — so the app is safe
* to deploy before or after the service. Chunks run with bounded concurrency.
* Strings with no detected PII pass through unchanged. Rejects on any service
* failure (which fails the whole batch) so callers can apply their own fail-safe (scrub).
*/
export async function maskPIIBatch(
texts: string[],
entityTypes: string[],
language = 'en'
): Promise<string[]> {
if (texts.length === 0) return []
const result = new Array<string>(texts.length)
await mapWithConcurrency(chunkIndicesByBudget(texts), CHUNK_CONCURRENCY, async (indices) => {
const chunkTexts = indices.map((i) => texts[i])
if (combinedRedactAvailable) {
const masked = await redactBatch(chunkTexts, entityTypes, language)
if (masked) {
indices.forEach((originalIndex, pos) => {
result[originalIndex] = masked[pos]
})
return
}
// 404: older Presidio image; stop probing and use the legacy path below.
combinedRedactAvailable = false
}
const spansPerText = await analyzeBatch(chunkTexts, entityTypes, language)
// A short/misaligned batch response would silently leave the unmatched
// strings unmasked (fail-open). Throw so the caller applies its fail-safe
// (scrub for logs, abort for in-flight stages) instead of leaking PII.
if (spansPerText.length !== chunkTexts.length) {
throw new Error(
`Presidio analyze_batch returned ${spansPerText.length} result(s) for ${chunkTexts.length} input(s)`
)
}
const toAnonymize: AnonymizeBatchItem[] = []
const anonymizePositions: number[] = []
indices.forEach((originalIndex, pos) => {
const spans = spansPerText[pos] ?? []
if (spans.length === 0) {
result[originalIndex] = chunkTexts[pos]
return
}
toAnonymize.push({ text: chunkTexts[pos], analyzer_results: spans })
anonymizePositions.push(pos)
})
const masked = await anonymizeBatch(toAnonymize)
if (masked.length !== toAnonymize.length) {
throw new Error(
`Presidio anonymize_batch returned ${masked.length} result(s) for ${toAnonymize.length} input(s)`
)
}
anonymizePositions.forEach((pos, k) => {
result[indices[pos]] = masked[k]
})
})
return result
}
export { type PIIEntityType, SUPPORTED_PII_ENTITIES } from '@/lib/guardrails/pii-entities'
+31
View File
@@ -0,0 +1,31 @@
import safe from 'safe-regex2'
/**
* Validate if input matches regex pattern
*/
export interface ValidationResult {
passed: boolean
error?: string
}
export function validateRegex(inputStr: string, pattern: string): ValidationResult {
let regex: RegExp
try {
regex = new RegExp(pattern)
} catch (error: any) {
return { passed: false, error: `Invalid regex pattern: ${error.message}` }
}
if (!safe(pattern)) {
return {
passed: false,
error: 'Regex pattern rejected: potentially unsafe (catastrophic backtracking)',
}
}
const match = regex.test(inputStr)
if (match) {
return { passed: true }
}
return { passed: false, error: 'Input does not match regex pattern' }
}