Skip to content

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

platform/apps/app/src/lib/chat/guardrails.ts
/**
* 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;
},
};
}