From 7b9f40d3b7daa602ffb433b0fa2ea13348c02c24 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Mon, 13 Jul 2026 01:14:16 +0000 Subject: [PATCH] fix(tess): redact chat persistence and egress (#725) --- .../src/chat/chat.gateway-redaction.spec.ts | 170 +++++++++++++++++ apps/gateway/src/chat/chat.gateway.ts | 179 +++++++++++++++++- .../commands/command-executor-p8012.spec.ts | 15 +- .../src/commands/command-executor.service.ts | 18 +- packages/log/src/index.ts | 5 + packages/log/src/redaction.spec.ts | 25 +++ packages/log/src/redaction.ts | 40 ++++ 7 files changed, 426 insertions(+), 26 deletions(-) create mode 100644 apps/gateway/src/chat/chat.gateway-redaction.spec.ts create mode 100644 packages/log/src/redaction.spec.ts create mode 100644 packages/log/src/redaction.ts diff --git a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts new file mode 100644 index 0000000..23c7060 --- /dev/null +++ b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ChatGateway } from './chat.gateway.js'; + +const CONVERSATION_ID = 'conversation-1'; +const CANARY = 'sk_canary12345678'; + +type GatewayInternals = { + clientSessions: Map; + relayEvent(client: unknown, conversationId: string, event: unknown): void; +}; + +function buildGateway() { + const brain = { + conversations: { + addMessage: vi.fn().mockResolvedValue(undefined), + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + }; + const gateway = new ChatGateway( + agentService as never, + {} as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + + return { gateway: gateway as unknown as GatewayInternals, brain }; +} + +describe('ChatGateway redaction boundary', (): void => { + it('redacts a secret split across assistant deltas before egress and persistence', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + const session = { + conversationId: CONVERSATION_ID, + cleanup: vi.fn(), + assistantText: '', + toolCalls: [], + pendingToolCalls: new Map(), + scope: { userId: 'user-1', tenantId: 'tenant-1' }, + }; + gateway.clientSessions.set(client.id, session); + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'sk_canary' }, + }); + + expect(JSON.stringify(client.emit.mock.calls)).not.toContain('sk_canary'); + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '12345678 ' }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_SECRET] ', + }); + expect(session.assistantText).toBe(`${CANARY} `); + expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY); + }); + + it('retains a split secret label until its value can be redacted', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'token ' }, + }); + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '=canaryvalue123 ' }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_SECRET] ', + }); + expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canaryvalue123'); + }); + + it('holds a streamed private key until it can be redacted', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '-----BEGIN PRIVATE KEY-----\ncanary' }, + }); + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: '\n-----END PRIVATE KEY-----' }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_SECRET]', + }); + expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canary'); + }); + + it('drops an oversized unterminated stream fragment rather than retaining it', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + + gateway.relayEvent(client, CONVERSATION_ID, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'x'.repeat(8_193) }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_STREAM_OVERFLOW]', + }); + }); + + it('persists only redacted assistant content with classifications', (): void => { + const { gateway, brain } = buildGateway(); + const client = { + connected: true, + id: 'client-1', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + gateway.clientSessions.set(client.id, { + conversationId: CONVERSATION_ID, + cleanup: vi.fn(), + assistantText: CANARY, + toolCalls: [], + pendingToolCalls: new Map(), + scope: { userId: 'user-1', tenantId: 'tenant-1' }, + }); + + gateway.relayEvent(client, CONVERSATION_ID, { type: 'agent_end' }); + + expect(brain.conversations.addMessage).toHaveBeenCalledWith( + expect.objectContaining({ + content: '[REDACTED_SECRET]', + metadata: expect.objectContaining({ classifications: ['secret'] }), + }), + 'user-1', + ); + expect(JSON.stringify(brain.conversations.addMessage.mock.calls)).not.toContain(CANARY); + }); +}); diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 41fa81c..7b4f0b3 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -18,6 +18,7 @@ import { } from '@mosaicstack/discord-plugin'; import type { Auth } from '@mosaicstack/auth'; import type { Brain } from '@mosaicstack/brain'; +import { redactSensitiveContent } from '@mosaicstack/log'; import type { SetThinkingPayload, SlashCommandApprovalResultPayload, @@ -63,6 +64,7 @@ interface ClientSession { * Keyed by conversationId, value is the model name to use. */ const modelOverrides = new Map(); +const MAX_REDACTION_BUFFER_LENGTH = 8_192; function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope { if (typeof value !== 'object' || value === null) return false; @@ -107,6 +109,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa private readonly logger = new Logger(ChatGateway.name); private readonly clientSessions = new Map(); + /** Raw stream fragments are kept in memory only until they are safe to redact and emit. */ + private readonly textEgressBuffers = new Map(); + private readonly thinkingEgressBuffers = new Map(); + private readonly overflowedEgress = new Set(); private readonly discordReplayProtector = new DiscordReplayProtector(); constructor( @@ -155,6 +161,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ); this.clientSessions.delete(client.id); } + this.textEgressBuffers.delete(client.id); + this.thinkingEgressBuffers.delete(client.id); + this.overflowedEgress.delete(this.egressKey(client, 'agent:text')); + this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking')); } private getClientScope(client: Socket): ActorTenantScope | null { @@ -317,7 +327,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa { conversationId, role: 'user', - content: data.content, + content: redactSensitiveContent(data.content).content, metadata: { timestamp: new Date().toISOString(), ...(correlationId @@ -327,6 +337,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa discordUserId: discordIngress?.userId, } : {}), + classifications: redactSensitiveContent(data.content).classifications, }, }, userId, @@ -744,6 +755,127 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } + private appendAndFlushRedactedEgress( + client: Socket, + conversationId: string, + eventName: 'agent:text' | 'agent:thinking', + buffers: Map, + delta: string, + ): void { + const key = this.egressKey(client, eventName); + if (this.overflowedEgress.has(key)) return; + + const buffered = `${buffers.get(client.id) ?? ''}${delta}`; + if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) { + buffers.delete(client.id); + this.overflowedEgress.add(key); + client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' }); + return; + } + + buffers.set(client.id, buffered); + this.flushRedactedEgress(client, conversationId, eventName, buffers, false); + } + + /** + * Holds any suffix that could become a secret, email, or phone number after a + * later stream chunk. This avoids relying on downstream redaction after data + * has already reached the socket. + */ + private flushRedactedEgress( + client: Socket, + conversationId: string, + eventName: 'agent:text' | 'agent:thinking', + buffers: Map, + final: boolean, + ): void { + const key = this.egressKey(client, eventName); + if (this.overflowedEgress.has(key)) { + if (final) this.overflowedEgress.delete(key); + return; + } + + const buffered = buffers.get(client.id) ?? ''; + const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered); + const released = buffered.slice(0, releaseLength); + const pending = buffered.slice(releaseLength); + + if (pending) { + buffers.set(client.id, pending); + } else { + buffers.delete(client.id); + } + + if (released) { + client.emit(eventName, { + conversationId, + text: redactSensitiveContent(released).content, + }); + } + } + + private safeRedactionPrefixLength(content: string): number { + let retainedFrom = content.length; + + // Retain the current token because it may become a split secret or email. + const token = /(?:^|\s)(\S*)$/.exec(content); + if (token) { + const matched = token[0] ?? ''; + const trailingToken = token[1] ?? ''; + retainedFrom = token.index + matched.length - trailingToken.length; + } + + // The secret classifier accepts whitespace around ':' and '=', so preserve + // a pending label until its value and delimiter are both complete. + const pendingSecretLabel = + /(?:^|[^A-Za-z0-9_])((?:api[_-]?key|token|password|secret|bearer|authorization)\s*)$/i.exec( + content, + ); + if (pendingSecretLabel) { + const label = pendingSecretLabel[1] ?? ''; + retainedFrom = Math.min( + retainedFrom, + pendingSecretLabel.index + pendingSecretLabel[0].length - label.length, + ); + } + + const secretLabel = /(?:api[_-]?key|token|password|secret|authorization)\s*[:=]\s*$/i.exec( + content, + ); + if (secretLabel) { + retainedFrom = Math.min(retainedFrom, secretLabel.index); + } + + // Phone numbers can contain whitespace and punctuation; preserve the full + // trailing numeric candidate until a non-phone character establishes a boundary. + const phone = /(?:^|[^A-Za-z0-9_])(\+?\d[\d(). -]*)$/.exec(content); + if (phone) { + const matched = phone[0] ?? ''; + const trailingPhoneCandidate = phone[1] ?? ''; + retainedFrom = Math.min( + retainedFrom, + phone.index + matched.length - trailingPhoneCandidate.length, + ); + } + + const privateKeyStart = content.lastIndexOf('-----BEGIN'); + if (privateKeyStart >= 0) { + const privateKey = content.slice(privateKeyStart); + if (/-----END(?: [A-Z]+)* KEY-----/.test(privateKey)) { + // Release the complete block in one pass so the full-block classifier can redact it. + retainedFrom = content.length; + } else { + retainedFrom = Math.min(retainedFrom, privateKeyStart); + } + } + + return retainedFrom; + } + + private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string { + return `${client.id}:${eventName}`; + } + private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void { if (!client.connected) { this.logger.warn( @@ -761,6 +893,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa cs.toolCalls = []; cs.pendingToolCalls.clear(); } + this.textEgressBuffers.set(client.id, ''); + this.thinkingEgressBuffers.set(client.id, ''); + this.overflowedEgress.delete(this.egressKey(client, 'agent:text')); + this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking')); client.emit('agent:start', { conversationId }); break; } @@ -789,6 +925,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } : undefined; + this.flushRedactedEgress( + client, + conversationId, + 'agent:text', + this.textEgressBuffers, + true, + ); + this.flushRedactedEgress( + client, + conversationId, + 'agent:thinking', + this.thinkingEgressBuffers, + true, + ); client.emit('agent:end', { conversationId, usage: usagePayload, @@ -831,8 +981,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa { conversationId, role: 'assistant', - content: cs.assistantText, - metadata, + content: redactSensitiveContent(cs.assistantText).content, + metadata: { + ...metadata, + classifications: redactSensitiveContent(cs.assistantText).classifications, + }, }, userId, ) @@ -854,20 +1007,26 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa case 'message_update': { const assistantEvent = event.assistantMessageEvent; if (assistantEvent.type === 'text_delta') { - // Accumulate assistant text for persistence + // Keep raw stream material in memory only; persist and emit only redacted text. const cs = this.clientSessions.get(client.id); if (cs) { cs.assistantText += assistantEvent.delta; } - client.emit('agent:text', { + this.appendAndFlushRedactedEgress( + client, conversationId, - text: assistantEvent.delta, - }); + 'agent:text', + this.textEgressBuffers, + assistantEvent.delta, + ); } else if (assistantEvent.type === 'thinking_delta') { - client.emit('agent:thinking', { + this.appendAndFlushRedactedEgress( + client, conversationId, - text: assistantEvent.delta, - }); + 'agent:thinking', + this.thinkingEgressBuffers, + assistantEvent.delta, + ); } break; } diff --git a/apps/gateway/src/commands/command-executor-p8012.spec.ts b/apps/gateway/src/commands/command-executor-p8012.spec.ts index 242d2ac..fc5d966 100644 --- a/apps/gateway/src/commands/command-executor-p8012.spec.ts +++ b/apps/gateway/src/commands/command-executor-p8012.spec.ts @@ -106,8 +106,8 @@ describe('CommandExecutorService — P8-012 commands', () => { expect(result.command).toBe('provider'); }); - // /provider login anthropic — success with URL containing poll token - it('/provider login returns success with URL and poll token', async () => { + // /provider login anthropic — no bearer token or auth URL reaches chat output + it('/provider login keeps its one-time token out of chat output', async () => { const payload: SlashCommandPayload = { command: 'provider', args: 'login anthropic', @@ -117,14 +117,9 @@ describe('CommandExecutorService — P8-012 commands', () => { expect(result.success).toBe(true); expect(result.command).toBe('provider'); expect(result.message).toContain('anthropic'); - expect(result.message).toContain('http'); - // data should contain loginUrl and pollToken - expect(result.data).toBeDefined(); - const data = result.data as Record; - expect(typeof data['loginUrl']).toBe('string'); - expect(typeof data['pollToken']).toBe('string'); - expect(data['loginUrl'] as string).toContain('anthropic'); - expect(data['loginUrl'] as string).toContain(data['pollToken'] as string); + expect(result.message).not.toContain('http'); + expect(result.message).not.toContain('token='); + expect(result.data).toEqual({ provider: 'anthropic' }); // Verify Valkey was called expect(mockRedis.set).toHaveBeenCalledOnce(); const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number]; diff --git a/apps/gateway/src/commands/command-executor.service.ts b/apps/gateway/src/commands/command-executor.service.ts index 0003af8..a493b99 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -435,22 +435,28 @@ export class CommandExecutorService { }; } const pollToken = crypto.randomUUID(); - const key = `mosaic:auth:poll:${pollToken}`; - // Store pending state in Valkey (TTL 5 minutes) + const tokenDigest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(pollToken), + ); + const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string => + byte.toString(16).padStart(2, '0'), + ).join(''); + const key = `mosaic:auth:poll:${tokenHash}`; + // Persist only a short-lived token digest. The raw token is delivered only by + // the authenticated dashboard flow, never in chat output or command metadata. await this.redis.set( key, JSON.stringify({ status: 'pending', provider: providerName, userId }), 'EX', 300, ); - // In production this would construct an OAuth URL - const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`; return { command: 'provider', success: true, - message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`, + message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`, conversationId, - data: { loginUrl, pollToken, provider: providerName }, + data: { provider: providerName }, }; } diff --git a/packages/log/src/index.ts b/packages/log/src/index.ts index 86bdffe..9b49ccf 100644 --- a/packages/log/src/index.ts +++ b/packages/log/src/index.ts @@ -10,3 +10,8 @@ export { type LogQuery, } from './agent-logs.js'; export { registerLogCommand } from './cli.js'; +export { + redactSensitiveContent, + type RedactionResult, + type SensitiveClassification, +} from './redaction.js'; diff --git a/packages/log/src/redaction.spec.ts b/packages/log/src/redaction.spec.ts new file mode 100644 index 0000000..4a0fd18 --- /dev/null +++ b/packages/log/src/redaction.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { redactSensitiveContent } from './redaction.js'; + +describe('redactSensitiveContent', (): void => { + it('redacts seeded secret and PII canaries before persistence or egress', (): void => { + const result = redactSensitiveContent( + 'email canary@example.test token=sk_CANARY12345678 phone +1 555 555 1212', + ); + expect(result.content).not.toContain('canary@example.test'); + expect(result.content).not.toContain('sk_CANARY12345678'); + expect(result.content).not.toContain('+1 555 555 1212'); + expect(result.classifications).toEqual(['secret', 'pii']); + }); + + it('redacts common provider credential formats', (): void => { + const result = redactSensitiveContent( + 'Authorization: Bearer canary.bearer.token jwt eyJcanary.eyJpayload.eyJsignature aws AKIACANARY1234567890', + ); + + expect(result.content).not.toContain('canary.bearer.token'); + expect(result.content).not.toContain('eyJcanary.eyJpayload.eyJsignature'); + expect(result.content).not.toContain('AKIACANARY1234567890'); + expect(result.classifications).toEqual(['secret']); + }); +}); diff --git a/packages/log/src/redaction.ts b/packages/log/src/redaction.ts new file mode 100644 index 0000000..3a9320b --- /dev/null +++ b/packages/log/src/redaction.ts @@ -0,0 +1,40 @@ +export type SensitiveClassification = 'secret' | 'pii'; + +export interface RedactionResult { + content: string; + classifications: SensitiveClassification[]; +} + +const SECRET_PATTERNS: RegExp[] = [ + /\b(?:sk|ghp|gitea)_[A-Za-z0-9_-]{8,}\b/g, + /\b(?:api[_-]?key|token|password|secret)\s*[:=]\s*[^\s,;]+/gi, + /\b(?:authorization\s*:\s*)?bearer\s+[A-Za-z0-9._~+/-]+=*/gi, + /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, + /-----BEGIN(?: [A-Z]+)* KEY-----[\s\S]*?-----END(?: [A-Z]+)* KEY-----/g, + /https?:\/\/[^\s?#]+[^\s]*[?&](?:token|key|secret|signature|sig)=[^\s&#]+/gi, +]; +const PII_PATTERNS: RegExp[] = [ + /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, + /\b\+?\d[\d(). -]{7,}\d\b/g, +]; + +export function redactSensitiveContent(content: string): RedactionResult { + let redacted = content; + const classifications: SensitiveClassification[] = []; + for (const pattern of SECRET_PATTERNS) { + if (pattern.test(redacted)) { + classifications.push('secret'); + redacted = redacted.replace(pattern, '[REDACTED_SECRET]'); + } + pattern.lastIndex = 0; + } + for (const pattern of PII_PATTERNS) { + if (pattern.test(redacted)) { + classifications.push('pii'); + redacted = redacted.replace(pattern, '[REDACTED_PII]'); + } + pattern.lastIndex = 0; + } + return { content: redacted, classifications: [...new Set(classifications)] }; +}