From 5f9067cf5756b894241e3211ed70d5651f2bd911 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 12 Jul 2026 17:00:52 -0500 Subject: [PATCH 1/6] fix(tess): redact sensitive runtime content --- .../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 | 14 ++++++++ packages/log/src/redaction.ts | 35 +++++++++++++++++++ 5 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 packages/log/src/redaction.spec.ts create mode 100644 packages/log/src/redaction.ts 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 a7a455a..6c2a2a7 100644 --- a/apps/gateway/src/commands/command-executor.service.ts +++ b/apps/gateway/src/commands/command-executor.service.ts @@ -436,22 +436,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..9016398 --- /dev/null +++ b/packages/log/src/redaction.spec.ts @@ -0,0 +1,14 @@ +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']); + }); +}); diff --git a/packages/log/src/redaction.ts b/packages/log/src/redaction.ts new file mode 100644 index 0000000..2bbbc0f --- /dev/null +++ b/packages/log/src/redaction.ts @@ -0,0 +1,35 @@ +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, +]; +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)] }; +} -- 2.49.1 From 0de3d514661d80d8a8c7e690e5db9acd5d49c639 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 12 Jul 2026 18:47:20 -0500 Subject: [PATCH 2/6] fix(tess): redact chat persistence and egress --- apps/gateway/src/chat/chat.gateway.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 41fa81c..7640296 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, @@ -317,7 +318,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 +328,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa discordUserId: discordIngress?.userId, } : {}), + classifications: redactSensitiveContent(data.content).classifications, }, }, userId, @@ -831,8 +833,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, ) @@ -856,17 +861,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa if (assistantEvent.type === 'text_delta') { // Accumulate assistant text for persistence const cs = this.clientSessions.get(client.id); + const redactedDelta = redactSensitiveContent(assistantEvent.delta).content; if (cs) { - cs.assistantText += assistantEvent.delta; + cs.assistantText += redactedDelta; } client.emit('agent:text', { conversationId, - text: assistantEvent.delta, + text: redactedDelta, }); } else if (assistantEvent.type === 'thinking_delta') { client.emit('agent:thinking', { conversationId, - text: assistantEvent.delta, + text: redactSensitiveContent(assistantEvent.delta).content, }); } break; -- 2.49.1 From 98b4f952841b92b2194d1d31af13e86255489ba2 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 12 Jul 2026 19:23:14 -0500 Subject: [PATCH 3/6] test(tess): cover redacted chat egress --- .../src/chat/chat.gateway-redaction.spec.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 apps/gateway/src/chat/chat.gateway-redaction.spec.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..0ac831a --- /dev/null +++ b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ChatGateway } from './chat.gateway.js'; + +const CONVERSATION_ID = 'conversation-1'; +const CANARY = 'token=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 an assistant delta before egress and before its persistence buffer', (): 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: CANARY }, + }); + + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: CONVERSATION_ID, + text: '[REDACTED_SECRET]', + }); + expect(session.assistantText).toBe('[REDACTED_SECRET]'); + expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY); + }); + + 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); + }); +}); -- 2.49.1 From 70851d9474e9b5b79dcad2b3df1833033fb7a0fd Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 12 Jul 2026 19:32:58 -0500 Subject: [PATCH 4/6] fix(tess): preserve redaction across stream chunks --- .../src/chat/chat.gateway-redaction.spec.ts | 17 ++- apps/gateway/src/chat/chat.gateway.ts | 119 ++++++++++++++++-- 2 files changed, 122 insertions(+), 14 deletions(-) diff --git a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts index 0ac831a..5e46330 100644 --- a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts +++ b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ChatGateway } from './chat.gateway.js'; const CONVERSATION_ID = 'conversation-1'; -const CANARY = 'token=sk_canary12345678'; +const CANARY = 'sk_canary12345678'; type GatewayInternals = { clientSessions: Map; @@ -31,7 +31,7 @@ function buildGateway() { } describe('ChatGateway redaction boundary', (): void => { - it('redacts an assistant delta before egress and before its persistence buffer', (): void => { + it('redacts a secret split across assistant deltas before egress and persistence', (): void => { const { gateway } = buildGateway(); const client = { connected: true, @@ -51,14 +51,21 @@ describe('ChatGateway redaction boundary', (): void => { gateway.relayEvent(client, CONVERSATION_ID, { type: 'message_update', - assistantMessageEvent: { type: 'text_delta', delta: CANARY }, + 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]', + text: '[REDACTED_SECRET] ', }); - expect(session.assistantText).toBe('[REDACTED_SECRET]'); + expect(session.assistantText).toBe(`${CANARY} `); expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY); }); diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 7640296..81979cc 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -108,6 +108,9 @@ 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 discordReplayProtector = new DiscordReplayProtector(); constructor( @@ -156,6 +159,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ); this.clientSessions.delete(client.id); } + this.textEgressBuffers.delete(client.id); + this.thinkingEgressBuffers.delete(client.id); } private getClientScope(client: Socket): ActorTenantScope | null { @@ -746,6 +751,81 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } + private appendAndFlushRedactedEgress( + client: Socket, + conversationId: string, + eventName: 'agent:text' | 'agent:thinking', + buffers: Map, + delta: string, + ): void { + buffers.set(client.id, `${buffers.get(client.id) ?? ''}${delta}`); + 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 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 secretLabel = /(?:api[_-]?key|token|password|secret)\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, + ); + } + + return retainedFrom; + } + private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void { if (!client.connected) { this.logger.warn( @@ -763,6 +843,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa cs.toolCalls = []; cs.pendingToolCalls.clear(); } + this.textEgressBuffers.set(client.id, ''); + this.thinkingEgressBuffers.set(client.id, ''); client.emit('agent:start', { conversationId }); break; } @@ -791,6 +873,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, @@ -859,21 +955,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); - const redactedDelta = redactSensitiveContent(assistantEvent.delta).content; if (cs) { - cs.assistantText += redactedDelta; + cs.assistantText += assistantEvent.delta; } - client.emit('agent:text', { + this.appendAndFlushRedactedEgress( + client, conversationId, - text: redactedDelta, - }); + 'agent:text', + this.textEgressBuffers, + assistantEvent.delta, + ); } else if (assistantEvent.type === 'thinking_delta') { - client.emit('agent:thinking', { + this.appendAndFlushRedactedEgress( + client, conversationId, - text: redactSensitiveContent(assistantEvent.delta).content, - }); + 'agent:thinking', + this.thinkingEgressBuffers, + assistantEvent.delta, + ); } break; } -- 2.49.1 From fdd58a353b800f26318c8c2485b143e263dec344 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 12 Jul 2026 19:39:37 -0500 Subject: [PATCH 5/6] fix(tess): harden redacted stream boundaries --- .../src/chat/chat.gateway-redaction.spec.ts | 45 +++++++++++++++++++ apps/gateway/src/chat/chat.gateway.ts | 45 ++++++++++++++++++- packages/log/src/redaction.spec.ts | 11 +++++ packages/log/src/redaction.ts | 5 +++ 4 files changed, 104 insertions(+), 2 deletions(-) diff --git a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts index 5e46330..e4e6b64 100644 --- a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts +++ b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts @@ -69,6 +69,51 @@ describe('ChatGateway redaction boundary', (): void => { 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('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 = { diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 81979cc..ce9b47d 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -64,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; @@ -111,6 +112,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa /** 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( @@ -161,6 +163,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } 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 { @@ -758,7 +762,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa buffers: Map, delta: string, ): void { - buffers.set(client.id, `${buffers.get(client.id) ?? ''}${delta}`); + 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); } @@ -774,6 +789,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa 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); @@ -806,7 +827,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa // The secret classifier accepts whitespace around ':' and '=', so preserve // a pending label until its value and delimiter are both complete. - const secretLabel = /(?:api[_-]?key|token|password|secret)\s*[:=]\s*$/i.exec(content); + 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); } @@ -826,6 +861,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa 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( @@ -845,6 +884,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } 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; } diff --git a/packages/log/src/redaction.spec.ts b/packages/log/src/redaction.spec.ts index 9016398..4a0fd18 100644 --- a/packages/log/src/redaction.spec.ts +++ b/packages/log/src/redaction.spec.ts @@ -11,4 +11,15 @@ describe('redactSensitiveContent', (): void => { 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 index 2bbbc0f..d0d54d2 100644 --- a/packages/log/src/redaction.ts +++ b/packages/log/src/redaction.ts @@ -8,6 +8,11 @@ export interface RedactionResult { 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]+)* PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z]+)* PRIVATE 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, -- 2.49.1 From 726f7ab7726ff3fe1cb8e6d7f65cdcae9b8d0802 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 12 Jul 2026 19:43:53 -0500 Subject: [PATCH 6/6] test(tess): cover streamed private key redaction --- .../src/chat/chat.gateway-redaction.spec.ts | 25 +++++++++++++++++++ apps/gateway/src/chat/chat.gateway.ts | 11 ++++++++ packages/log/src/redaction.ts | 2 +- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts index e4e6b64..23c7060 100644 --- a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts +++ b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts @@ -94,6 +94,31 @@ describe('ChatGateway redaction boundary', (): void => { 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 = { diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index ce9b47d..7b4f0b3 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -858,6 +858,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ); } + 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; } diff --git a/packages/log/src/redaction.ts b/packages/log/src/redaction.ts index d0d54d2..3a9320b 100644 --- a/packages/log/src/redaction.ts +++ b/packages/log/src/redaction.ts @@ -11,7 +11,7 @@ const SECRET_PATTERNS: RegExp[] = [ /\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]+)* PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z]+)* PRIVATE KEY-----/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[] = [ -- 2.49.1