diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a7ebca..593d6c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,82 @@ # Changelog +## [1.2.0] - 2026-02-21 + +### Added + +#### PII Detection and Redaction +Protect sensitive personal information in prompts before they reach AI providers. When enabled, LockLLM detects emails, phone numbers, SSNs, credit card numbers, and other PII entities. Choose how to handle detected PII with the `piiAction` option: + +- **`block`** - Reject requests containing PII entirely. Throws a `PIIDetectedError` with entity types and count. +- **`strip`** - Automatically redact PII from prompts before forwarding to the AI provider. The redacted text is available via `redacted_input` in the scan response. +- **`allow_with_warning`** - Allow requests through but include PII metadata in the response for logging. + +PII detection is opt-in and disabled by default. + +```typescript +// Block requests containing PII +const openai = createOpenAI({ + apiKey: process.env.LOCKLLM_API_KEY, + proxyOptions: { + piiAction: 'strip' // Automatically redact PII before sending to AI + } +}); + +// Handle PII errors when using block mode +try { + await openai.chat.completions.create({ ... }); +} catch (error) { + if (error instanceof PIIDetectedError) { + console.log(error.pii_details.entity_types); // ['email', 'phone_number'] + console.log(error.pii_details.entity_count); // 3 + } +} +``` + +#### Scan API PII Support +The scan endpoint now accepts a `piiAction` option alongside existing scan options: + +```typescript +const result = await lockllm.scan( + { input: 'My email is test@example.com' }, + { piiAction: 'block', scanAction: 'block' } +); + +if (result.pii_result) { + console.log(result.pii_result.detected); // true + console.log(result.pii_result.entity_types); // ['email'] + console.log(result.pii_result.entity_count); // 1 + console.log(result.pii_result.redacted_input); // 'My email is [EMAIL]' (strip mode only) +} +``` + +#### Enhanced Proxy Response Metadata +Proxy responses now include additional fields for better observability: + +- **PII detection metadata** - `pii_detected` object with detection status, entity types, count, and action taken +- **Blocked status** - `blocked` flag when a request was rejected by security checks +- **Sensitivity and label** - `sensitivity` level used and numeric `label` (0 = safe, 1 = unsafe) +- **Decoded detail fields** - `scan_detail`, `policy_detail`, and `abuse_detail` automatically decoded from base64 response headers +- **Extended routing metadata** - `estimated_original_cost`, `estimated_routed_cost`, `estimated_input_tokens`, `estimated_output_tokens`, and `routing_fee_reason` + +#### Sensitivity Header Support +You can now set the detection sensitivity level via `proxyOptions` or `buildLockLLMHeaders`: + +```typescript +const openai = createOpenAI({ + apiKey: process.env.LOCKLLM_API_KEY, + proxyOptions: { + sensitivity: 'high' // 'low', 'medium', or 'high' + } +}); +``` + +### Notes +- PII detection is opt-in. Existing integrations continue to work without changes. +- All new types (`PIIAction`, `PIIResult`, `PIIDetectedError`, `PIIDetectedErrorData`) are fully exported for TypeScript users. + +--- + ## [1.1.0] - 2026-02-18 ### Added diff --git a/README.md b/README.md index ccbcace..ffcd169 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ **All-in-One AI Security for LLM Applications** -*Keep control of your AI. Detect prompt injection, jailbreaks, and adversarial attacks in real-time across 17+ providers with zero code changes.* +*Keep control of your AI. Detect prompt injection, jailbreaks, PII leakage, and adversarial attacks in real-time across 17+ providers with zero code changes.* [Quick Start](#quick-start) · [Documentation](https://www.lockllm.com/docs) · [Examples](#examples) · [Benchmarks](https://www.lockllm.com) · [API Reference](#api-reference) @@ -82,6 +82,7 @@ LockLLM provides production-ready AI security that integrates seamlessly into yo | **Custom Content Policies** | Define your own content rules in the dashboard and enforce them automatically across all providers | | **AI Abuse Detection** | Detect bot-generated content, repetition attacks, and resource exhaustion from your end-users | | **Intelligent Routing** | Automatically select the optimal model for each request based on task type and complexity to save costs | +| **PII Detection & Redaction** | Detect and automatically redact emails, phone numbers, SSNs, credit cards, and other personal information before they reach AI providers | | **Response Caching** | Cache identical LLM responses to reduce costs and latency on repeated queries | | **Enterprise Privacy** | Provider keys encrypted at rest, prompts never stored | | **Production Ready** | Battle-tested with automatic retries, timeouts, and error handling | @@ -406,6 +407,7 @@ import { PromptInjectionError, PolicyViolationError, AbuseDetectedError, + PIIDetectedError, InsufficientCreditsError, AuthenticationError, RateLimitError, @@ -433,6 +435,11 @@ try { console.log("Abuse detected:", error.abuse_details.abuse_types); console.log("Confidence:", error.abuse_details.confidence); + } else if (error instanceof PIIDetectedError) { + // Personal information detected (when piiAction is 'block') + console.log("PII found:", error.pii_details.entity_types); + console.log("Entity count:", error.pii_details.entity_count); + } else if (error instanceof InsufficientCreditsError) { // Not enough credits console.log("Balance:", error.current_balance); @@ -548,7 +555,7 @@ LockLLM Security Gateway 3. **Error Response** - Detailed error returned with threat classification and confidence scores 4. **Logging** - Incident automatically logged in [dashboard](https://www.lockllm.com/dashboard) for review and monitoring -### Security & Privacy +### Privacy & Security LockLLM is built with privacy and security as core principles. Your data stays yours. @@ -617,6 +624,7 @@ interface ScanOptions { scanAction?: 'block' | 'allow_with_warning'; // Core injection behavior policyAction?: 'block' | 'allow_with_warning'; // Custom policy behavior abuseAction?: 'block' | 'allow_with_warning'; // Abuse detection (opt-in) + piiAction?: 'strip' | 'block' | 'allow_with_warning'; // PII detection (opt-in) } ``` @@ -651,6 +659,15 @@ interface ScanResponse { abuse_warnings?: AbuseWarning; // Present when intelligent routing is enabled routing?: { task_type: string; complexity: number; selected_model?: string; }; + // Present when PII detection is enabled + pii_result?: PIIResult; +} + +interface PIIResult { + detected: boolean; // Whether PII was detected + entity_types: string[]; // Types of PII entities found (e.g., 'email', 'phone_number') + entity_count: number; // Number of PII entities found + redacted_input?: string; // Redacted text (only present when piiAction is 'strip') } ``` @@ -676,7 +693,9 @@ interface GenericClientConfig { scanAction?: 'block' | 'allow_with_warning'; policyAction?: 'block' | 'allow_with_warning'; abuseAction?: 'block' | 'allow_with_warning' | null; + piiAction?: 'strip' | 'block' | 'allow_with_warning' | null; routeAction?: 'disabled' | 'auto' | 'custom'; + sensitivity?: 'low' | 'medium' | 'high'; cacheResponse?: boolean; cacheTTL?: number; }; @@ -727,9 +746,10 @@ const headers = buildLockLLMHeaders({ scanAction: 'block', policyAction: 'allow_with_warning', abuseAction: 'block', + piiAction: 'strip', routeAction: 'auto' }); -// Returns: { 'x-lockllm-scan-mode': 'combined', ... } +// Returns: { 'x-lockllm-scan-mode': 'combined', 'x-lockllm-pii-action': 'strip', ... } ``` **Parse proxy response metadata:** @@ -743,6 +763,7 @@ console.log(metadata.safe); // true/false console.log(metadata.scan_mode); // 'combined' console.log(metadata.cache_status); // 'HIT' or 'MISS' console.log(metadata.routing); // { task_type, complexity, selected_model, ... } +console.log(metadata.pii_detected); // { detected, entity_types, entity_count, action } ``` ## Error Types @@ -758,6 +779,7 @@ LockLLMError (base) ├── PromptInjectionError (400) ├── PolicyViolationError (403) ├── AbuseDetectedError (400) +├── PIIDetectedError (403) ├── InsufficientCreditsError (402) ├── UpstreamError (502) ├── ConfigurationError (400) @@ -803,6 +825,13 @@ class AbuseDetectedError extends LockLLMError { }; } +class PIIDetectedError extends LockLLMError { + pii_details: { + entity_types: string[]; // PII types found (e.g., 'email', 'phone_number') + entity_count: number; // Number of PII entities detected + }; +} + class InsufficientCreditsError extends LockLLMError { current_balance: number; // Current credit balance estimated_cost: number; // Estimated cost of the request @@ -908,7 +937,8 @@ const result = await lockllm.scan( { scanAction: 'block', // Block core injection attacks policyAction: 'allow_with_warning', // Allow but warn on policy violations - abuseAction: 'block' // Enable abuse detection (opt-in) + abuseAction: 'block', // Enable abuse detection (opt-in) + piiAction: 'strip' // Redact PII from input (opt-in) } ); @@ -920,6 +950,7 @@ const openai = createOpenAI({ scanAction: 'block', // Block injection attacks policyAction: 'block', // Block policy violations abuseAction: 'allow_with_warning', // Detect abuse, don't block + piiAction: 'strip', // Automatically redact PII routeAction: 'auto' // Enable intelligent routing } }); @@ -939,6 +970,7 @@ const openai = createOpenAI({ - `scanAction` - Controls core injection detection: `'block'` | `'allow_with_warning'` - `policyAction` - Controls custom policy violations: `'block'` | `'allow_with_warning'` - `abuseAction` - Controls abuse detection (opt-in): `'block'` | `'allow_with_warning'` | `null` +- `piiAction` - Controls PII detection (opt-in): `'strip'` | `'block'` | `'allow_with_warning'` | `null` - `routeAction` - Controls intelligent routing: `'disabled'` | `'auto'` | `'custom'` **Default Behavior (no headers):** @@ -946,6 +978,7 @@ const openai = createOpenAI({ - Scan Action: `allow_with_warning` (detect but don't block) - Policy Action: `allow_with_warning` (detect but don't block) - Abuse Action: `null` (disabled, opt-in only) +- PII Action: `null` (disabled, opt-in only) - Route Action: `disabled` (no routing) See [examples/advanced-options.ts](examples/advanced-options.ts) for complete examples. diff --git a/package.json b/package.json index a8442ce..57aa7c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lockllm/sdk", - "version": "1.1.0", + "version": "1.2.0", "description": "Enterprise-grade AI security SDK providing real-time protection against prompt injection, jailbreaks, and adversarial attacks. Drop-in replacement for OpenAI, Anthropic, and 17+ providers with zero code changes. Includes REST API, proxy mode, browser extension, and webhook support. Free BYOK model with unlimited scanning.", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/src/errors.ts b/src/errors.ts index 4949bc9..c6afc38 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -9,6 +9,7 @@ import type { PolicyViolationErrorData, AbuseDetectedErrorData, InsufficientCreditsErrorData, + PIIDetectedErrorData, } from './types/errors'; /** @@ -189,6 +190,28 @@ export class AbuseDetectedError extends LockLLMError { } } +/** + * Error thrown when PII (personal information) is detected and action is block + */ +export class PIIDetectedError extends LockLLMError { + public readonly pii_details: { + entity_types: string[]; + entity_count: number; + }; + + constructor(data: PIIDetectedErrorData) { + super({ + message: data.message, + type: 'lockllm_pii_error', + code: 'pii_detected', + status: 403, + requestId: data.requestId, + }); + this.name = 'PIIDetectedError'; + this.pii_details = data.pii_details; + } +} + /** * Error thrown when user has insufficient credits */ @@ -267,6 +290,18 @@ export function parseError(response: any, requestId?: string): LockLLMError { }); } + // PII detected error + if (error.code === 'pii_detected' && error.pii_details) { + return new PIIDetectedError({ + message: error.message, + type: error.type, + code: error.code, + status: 403, + requestId: error.request_id || requestId, + pii_details: error.pii_details, + }); + } + // Abuse detected error if (error.code === 'abuse_detected' && error.abuse_details) { return new AbuseDetectedError({ diff --git a/src/index.ts b/src/index.ts index 2d069dc..6fead07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ export { PromptInjectionError, PolicyViolationError, AbuseDetectedError, + PIIDetectedError, InsufficientCreditsError, UpstreamError, ConfigurationError, @@ -32,6 +33,7 @@ export type { ScanMode, ScanAction, RouteAction, + PIIAction, ProxyRequestOptions, ProxyResponseMetadata, } from './types/common'; @@ -44,6 +46,7 @@ export type { PolicyViolation, ScanWarning, AbuseWarning, + PIIResult, } from './types/scan'; export type { @@ -52,6 +55,7 @@ export type { PromptInjectionErrorData, PolicyViolationErrorData, AbuseDetectedErrorData, + PIIDetectedErrorData, InsufficientCreditsErrorData, } from './types/errors'; diff --git a/src/scan.ts b/src/scan.ts index e4ac422..25eed69 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -89,6 +89,11 @@ export class ScanClient { headers['x-lockllm-abuse-action'] = options.abuseAction; } + // PII action: opt-in PII detection (null/undefined means disabled) + if (options?.piiAction !== undefined && options?.piiAction !== null) { + headers['x-lockllm-pii-action'] = options.piiAction; + } + // Build request body const body: Record = { input: request.input, diff --git a/src/types/common.ts b/src/types/common.ts index 276f8a6..10f3607 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -2,6 +2,8 @@ * Common types used throughout the SDK */ +import type { Sensitivity } from './scan'; + export interface LockLLMConfig { /** Your LockLLM API key */ apiKey: string; @@ -59,6 +61,9 @@ export type ScanAction = 'block' | 'allow_with_warning'; /** Routing action for intelligent model selection */ export type RouteAction = 'disabled' | 'auto' | 'custom'; +/** PII detection action (opt-in) */ +export type PIIAction = 'strip' | 'block' | 'allow_with_warning'; + /** Proxy request options with advanced headers */ export interface ProxyRequestOptions extends RequestOptions { /** Scan mode (default: combined) - Check both core security and custom policies */ @@ -71,6 +76,10 @@ export interface ProxyRequestOptions extends RequestOptions { abuseAction?: ScanAction | null; /** Routing action (default: disabled) - No intelligent routing unless explicitly enabled */ routeAction?: RouteAction; + /** PII detection action (opt-in, default: null) - When null, PII detection is disabled */ + piiAction?: PIIAction | null; + /** Detection sensitivity level (default: medium) - Controls injection detection threshold */ + sensitivity?: Sensitivity; /** Response caching (default: enabled). Set false to disable. */ cacheResponse?: boolean; /** Cache TTL in seconds (default: 3600) */ @@ -93,6 +102,12 @@ export interface ProxyResponseMetadata { provider: string; /** Model used */ model?: string; + /** Detection sensitivity level used */ + sensitivity?: string; + /** Safety label (0 = safe, 1 = unsafe) */ + label?: number; + /** Whether the request was blocked */ + blocked?: boolean; /** Scan warning details */ scan_warning?: { injection_score: number; @@ -111,6 +126,13 @@ export interface ProxyResponseMetadata { types: string; detail: string; }; + /** PII detection metadata */ + pii_detected?: { + detected: boolean; + entity_types: string; + entity_count: number; + action: string; + }; /** Routing metadata */ routing?: { enabled: boolean; @@ -121,6 +143,11 @@ export interface ProxyResponseMetadata { original_provider: string; original_model: string; estimated_savings: number; + estimated_original_cost: number; + estimated_routed_cost: number; + estimated_input_tokens: number; + estimated_output_tokens: number; + routing_fee_reason: string; }; /** Credits reserved for this request */ credits_reserved?: number; @@ -138,4 +165,10 @@ export interface ProxyResponseMetadata { tokens_saved?: number; /** Cost saved from cache hit */ cost_saved?: number; + /** Decoded scan detail (from base64 header) */ + scan_detail?: any; + /** Decoded policy warning detail (from base64 header) */ + policy_detail?: any; + /** Decoded abuse detail (from base64 header) */ + abuse_detail?: any; } diff --git a/src/types/errors.ts b/src/types/errors.ts index 2e00abd..a758498 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -56,3 +56,10 @@ export interface InsufficientCreditsErrorData extends LockLLMErrorData { current_balance: number; estimated_cost: number; } + +export interface PIIDetectedErrorData extends LockLLMErrorData { + pii_details: { + entity_types: string[]; + entity_count: number; + }; +} diff --git a/src/types/scan.ts b/src/types/scan.ts index 1c49275..90443da 100644 --- a/src/types/scan.ts +++ b/src/types/scan.ts @@ -3,6 +3,7 @@ */ import type { ScanResult } from './errors'; +import type { PIIAction } from './common'; export type Sensitivity = 'low' | 'medium' | 'high'; @@ -23,6 +24,18 @@ export interface ScanRequest { chunk?: boolean; } +/** PII detection result */ +export interface PIIResult { + /** Whether PII was detected */ + detected: boolean; + /** Types of PII entities found (user-friendly names) */ + entity_types: string[]; + /** Number of PII entities found */ + entity_count: number; + /** Redacted input text (only present when piiAction is "strip") */ + redacted_input?: string; +} + /** Scan request options with action headers */ export interface ScanOptions { /** Scan action for core injection (default: allow_with_warning) - Threats detected but not blocked */ @@ -31,6 +44,8 @@ export interface ScanOptions { policyAction?: ScanAction; /** Abuse detection action (opt-in, default: null) - When null, abuse detection is disabled */ abuseAction?: ScanAction | null; + /** PII detection action (opt-in, default: null) - When null, PII detection is disabled */ + piiAction?: PIIAction | null; /** Custom headers to include in the request */ headers?: Record; /** Request timeout in milliseconds */ @@ -142,4 +157,6 @@ export interface ScanResponse { /** Estimated cost */ estimated_cost?: number; }; + /** PII detection result (present when PII detection is enabled) */ + pii_result?: PIIResult; } diff --git a/src/utils/proxy-headers.ts b/src/utils/proxy-headers.ts index eaaf840..67b99f3 100644 --- a/src/utils/proxy-headers.ts +++ b/src/utils/proxy-headers.ts @@ -42,6 +42,16 @@ export function buildLockLLMHeaders(options?: ProxyRequestOptions): Record): P model: getHeader('x-lockllm-model') || undefined, }; + // Parse sensitivity + const sensitivity = getHeader('x-lockllm-sensitivity'); + if (sensitivity) { + metadata.sensitivity = sensitivity; + } + + // Parse label + const label = getHeader('x-lockllm-label'); + if (label) { + metadata.label = parseInt(label, 10); + } + + // Parse blocked status + const blocked = getHeader('x-lockllm-blocked'); + if (blocked === 'true') { + metadata.blocked = true; + } + // Parse scan warning const scanWarning = getHeader('x-lockllm-scan-warning'); if (scanWarning === 'true') { @@ -118,6 +146,21 @@ export function parseProxyMetadata(headers: Headers | Record): P }; } + // Parse PII detection + const piiDetected = getHeader('x-lockllm-pii-detected'); + if (piiDetected) { + const piiTypes = getHeader('x-lockllm-pii-types'); + const piiCount = getHeader('x-lockllm-pii-count'); + const piiAction = getHeader('x-lockllm-pii-action'); + + metadata.pii_detected = { + detected: piiDetected === 'true', + entity_types: piiTypes || '', + entity_count: piiCount ? parseInt(piiCount, 10) : 0, + action: piiAction || '', + }; + } + // Parse routing metadata const routeEnabled = getHeader('x-lockllm-route-enabled'); if (routeEnabled === 'true') { @@ -129,6 +172,12 @@ export function parseProxyMetadata(headers: Headers | Record): P const originalModel = getHeader('x-lockllm-original-model'); const estimatedSavings = getHeader('x-lockllm-estimated-savings'); + const estimatedOriginalCost = getHeader('x-lockllm-estimated-original-cost'); + const estimatedRoutedCost = getHeader('x-lockllm-estimated-routed-cost'); + const estimatedInputTokens = getHeader('x-lockllm-estimated-input-tokens'); + const estimatedOutputTokens = getHeader('x-lockllm-estimated-output-tokens'); + const routingFeeReason = getHeader('x-lockllm-routing-fee-reason'); + metadata.routing = { enabled: true, task_type: taskType || '', @@ -138,6 +187,11 @@ export function parseProxyMetadata(headers: Headers | Record): P original_provider: originalProvider || '', original_model: originalModel || '', estimated_savings: estimatedSavings ? parseFloat(estimatedSavings) : 0, + estimated_original_cost: estimatedOriginalCost ? parseFloat(estimatedOriginalCost) : 0, + estimated_routed_cost: estimatedRoutedCost ? parseFloat(estimatedRoutedCost) : 0, + estimated_input_tokens: estimatedInputTokens ? parseInt(estimatedInputTokens, 10) : 0, + estimated_output_tokens: estimatedOutputTokens ? parseInt(estimatedOutputTokens, 10) : 0, + routing_fee_reason: routingFeeReason || '', }; } @@ -183,6 +237,22 @@ export function parseProxyMetadata(headers: Headers | Record): P metadata.balance_after = parseFloat(balanceAfter); } + // Parse base64-encoded detail fields + const scanDetail = getHeader('x-lockllm-scan-detail'); + if (scanDetail) { + metadata.scan_detail = decodeDetailField(scanDetail); + } + + const policyDetail = getHeader('x-lockllm-warning-detail'); + if (policyDetail) { + metadata.policy_detail = decodeDetailField(policyDetail); + } + + const abuseDetail = getHeader('x-lockllm-abuse-detail'); + if (abuseDetail) { + metadata.abuse_detail = decodeDetailField(abuseDetail); + } + return metadata; } diff --git a/tests/pii-coverage.test.js b/tests/pii-coverage.test.js new file mode 100644 index 0000000..6743e5a --- /dev/null +++ b/tests/pii-coverage.test.js @@ -0,0 +1,589 @@ +/** + * Tests for PII-related features and remaining coverage gaps + * Covers: PIIDetectedError, parseError PII branch, scan piiAction header, + * proxy-headers blocked status, proxy-headers PII parsing, buildLockLLMHeaders piiAction + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + PIIDetectedError, + parseError, +} from '../src/errors'; +import { + buildLockLLMHeaders, + parseProxyMetadata, +} from '../src/utils/proxy-headers'; + +// ============================================================ +// errors.ts - PIIDetectedError constructor (lines 203-212) +// ============================================================ +describe('PIIDetectedError', () => { + it('should create PII detected error with details', () => { + const error = new PIIDetectedError({ + message: 'PII detected in prompt', + pii_details: { + entity_types: ['email', 'phone_number'], + entity_count: 3, + }, + requestId: 'req_pii_001', + }); + + expect(error).toBeInstanceOf(PIIDetectedError); + expect(error.name).toBe('PIIDetectedError'); + expect(error.message).toBe('PII detected in prompt'); + expect(error.type).toBe('lockllm_pii_error'); + expect(error.code).toBe('pii_detected'); + expect(error.status).toBe(403); + expect(error.requestId).toBe('req_pii_001'); + expect(error.pii_details).toEqual({ + entity_types: ['email', 'phone_number'], + entity_count: 3, + }); + }); + + it('should create PII detected error without requestId', () => { + const error = new PIIDetectedError({ + message: 'PII found', + pii_details: { + entity_types: ['ssn'], + entity_count: 1, + }, + }); + + expect(error.name).toBe('PIIDetectedError'); + expect(error.requestId).toBeUndefined(); + expect(error.pii_details.entity_types).toEqual(['ssn']); + expect(error.pii_details.entity_count).toBe(1); + }); + + it('should be an instance of Error', () => { + const error = new PIIDetectedError({ + message: 'PII error', + pii_details: { + entity_types: [], + entity_count: 0, + }, + }); + + expect(error).toBeInstanceOf(Error); + }); +}); + +// ============================================================ +// errors.ts - parseError PII detected branch (lines 295-303) +// ============================================================ +describe('parseError - PII detected error', () => { + it('should parse pii_detected error from API response', () => { + const response = { + error: { + code: 'pii_detected', + message: 'Personal information detected', + type: 'lockllm_pii_error', + request_id: 'req_pii_parse', + pii_details: { + entity_types: ['email', 'credit_card'], + entity_count: 2, + }, + }, + }; + + const error = parseError(response, 'req_fallback'); + + expect(error).toBeInstanceOf(PIIDetectedError); + expect(error.message).toBe('Personal information detected'); + expect(error.requestId).toBe('req_pii_parse'); + expect(error.pii_details).toEqual({ + entity_types: ['email', 'credit_card'], + entity_count: 2, + }); + }); + + it('should use fallback requestId when error.request_id is missing for PII', () => { + const response = { + error: { + code: 'pii_detected', + message: 'PII found', + type: 'lockllm_pii_error', + pii_details: { + entity_types: ['address'], + entity_count: 1, + }, + }, + }; + + const error = parseError(response, 'req_pii_fallback'); + + expect(error).toBeInstanceOf(PIIDetectedError); + expect(error.requestId).toBe('req_pii_fallback'); + }); + + it('should NOT match pii_detected without pii_details', () => { + const response = { + error: { + code: 'pii_detected', + message: 'PII detected but no details', + type: 'lockllm_pii_error', + }, + }; + + const error = parseError(response); + + // Should fall through to generic error, not PIIDetectedError + expect(error).not.toBeInstanceOf(PIIDetectedError); + }); +}); + +// ============================================================ +// scan.ts - piiAction header (lines 94-95) +// ============================================================ +describe('ScanClient - piiAction header', () => { + it('should set x-lockllm-pii-action header when piiAction is provided', async () => { + let capturedHeaders = {}; + + const mockHttp = { + post: vi.fn().mockImplementation(async (url, body, options) => { + capturedHeaders = options?.headers || {}; + return { + data: { + request_id: 'req_pii_scan', + safe: true, + label: 0, + sensitivity: 'medium', + confidence: 99, + injection: 0, + }, + }; + }), + }; + + const { ScanClient } = await import('../src/scan'); + const scanClient = new ScanClient(mockHttp); + + await scanClient.scan( + { input: 'My email is test@example.com' }, + { piiAction: 'block' } + ); + + expect(mockHttp.post).toHaveBeenCalled(); + expect(capturedHeaders['x-lockllm-pii-action']).toBe('block'); + }); + + it('should set piiAction to strip', async () => { + let capturedHeaders = {}; + + const mockHttp = { + post: vi.fn().mockImplementation(async (url, body, options) => { + capturedHeaders = options?.headers || {}; + return { + data: { + request_id: 'req_pii_strip', + safe: true, + label: 0, + sensitivity: 'medium', + confidence: 99, + injection: 0, + }, + }; + }), + }; + + const { ScanClient } = await import('../src/scan'); + const scanClient = new ScanClient(mockHttp); + + await scanClient.scan( + { input: 'Test prompt' }, + { piiAction: 'strip' } + ); + + expect(capturedHeaders['x-lockllm-pii-action']).toBe('strip'); + }); + + it('should set piiAction to allow_with_warning', async () => { + let capturedHeaders = {}; + + const mockHttp = { + post: vi.fn().mockImplementation(async (url, body, options) => { + capturedHeaders = options?.headers || {}; + return { + data: { + request_id: 'req_pii_warn', + safe: true, + label: 0, + sensitivity: 'medium', + confidence: 99, + injection: 0, + }, + }; + }), + }; + + const { ScanClient } = await import('../src/scan'); + const scanClient = new ScanClient(mockHttp); + + await scanClient.scan( + { input: 'Test prompt' }, + { piiAction: 'allow_with_warning' } + ); + + expect(capturedHeaders['x-lockllm-pii-action']).toBe('allow_with_warning'); + }); + + it('should NOT set pii-action header when piiAction is undefined', async () => { + let capturedHeaders = {}; + + const mockHttp = { + post: vi.fn().mockImplementation(async (url, body, options) => { + capturedHeaders = options?.headers || {}; + return { + data: { + request_id: 'req_no_pii', + safe: true, + label: 0, + sensitivity: 'medium', + confidence: 99, + injection: 0, + }, + }; + }), + }; + + const { ScanClient } = await import('../src/scan'); + const scanClient = new ScanClient(mockHttp); + + await scanClient.scan( + { input: 'Test prompt' }, + { scanAction: 'block' } + ); + + expect(capturedHeaders['x-lockllm-pii-action']).toBeUndefined(); + }); + + it('should NOT set pii-action header when piiAction is null', async () => { + let capturedHeaders = {}; + + const mockHttp = { + post: vi.fn().mockImplementation(async (url, body, options) => { + capturedHeaders = options?.headers || {}; + return { + data: { + request_id: 'req_null_pii', + safe: true, + label: 0, + sensitivity: 'medium', + confidence: 99, + injection: 0, + }, + }; + }), + }; + + const { ScanClient } = await import('../src/scan'); + const scanClient = new ScanClient(mockHttp); + + await scanClient.scan( + { input: 'Test prompt' }, + { piiAction: null } + ); + + expect(capturedHeaders['x-lockllm-pii-action']).toBeUndefined(); + }); +}); + +// ============================================================ +// proxy-headers.ts - blocked status (lines 103-105) +// ============================================================ +describe('parseProxyMetadata - blocked status', () => { + it('should parse blocked=true', () => { + const headers = new Headers({ + 'x-request-id': 'req_blocked', + 'x-lockllm-scanned': 'true', + 'x-lockllm-safe': 'false', + 'x-lockllm-blocked': 'true', + }); + + const metadata = parseProxyMetadata(headers); + + expect(metadata.blocked).toBe(true); + }); + + it('should not set blocked when header is false', () => { + const headers = new Headers({ + 'x-request-id': 'req_not_blocked', + 'x-lockllm-scanned': 'true', + 'x-lockllm-safe': 'true', + 'x-lockllm-blocked': 'false', + }); + + const metadata = parseProxyMetadata(headers); + + expect(metadata.blocked).toBeUndefined(); + }); + + it('should not set blocked when header is absent', () => { + const headers = new Headers({ + 'x-request-id': 'req_no_blocked', + 'x-lockllm-scanned': 'true', + 'x-lockllm-safe': 'true', + }); + + const metadata = parseProxyMetadata(headers); + + expect(metadata.blocked).toBeUndefined(); + }); +}); + +// ============================================================ +// proxy-headers.ts - PII detection parsing (lines 151-162) +// ============================================================ +describe('parseProxyMetadata - PII detection', () => { + it('should parse PII detected=true with all fields', () => { + const headers = new Headers({ + 'x-request-id': 'req_pii_meta', + 'x-lockllm-scanned': 'true', + 'x-lockllm-safe': 'true', + 'x-lockllm-pii-detected': 'true', + 'x-lockllm-pii-types': 'email,phone_number,ssn', + 'x-lockllm-pii-count': '5', + 'x-lockllm-pii-action': 'strip', + }); + + const metadata = parseProxyMetadata(headers); + + expect(metadata.pii_detected).toBeDefined(); + expect(metadata.pii_detected.detected).toBe(true); + expect(metadata.pii_detected.entity_types).toBe('email,phone_number,ssn'); + expect(metadata.pii_detected.entity_count).toBe(5); + expect(metadata.pii_detected.action).toBe('strip'); + }); + + it('should parse PII detected=false', () => { + const headers = new Headers({ + 'x-request-id': 'req_pii_false', + 'x-lockllm-scanned': 'true', + 'x-lockllm-safe': 'true', + 'x-lockllm-pii-detected': 'false', + }); + + const metadata = parseProxyMetadata(headers); + + expect(metadata.pii_detected).toBeDefined(); + expect(metadata.pii_detected.detected).toBe(false); + expect(metadata.pii_detected.entity_types).toBe(''); + expect(metadata.pii_detected.entity_count).toBe(0); + expect(metadata.pii_detected.action).toBe(''); + }); + + it('should handle PII with missing optional fields', () => { + const headers = new Headers({ + 'x-request-id': 'req_pii_partial', + 'x-lockllm-scanned': 'true', + 'x-lockllm-safe': 'true', + 'x-lockllm-pii-detected': 'true', + }); + + const metadata = parseProxyMetadata(headers); + + expect(metadata.pii_detected).toBeDefined(); + expect(metadata.pii_detected.detected).toBe(true); + expect(metadata.pii_detected.entity_types).toBe(''); + expect(metadata.pii_detected.entity_count).toBe(0); + expect(metadata.pii_detected.action).toBe(''); + }); + + it('should not set pii_detected when header is absent', () => { + const headers = new Headers({ + 'x-request-id': 'req_no_pii', + 'x-lockllm-scanned': 'true', + 'x-lockllm-safe': 'true', + }); + + const metadata = parseProxyMetadata(headers); + + expect(metadata.pii_detected).toBeUndefined(); + }); +}); + +// ============================================================ +// proxy-headers.ts - buildLockLLMHeaders with piiAction (lines 46-48) +// ============================================================ +describe('buildLockLLMHeaders - piiAction', () => { + it('should set pii-action header when piiAction is block', () => { + const headers = buildLockLLMHeaders({ piiAction: 'block' }); + + expect(headers['x-lockllm-pii-action']).toBe('block'); + }); + + it('should set pii-action header when piiAction is strip', () => { + const headers = buildLockLLMHeaders({ piiAction: 'strip' }); + + expect(headers['x-lockllm-pii-action']).toBe('strip'); + }); + + it('should set pii-action header when piiAction is allow_with_warning', () => { + const headers = buildLockLLMHeaders({ piiAction: 'allow_with_warning' }); + + expect(headers['x-lockllm-pii-action']).toBe('allow_with_warning'); + }); + + it('should not set pii-action header when piiAction is null', () => { + const headers = buildLockLLMHeaders({ piiAction: null }); + + expect(headers['x-lockllm-pii-action']).toBeUndefined(); + }); + + it('should not set pii-action header when piiAction is undefined', () => { + const headers = buildLockLLMHeaders({ piiAction: undefined }); + + expect(headers['x-lockllm-pii-action']).toBeUndefined(); + }); +}); + +// ============================================================ +// proxy-headers.ts - buildLockLLMHeaders with sensitivity (lines 51-53) +// ============================================================ +describe('buildLockLLMHeaders - sensitivity', () => { + it('should set sensitivity header when provided', () => { + const headers = buildLockLLMHeaders({ sensitivity: 'high' }); + + expect(headers['x-lockllm-sensitivity']).toBe('high'); + }); + + it('should set sensitivity to low', () => { + const headers = buildLockLLMHeaders({ sensitivity: 'low' }); + + expect(headers['x-lockllm-sensitivity']).toBe('low'); + }); + + it('should set sensitivity to medium', () => { + const headers = buildLockLLMHeaders({ sensitivity: 'medium' }); + + expect(headers['x-lockllm-sensitivity']).toBe('medium'); + }); + + it('should not set sensitivity header when not provided', () => { + const headers = buildLockLLMHeaders({}); + + expect(headers['x-lockllm-sensitivity']).toBeUndefined(); + }); +}); + +// ============================================================ +// proxy-headers.ts - parseProxyMetadata sensitivity + label (lines 90-99) +// ============================================================ +describe('parseProxyMetadata - sensitivity and label', () => { + it('should parse sensitivity from response headers', () => { + const headers = new Headers({ + 'x-request-id': 'req_sens', + 'x-lockllm-scanned': 'true', + 'x-lockllm-safe': 'true', + 'x-lockllm-sensitivity': 'high', + }); + + const metadata = parseProxyMetadata(headers); + + expect(metadata.sensitivity).toBe('high'); + }); + + it('should not set sensitivity when header is absent', () => { + const headers = new Headers({ + 'x-request-id': 'req_no_sens', + 'x-lockllm-scanned': 'true', + 'x-lockllm-safe': 'true', + }); + + const metadata = parseProxyMetadata(headers); + + expect(metadata.sensitivity).toBeUndefined(); + }); + + it('should parse label from response headers', () => { + const headers = new Headers({ + 'x-request-id': 'req_label', + 'x-lockllm-scanned': 'true', + 'x-lockllm-safe': 'false', + 'x-lockllm-label': '1', + }); + + const metadata = parseProxyMetadata(headers); + + expect(metadata.label).toBe(1); + }); + + it('should parse label=0', () => { + const headers = new Headers({ + 'x-request-id': 'req_label_0', + 'x-lockllm-scanned': 'true', + 'x-lockllm-safe': 'true', + 'x-lockllm-label': '0', + }); + + const metadata = parseProxyMetadata(headers); + + expect(metadata.label).toBe(0); + }); + + it('should not set label when header is absent', () => { + const headers = new Headers({ + 'x-request-id': 'req_no_label', + 'x-lockllm-scanned': 'true', + 'x-lockllm-safe': 'true', + }); + + const metadata = parseProxyMetadata(headers); + + expect(metadata.label).toBeUndefined(); + }); +}); + +// ============================================================ +// proxy-headers.ts - routing cost/token fields (lines 190-193) +// ============================================================ +describe('parseProxyMetadata - routing cost and token fields', () => { + it('should parse all routing cost and token headers', () => { + const headers = new Headers({ + 'x-request-id': 'req_route_full', + 'x-lockllm-scanned': 'true', + 'x-lockllm-safe': 'true', + 'x-lockllm-route-enabled': 'true', + 'x-lockllm-task-type': 'code_generation', + 'x-lockllm-complexity': '0.85', + 'x-lockllm-selected-model': 'claude-3-7-sonnet', + 'x-lockllm-routing-reason': 'High complexity', + 'x-lockllm-original-provider': 'openai', + 'x-lockllm-original-model': 'gpt-4', + 'x-lockllm-estimated-savings': '1.25', + 'x-lockllm-estimated-original-cost': '5.50', + 'x-lockllm-estimated-routed-cost': '4.25', + 'x-lockllm-estimated-input-tokens': '1500', + 'x-lockllm-estimated-output-tokens': '800', + 'x-lockllm-routing-fee-reason': 'cost_savings', + }); + + const metadata = parseProxyMetadata(headers); + + expect(metadata.routing).toBeDefined(); + expect(metadata.routing.estimated_original_cost).toBe(5.50); + expect(metadata.routing.estimated_routed_cost).toBe(4.25); + expect(metadata.routing.estimated_input_tokens).toBe(1500); + expect(metadata.routing.estimated_output_tokens).toBe(800); + expect(metadata.routing.routing_fee_reason).toBe('cost_savings'); + }); + + it('should default routing cost/token fields to 0 when headers are absent', () => { + const headers = new Headers({ + 'x-request-id': 'req_route_minimal', + 'x-lockllm-scanned': 'true', + 'x-lockllm-safe': 'true', + 'x-lockllm-route-enabled': 'true', + }); + + const metadata = parseProxyMetadata(headers); + + expect(metadata.routing).toBeDefined(); + expect(metadata.routing.estimated_original_cost).toBe(0); + expect(metadata.routing.estimated_routed_cost).toBe(0); + expect(metadata.routing.estimated_input_tokens).toBe(0); + expect(metadata.routing.estimated_output_tokens).toBe(0); + expect(metadata.routing.routing_fee_reason).toBe(''); + }); +});