Compare commits
6 Commits
mos-comms
...
726f7ab772
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
726f7ab772 | ||
|
|
fdd58a353b | ||
|
|
70851d9474 | ||
|
|
98b4f95284 | ||
|
|
0de3d51466 | ||
|
|
5f9067cf57 |
170
apps/gateway/src/chat/chat.gateway-redaction.spec.ts
Normal file
170
apps/gateway/src/chat/chat.gateway-redaction.spec.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@mosaicstack/discord-plugin';
|
} from '@mosaicstack/discord-plugin';
|
||||||
import type { Auth } from '@mosaicstack/auth';
|
import type { Auth } from '@mosaicstack/auth';
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaicstack/brain';
|
||||||
|
import { redactSensitiveContent } from '@mosaicstack/log';
|
||||||
import type {
|
import type {
|
||||||
SetThinkingPayload,
|
SetThinkingPayload,
|
||||||
SlashCommandApprovalResultPayload,
|
SlashCommandApprovalResultPayload,
|
||||||
@@ -63,6 +64,7 @@ interface ClientSession {
|
|||||||
* Keyed by conversationId, value is the model name to use.
|
* Keyed by conversationId, value is the model name to use.
|
||||||
*/
|
*/
|
||||||
const modelOverrides = new Map<string, string>();
|
const modelOverrides = new Map<string, string>();
|
||||||
|
const MAX_REDACTION_BUFFER_LENGTH = 8_192;
|
||||||
|
|
||||||
function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope {
|
function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope {
|
||||||
if (typeof value !== 'object' || value === null) return false;
|
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 logger = new Logger(ChatGateway.name);
|
||||||
private readonly clientSessions = new Map<string, ClientSession>();
|
private readonly clientSessions = new Map<string, ClientSession>();
|
||||||
|
/** Raw stream fragments are kept in memory only until they are safe to redact and emit. */
|
||||||
|
private readonly textEgressBuffers = new Map<string, string>();
|
||||||
|
private readonly thinkingEgressBuffers = new Map<string, string>();
|
||||||
|
private readonly overflowedEgress = new Set<string>();
|
||||||
private readonly discordReplayProtector = new DiscordReplayProtector();
|
private readonly discordReplayProtector = new DiscordReplayProtector();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -155,6 +161,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
);
|
);
|
||||||
this.clientSessions.delete(client.id);
|
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 {
|
private getClientScope(client: Socket): ActorTenantScope | null {
|
||||||
@@ -317,7 +327,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
{
|
{
|
||||||
conversationId,
|
conversationId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: data.content,
|
content: redactSensitiveContent(data.content).content,
|
||||||
metadata: {
|
metadata: {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
...(correlationId
|
...(correlationId
|
||||||
@@ -327,6 +337,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
discordUserId: discordIngress?.userId,
|
discordUserId: discordIngress?.userId,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
classifications: redactSensitiveContent(data.content).classifications,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
userId,
|
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<string, string>,
|
||||||
|
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<string, string>,
|
||||||
|
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 {
|
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
||||||
if (!client.connected) {
|
if (!client.connected) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -761,6 +893,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
cs.toolCalls = [];
|
cs.toolCalls = [];
|
||||||
cs.pendingToolCalls.clear();
|
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 });
|
client.emit('agent:start', { conversationId });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -789,6 +925,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
this.flushRedactedEgress(
|
||||||
|
client,
|
||||||
|
conversationId,
|
||||||
|
'agent:text',
|
||||||
|
this.textEgressBuffers,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.flushRedactedEgress(
|
||||||
|
client,
|
||||||
|
conversationId,
|
||||||
|
'agent:thinking',
|
||||||
|
this.thinkingEgressBuffers,
|
||||||
|
true,
|
||||||
|
);
|
||||||
client.emit('agent:end', {
|
client.emit('agent:end', {
|
||||||
conversationId,
|
conversationId,
|
||||||
usage: usagePayload,
|
usage: usagePayload,
|
||||||
@@ -831,8 +981,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
{
|
{
|
||||||
conversationId,
|
conversationId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: cs.assistantText,
|
content: redactSensitiveContent(cs.assistantText).content,
|
||||||
metadata,
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
classifications: redactSensitiveContent(cs.assistantText).classifications,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
)
|
)
|
||||||
@@ -854,20 +1007,26 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
case 'message_update': {
|
case 'message_update': {
|
||||||
const assistantEvent = event.assistantMessageEvent;
|
const assistantEvent = event.assistantMessageEvent;
|
||||||
if (assistantEvent.type === 'text_delta') {
|
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 cs = this.clientSessions.get(client.id);
|
||||||
if (cs) {
|
if (cs) {
|
||||||
cs.assistantText += assistantEvent.delta;
|
cs.assistantText += assistantEvent.delta;
|
||||||
}
|
}
|
||||||
client.emit('agent:text', {
|
this.appendAndFlushRedactedEgress(
|
||||||
|
client,
|
||||||
conversationId,
|
conversationId,
|
||||||
text: assistantEvent.delta,
|
'agent:text',
|
||||||
});
|
this.textEgressBuffers,
|
||||||
|
assistantEvent.delta,
|
||||||
|
);
|
||||||
} else if (assistantEvent.type === 'thinking_delta') {
|
} else if (assistantEvent.type === 'thinking_delta') {
|
||||||
client.emit('agent:thinking', {
|
this.appendAndFlushRedactedEgress(
|
||||||
|
client,
|
||||||
conversationId,
|
conversationId,
|
||||||
text: assistantEvent.delta,
|
'agent:thinking',
|
||||||
});
|
this.thinkingEgressBuffers,
|
||||||
|
assistantEvent.delta,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
expect(result.command).toBe('provider');
|
expect(result.command).toBe('provider');
|
||||||
});
|
});
|
||||||
|
|
||||||
// /provider login anthropic — success with URL containing poll token
|
// /provider login anthropic — no bearer token or auth URL reaches chat output
|
||||||
it('/provider login <name> returns success with URL and poll token', async () => {
|
it('/provider login <name> keeps its one-time token out of chat output', async () => {
|
||||||
const payload: SlashCommandPayload = {
|
const payload: SlashCommandPayload = {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
args: 'login anthropic',
|
args: 'login anthropic',
|
||||||
@@ -117,14 +117,9 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('provider');
|
expect(result.command).toBe('provider');
|
||||||
expect(result.message).toContain('anthropic');
|
expect(result.message).toContain('anthropic');
|
||||||
expect(result.message).toContain('http');
|
expect(result.message).not.toContain('http');
|
||||||
// data should contain loginUrl and pollToken
|
expect(result.message).not.toContain('token=');
|
||||||
expect(result.data).toBeDefined();
|
expect(result.data).toEqual({ provider: 'anthropic' });
|
||||||
const data = result.data as Record<string, unknown>;
|
|
||||||
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);
|
|
||||||
// Verify Valkey was called
|
// Verify Valkey was called
|
||||||
expect(mockRedis.set).toHaveBeenCalledOnce();
|
expect(mockRedis.set).toHaveBeenCalledOnce();
|
||||||
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
||||||
|
|||||||
@@ -436,22 +436,28 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const pollToken = crypto.randomUUID();
|
const pollToken = crypto.randomUUID();
|
||||||
const key = `mosaic:auth:poll:${pollToken}`;
|
const tokenDigest = await crypto.subtle.digest(
|
||||||
// Store pending state in Valkey (TTL 5 minutes)
|
'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(
|
await this.redis.set(
|
||||||
key,
|
key,
|
||||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||||
'EX',
|
'EX',
|
||||||
300,
|
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 {
|
return {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
success: true,
|
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,
|
conversationId,
|
||||||
data: { loginUrl, pollToken, provider: providerName },
|
data: { provider: providerName },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,8 @@ export {
|
|||||||
type LogQuery,
|
type LogQuery,
|
||||||
} from './agent-logs.js';
|
} from './agent-logs.js';
|
||||||
export { registerLogCommand } from './cli.js';
|
export { registerLogCommand } from './cli.js';
|
||||||
|
export {
|
||||||
|
redactSensitiveContent,
|
||||||
|
type RedactionResult,
|
||||||
|
type SensitiveClassification,
|
||||||
|
} from './redaction.js';
|
||||||
|
|||||||
25
packages/log/src/redaction.spec.ts
Normal file
25
packages/log/src/redaction.spec.ts
Normal file
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
packages/log/src/redaction.ts
Normal file
40
packages/log/src/redaction.ts
Normal file
@@ -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)] };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user