Guardrails — injection + system-leak patterns
Source: platform/apps/app/src/lib/chat/guardrails.ts · rendered from main on every deploy — edit in the repo, not here
/** * Input/output guardrails for enterprise chat (ADR-0017 §4e, ADR-0021). * * Input: sanitize queries, detect injection signatures, enforce length limits. * Output: validate citations, detect leaked system prompt patterns, scan for PII. * Stream: real-time output scanning with termination on system leak. */
// ── Input guardrails ───────────────────────────────────────────────────
/** Max query length (Thai is ~3x more token-dense than English). */export const MAX_QUERY_LENGTH = 2000;
/** Zero-width and invisible characters to strip from input. */const INVISIBLE_CHARS = /[\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF\u00AD]/g;
/** Known prompt injection signatures. */const INJECTION_PATTERNS = [ /ignore\s+(all\s+)?previous\s+instructions/i, /ignore\s+(all\s+)?above/i, /you\s+are\s+now\s+(?:a|an|the)\s+/i, /system\s*:\s*/i, /<\|(?:im_start|im_end|system|endoftext)\|>/i, /\[INST\]/i, /```system/i, /OVERRIDE|ADMIN_MODE|DEBUG_MODE/i, // Thai injection patterns /เพิกเฉย.*คำสั่ง/, /ลืม.*ก่อนหน้า/, /แสร้ง.*เป็น/,];
export interface SanitizeResult { sanitized: string; blocked: boolean; reason?: string;}
/** Sanitize user input: strip invisible chars, normalize whitespace, check for injections. */export function sanitizeInput(query: string): SanitizeResult { if (query.length > MAX_QUERY_LENGTH) { return { sanitized: query, blocked: true, reason: 'query_too_long' }; }
// Strip invisible characters let cleaned = query.replace(INVISIBLE_CHARS, '');
// Collapse excessive whitespace cleaned = cleaned.replace(/\s{3,}/g, ' ').trim();
// Check injection patterns for (const pattern of INJECTION_PATTERNS) { if (pattern.test(cleaned)) { return { sanitized: cleaned, blocked: true, reason: 'injection_detected' }; } }
return { sanitized: cleaned, blocked: false };}
// ── Output guardrails ──────────────────────────────────────────────────
/** Patterns that indicate the system prompt leaked into the output. */const SYSTEM_LEAK_PATTERNS = [ /คุณคือ\s*"?ปุจฉา"?\s*\(Puccha\)/, /ตอบคำถามโดยใช้ข้อมูลจากเอกสารที่ให้มา/, /You are .?Puccha.? .?ปุจฉา/, /system prompt/i, /my instructions are/i, // ADR-0169 — protect the guided-flow prompt/tooling ("secret sauce"). These // are internal identifiers + guidance headers that must never surface in a // customer-facing reply; if the model echoes them (prompt-extraction attempt), // terminate the stream like any other system-prompt leak. /updateFlow/, /โหมดงานทีละขั้น/, /step-by-step task mode/i,];
/** Validate that all [N] citations in the output map to actual chunks. */export function validateCitations(output: string, maxSourceIndex: number): string { return output.replace(/\[(\d+)\]/g, (match, numStr) => { const num = Number.parseInt(numStr, 10); if (num < 1 || num > maxSourceIndex) return ''; // Strip invalid citation return match; });}
/** Check if the output contains leaked system prompt patterns. */export function detectSystemLeak(output: string): boolean { return SYSTEM_LEAK_PATTERNS.some((p) => p.test(output));}
/** Simple PII detection for Thai context (regex-based, not ML). */export function detectPII(text: string): { hasPII: boolean; types: string[] } { const types: string[] = [];
// Thai national ID (13 digits) if (/\b\d{1}[-\s]?\d{4}[-\s]?\d{5}[-\s]?\d{2}[-\s]?\d{1}\b/.test(text)) { types.push('thai_national_id'); }
// Thai phone numbers (08x/09x, 10 digits) if (/\b0[689]\d[-\s]?\d{3}[-\s]?\d{4}\b/.test(text)) { types.push('phone_number'); }
// Email addresses if (/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/.test(text)) { types.push('email'); }
// Credit card numbers (13-19 digits with optional separators) if (/\b(?:\d{4}[-\s]?){3,4}\d{1,4}\b/.test(text)) { types.push('credit_card'); }
return { hasPII: types.length > 0, types };}
// ── Stream output scanner (ADR-0021 §Phase 1) ────────────────────────
export interface GuardrailTrip { type: 'system_leak' | 'pii_detected' | 'invalid_citation'; details?: string;}
/** * Stateful output scanner for SSE streams. * Accumulates chunks and runs guardrail checks periodically. * * - System leak → returns `terminate: true` (caller must end the stream) * - PII detected → logged as a trip but stream continues * - Citation validation runs on the full text at stream end via `finalize()` */export function createOutputScanner(maxSourceIndex: number) { let buffer = ''; let chunkCount = 0; /** Tail of the previous scan window — catches patterns split across chunk boundaries. */ let prevTail = ''; const trips: GuardrailTrip[] = []; let terminated = false;
return { /** Feed a chunk from the LLM stream. Returns `terminate: true` if stream must stop. */ scan(chunk: string): { terminate: boolean } { if (terminated) return { terminate: true };
buffer += chunk; chunkCount++;
// Scan every 3 chunks to amortize regex cost if (chunkCount % 3 === 0) { // Use prevTail + recent buffer to catch patterns spanning chunk boundaries const scanWindow = prevTail + buffer.slice(-(buffer.length - prevTail.length)); if (detectSystemLeak(scanWindow)) { trips.push({ type: 'system_leak' }); terminated = true; return { terminate: true }; }
const pii = detectPII(buffer); if (pii.hasPII && !trips.some((t) => t.type === 'pii_detected')) { trips.push({ type: 'pii_detected', details: pii.types.join(', ') }); }
// Keep last 200 chars as overlap for next scan window prevTail = buffer.slice(-200); }
return { terminate: false }; },
/** Run final checks on the complete output. Returns sanitized text + all trips. */ finalize(): { text: string; trips: GuardrailTrip[]; hasPII: boolean } { // Final system leak check on complete buffer if (!terminated && detectSystemLeak(buffer)) { trips.push({ type: 'system_leak' }); }
// Final PII check on complete buffer const pii = detectPII(buffer);
// Citation validation const sanitized = validateCitations(buffer, maxSourceIndex); if (sanitized !== buffer) { trips.push({ type: 'invalid_citation' }); }
return { text: sanitized, trips, hasPII: pii.hasPII, }; },
get wasTerminated() { return terminated; },
get currentTrips() { return trips; }, };}