/** * Sensitive output placeholder extraction and substitution. * * Tool outputs may contain `` * directives. This module: * 1. Parses and strips those directives from tool output. * 2. Replaces any raw sensitive values remaining in the output with stable, * high-uniqueness placeholders so the LLM never sees the real values. * 3. Returns bindings (placeholder -> real value) for deterministic * post-generation substitution in the agent loop. * * Raw sensitive values MUST NOT be logged or emitted in lifecycle events. */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type SensitiveOutputKind = "invite_code"; export interface SensitiveOutputBinding { kind: SensitiveOutputKind; placeholder: string; value: string; } // --------------------------------------------------------------------------- // Directive regex // --------------------------------------------------------------------------- const DIRECTIVE_RE = //g; // --------------------------------------------------------------------------- // Placeholder generation // --------------------------------------------------------------------------- const KIND_PREFIX: Record = { invite_code: "VELLUM_ASSISTANT_INVITE_CODE_", }; const VALID_KINDS = new Set(Object.keys(KIND_PREFIX)); /** * Generate an 8-char uppercase base-36 short ID. * Provides ~41 bits of entropy - sufficient for intra-request uniqueness. */ function generateShortId(): string { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let id = ""; for (let i = 0; i < 8; i++) { id += chars[Math.floor(Math.random() * chars.length)]; } return id; } function makePlaceholder(kind: SensitiveOutputKind): string { return `${KIND_PREFIX[kind]}${generateShortId()}`; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export interface SanitizeResult { sanitizedContent: string; bindings: SensitiveOutputBinding[]; } /** * Extract `` directives from tool output content, * strip them, replace any remaining occurrences of the raw sensitive values * with placeholders, and return the bindings for downstream substitution. * * Guarantees: * - Directives are fully removed from the returned content. * - Empty values are silently dropped. * - Duplicate values produce a single binding (same placeholder). * - Unknown kinds are silently ignored. */ export function extractAndSanitize(content: string): SanitizeResult { const bindings: SensitiveOutputBinding[] = []; const seenValues = new Map(); // Step 1: parse directives // Reset lastIndex for safety since the regex is global DIRECTIVE_RE.lastIndex = 0; let match: RegExpExecArray | null; while ((match = DIRECTIVE_RE.exec(content)) !== null) { const kind = match[1]; const value = match[2]; if (!value || value.trim().length === 0) { continue; } if (!VALID_KINDS.has(kind)) { continue; } const typedKind = kind as SensitiveOutputKind; if (!seenValues.has(value)) { const binding: SensitiveOutputBinding = { kind: typedKind, placeholder: makePlaceholder(typedKind), value, }; bindings.push(binding); seenValues.set(value, binding); } } if (bindings.length === 0) { return { sanitizedContent: content, bindings: [] }; } // Step 2: strip directive tags let sanitized = content.replace(DIRECTIVE_RE, ""); // Step 3: replace raw values with placeholders throughout remaining content for (const binding of bindings) { sanitized = sanitized.split(binding.value).join(binding.placeholder); } return { sanitizedContent: sanitized, bindings }; } /** * Apply placeholder->value substitution to a text string. * Used by the agent loop to resolve placeholders in streamed deltas * and final message content. */ export function applySubstitutions( text: string, substitutionMap: ReadonlyMap, ): string { if (substitutionMap.size === 0) { return text; } let result = text; for (const [placeholder, value] of substitutionMap) { result = result.split(placeholder).join(value); } return result; } /** * Chunk-safe substitution for streaming text deltas. * * Because a placeholder like `VELLUM_ASSISTANT_INVITE_CODE_AB12CD34` may be * split across consecutive streamed chunks, this function buffers a trailing * segment that could be the start of an incomplete placeholder and returns it * as `pending`. The caller must prepend `pending` to the next chunk. * * Returns `{ emit, pending }`: * - `emit`: text safe to send to the client (all complete placeholders resolved). * - `pending`: trailing text that might be an incomplete placeholder prefix. */ export function applyStreamingSubstitution( text: string, substitutionMap: ReadonlyMap, ): { emit: string; pending: string } { if (substitutionMap.size === 0) { return { emit: text, pending: "" }; } // First, resolve any complete placeholders let resolved = text; for (const [placeholder, value] of substitutionMap) { resolved = resolved.split(placeholder).join(value); } // Check if the tail of resolved text could be an incomplete placeholder prefix. // All current placeholders start with "VELLUM_ASSISTANT_". const PREFIX = "VELLUM_ASSISTANT_"; const minSuffixLen = 1; // At minimum, one char of the prefix // Walk backwards from the end to find a trailing partial match of any placeholder prefix let pendingStart = resolved.length; for ( let i = Math.max( 0, resolved.length - getMaxPlaceholderLength(substitutionMap), ); i < resolved.length; i++ ) { const tail = resolved.slice(i); // Check if any placeholder starts with this tail if (tail.length >= minSuffixLen && PREFIX.startsWith(tail)) { pendingStart = i; break; } // Also check if any full placeholder key starts with this tail for (const placeholder of substitutionMap.keys()) { if (placeholder.startsWith(tail) && tail.length < placeholder.length) { pendingStart = i; break; } } if (pendingStart !== resolved.length) { break; } } return { emit: resolved.slice(0, pendingStart), pending: resolved.slice(pendingStart), }; } function getMaxPlaceholderLength(map: ReadonlyMap): number { let max = 0; for (const key of map.keys()) { if (key.length > max) { max = key.length; } } return max; }