feat: outbound message redaction for secrets and PII (#416)
This commit is contained in:
@@ -91,6 +91,13 @@ export interface AgentConfig {
|
||||
allowedTools?: string[]; // Per-agent tool whitelist (overrides global/env ALLOWED_TOOLS)
|
||||
disallowedTools?: string[]; // Per-agent tool blocklist (overrides global/env DISALLOWED_TOOLS)
|
||||
};
|
||||
/** Security settings */
|
||||
security?: {
|
||||
redaction?: {
|
||||
secrets?: boolean;
|
||||
pii?: boolean;
|
||||
};
|
||||
};
|
||||
/** Polling config */
|
||||
polling?: PollingYamlConfig;
|
||||
/** Integrations */
|
||||
@@ -197,6 +204,17 @@ export interface LettaBotConfig {
|
||||
maxAgeDays?: number;
|
||||
};
|
||||
|
||||
// Security
|
||||
security?: {
|
||||
/** Outbound message redaction (catches leaked secrets/PII before channel delivery) */
|
||||
redaction?: {
|
||||
/** Redact common secret patterns (API keys, tokens, bearer tokens). Default: true */
|
||||
secrets?: boolean;
|
||||
/** Redact PII patterns (emails, phone numbers). Default: false */
|
||||
pii?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// API server (health checks, CLI messaging)
|
||||
/** @deprecated Use server.api instead */
|
||||
api?: {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { installSkillsToAgent, withAgentSkillsOnPath, getAgentSkillExecutableDir
|
||||
import { formatMessageEnvelope, formatGroupBatchEnvelope, type SessionContextOptions } from './formatter.js';
|
||||
import type { GroupBatcher } from './group-batcher.js';
|
||||
import { loadMemoryBlocks } from './memory.js';
|
||||
import { redactOutbound } from './redact.js';
|
||||
import { SYSTEM_PROMPT } from './system-prompt.js';
|
||||
import { parseDirectives, stripActionsBlock, type Directive } from './directives.js';
|
||||
import { resolveEmoji } from './emoji.js';
|
||||
@@ -1358,6 +1359,19 @@ export class LettaBot implements AgentSession {
|
||||
registerChannel(adapter: ChannelAdapter): void {
|
||||
adapter.onMessage = (msg) => this.handleMessage(msg, adapter);
|
||||
adapter.onCommand = (cmd, chatId) => this.handleCommand(cmd, adapter.id, chatId);
|
||||
|
||||
// Wrap outbound methods when any redaction layer is active.
|
||||
// Secrets are enabled by default unless explicitly disabled.
|
||||
const redactionConfig = this.config.redaction;
|
||||
const shouldRedact = redactionConfig?.secrets !== false || redactionConfig?.pii === true;
|
||||
if (shouldRedact) {
|
||||
const origSend = adapter.sendMessage.bind(adapter);
|
||||
adapter.sendMessage = (msg) => origSend({ ...msg, text: redactOutbound(msg.text, redactionConfig) });
|
||||
|
||||
const origEdit = adapter.editMessage.bind(adapter);
|
||||
adapter.editMessage = (chatId, messageId, text) => origEdit(chatId, messageId, redactOutbound(text, redactionConfig));
|
||||
}
|
||||
|
||||
this.channels.set(adapter.id, adapter);
|
||||
log.info(`Registered channel: ${adapter.name}`);
|
||||
}
|
||||
|
||||
103
src/core/redact.test.ts
Normal file
103
src/core/redact.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { redactOutbound } from './redact.js';
|
||||
|
||||
describe('redactOutbound', () => {
|
||||
describe('secret patterns (default: enabled)', () => {
|
||||
it('redacts OpenAI-style API keys', () => {
|
||||
expect(redactOutbound('My key is sk-abc123def456ghi789jkl012mno345')).toContain('[REDACTED]');
|
||||
expect(redactOutbound('My key is sk-abc123def456ghi789jkl012mno345')).not.toContain('sk-');
|
||||
});
|
||||
|
||||
it('redacts GitHub tokens', () => {
|
||||
expect(redactOutbound('ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijkl')).toContain('[REDACTED]');
|
||||
expect(redactOutbound('ghs_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijkl')).toContain('[REDACTED]');
|
||||
expect(redactOutbound('github_pat_ABCDEFGHIJKLMNOPQRSTUVWXYZab')).toContain('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts Slack tokens', () => {
|
||||
expect(redactOutbound('Token: xoxb-123456789-abcdefghij')).toContain('[REDACTED]');
|
||||
expect(redactOutbound('xoxp-999-888-777-abcdef')).toContain('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts AWS access keys', () => {
|
||||
expect(redactOutbound('AKIAIOSFODNN7EXAMPLE')).toContain('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts bearer tokens', () => {
|
||||
expect(redactOutbound('Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')).toContain('[REDACTED]');
|
||||
expect(redactOutbound('Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')).not.toContain('eyJ');
|
||||
});
|
||||
|
||||
it('redacts URLs with embedded credentials', () => {
|
||||
const url = 'https://admin:s3cretP4ss@database.example.com:5432/mydb';
|
||||
expect(redactOutbound(url)).toContain('[REDACTED]');
|
||||
expect(redactOutbound(url)).not.toContain('s3cretP4ss');
|
||||
});
|
||||
|
||||
it('redacts key=value patterns near sensitive keywords', () => {
|
||||
expect(redactOutbound('api_key=abcdef1234567890abcdef1234567890')).toContain('[REDACTED]');
|
||||
expect(redactOutbound('token: "aBcDeFgHiJkLmNoPqRsTuVwXyZ012345"')).toContain('[REDACTED]');
|
||||
expect(redactOutbound('secret = AAAAABBBBBCCCCCDDDDDEEEEEFFFFF00')).toContain('[REDACTED]');
|
||||
});
|
||||
|
||||
it('leaves normal text untouched', () => {
|
||||
const text = 'Hello! How are you doing today? The weather is nice.';
|
||||
expect(redactOutbound(text)).toBe(text);
|
||||
});
|
||||
|
||||
it('leaves short tokens and common words alone', () => {
|
||||
const text = 'The key to success is perseverance.';
|
||||
expect(redactOutbound(text)).toBe(text);
|
||||
});
|
||||
|
||||
it('handles empty and null-ish input', () => {
|
||||
expect(redactOutbound('')).toBe('');
|
||||
expect(redactOutbound(undefined as unknown as string)).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PII patterns (default: disabled)', () => {
|
||||
it('does not redact emails by default', () => {
|
||||
expect(redactOutbound('Contact me at user@example.com')).toContain('user@example.com');
|
||||
});
|
||||
|
||||
it('redacts emails when PII enabled', () => {
|
||||
expect(redactOutbound('Contact me at user@example.com', { pii: true })).not.toContain('user@example.com');
|
||||
expect(redactOutbound('Contact me at user@example.com', { pii: true })).toContain('[REDACTED]');
|
||||
});
|
||||
|
||||
it('redacts phone numbers when PII enabled', () => {
|
||||
expect(redactOutbound('Call me at 555-123-4567', { pii: true })).toContain('[REDACTED]');
|
||||
expect(redactOutbound('Call me at (555) 123-4567', { pii: true })).toContain('[REDACTED]');
|
||||
expect(redactOutbound('Call me at +1 555 123 4567', { pii: true })).toContain('[REDACTED]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config behavior', () => {
|
||||
it('secrets enabled by default (no config)', () => {
|
||||
expect(redactOutbound('sk-abc123def456ghi789jkl012mno345')).toContain('[REDACTED]');
|
||||
});
|
||||
|
||||
it('secrets can be explicitly disabled', () => {
|
||||
const text = 'sk-abc123def456ghi789jkl012mno345';
|
||||
expect(redactOutbound(text, { secrets: false })).toBe(text);
|
||||
});
|
||||
|
||||
it('both layers work together', () => {
|
||||
const text = 'Key: sk-abc123def456ghi789jkl012mno345 and email: user@example.com';
|
||||
const result = redactOutbound(text, { secrets: true, pii: true });
|
||||
expect(result).not.toContain('sk-');
|
||||
expect(result).not.toContain('user@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple matches in one message', () => {
|
||||
it('redacts all occurrences', () => {
|
||||
const text = 'Keys: sk-aaabbbcccdddeeefffggghhhiiijjjkkk and ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijkl';
|
||||
const result = redactOutbound(text);
|
||||
expect(result).not.toContain('sk-');
|
||||
expect(result).not.toContain('ghp_');
|
||||
expect(result.match(/\[REDACTED\]/g)?.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
72
src/core/redact.ts
Normal file
72
src/core/redact.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Outbound message redaction — catches common secret patterns before
|
||||
* text reaches channel adapters.
|
||||
*/
|
||||
|
||||
export interface RedactionConfig {
|
||||
/** Redact common secret patterns (API keys, tokens, bearer tokens). Default: true */
|
||||
secrets?: boolean;
|
||||
/** Redact PII patterns (emails, phone numbers). Default: false */
|
||||
pii?: boolean;
|
||||
}
|
||||
|
||||
const REDACTED = '[REDACTED]';
|
||||
|
||||
// ── Secret patterns ──────────────────────────────────────────────────────────
|
||||
|
||||
const SECRET_PATTERNS: RegExp[] = [
|
||||
// OpenAI / Letta API keys
|
||||
/sk-[A-Za-z0-9_-]{20,}/g,
|
||||
// GitHub tokens
|
||||
/gh[ps]_[A-Za-z0-9]{36,}/g,
|
||||
/github_pat_[A-Za-z0-9_]{22,}/g,
|
||||
// Slack tokens
|
||||
/xox[bpras]-[A-Za-z0-9-]{10,}/g,
|
||||
// AWS access keys
|
||||
/AKIA[0-9A-Z]{16}/g,
|
||||
// Generic bearer tokens in text
|
||||
/Bearer\s+[A-Za-z0-9_\-.~+/]+=*/gi,
|
||||
// URLs with embedded credentials (user:pass@host)
|
||||
/https?:\/\/[^:@\s]+:[^@\s]+@[^\s]+/g,
|
||||
// Generic long hex/base64 strings near sensitive keywords
|
||||
/(?:key|token|secret|password|apikey|api_key|auth)\s*[:=]\s*["']?[A-Za-z0-9_\-+/]{20,}["']?/gi,
|
||||
];
|
||||
|
||||
// ── PII patterns ─────────────────────────────────────────────────────────────
|
||||
|
||||
const PII_PATTERNS: RegExp[] = [
|
||||
// Email addresses
|
||||
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
|
||||
// Phone numbers (various formats)
|
||||
/(?<!\d)(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)?\d{3}[-.\s]?\d{4}(?!\d)/g,
|
||||
];
|
||||
|
||||
/**
|
||||
* Redact sensitive patterns from outbound text.
|
||||
* Returns the text with matches replaced by [REDACTED].
|
||||
*/
|
||||
export function redactOutbound(text: string, config?: RedactionConfig): string {
|
||||
if (!text) return text;
|
||||
|
||||
const redactSecrets = config?.secrets !== false; // default: true
|
||||
const redactPii = config?.pii === true; // default: false
|
||||
|
||||
let result = text;
|
||||
|
||||
if (redactSecrets) {
|
||||
for (const pattern of SECRET_PATTERNS) {
|
||||
// Reset lastIndex for global regexes
|
||||
pattern.lastIndex = 0;
|
||||
result = result.replace(pattern, REDACTED);
|
||||
}
|
||||
}
|
||||
|
||||
if (redactPii) {
|
||||
for (const pattern of PII_PATTERNS) {
|
||||
pattern.lastIndex = 0;
|
||||
result = result.replace(pattern, REDACTED);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
51
src/core/redaction-channel.test.ts
Normal file
51
src/core/redaction-channel.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { LettaBot } from './bot.js';
|
||||
import type { OutboundMessage } from './types.js';
|
||||
import type { ChannelAdapter } from '../channels/types.js';
|
||||
|
||||
describe('channel redaction wrapping', () => {
|
||||
let workDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), 'lettabot-channel-redaction-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('applies pii redaction when secrets are disabled', async () => {
|
||||
const bot = new LettaBot({
|
||||
workingDir: workDir,
|
||||
allowedTools: [],
|
||||
redaction: { secrets: false, pii: true },
|
||||
});
|
||||
|
||||
const sendSpy = vi.fn(async (_msg: OutboundMessage) => ({ messageId: 'sent-1' }));
|
||||
|
||||
const adapter: ChannelAdapter = {
|
||||
id: 'mock',
|
||||
name: 'Mock',
|
||||
start: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
isRunning: vi.fn(() => true),
|
||||
sendMessage: sendSpy,
|
||||
editMessage: vi.fn(async () => {}),
|
||||
sendTypingIndicator: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
bot.registerChannel(adapter);
|
||||
|
||||
const text = 'Email user@example.com and key sk-abc123def456ghi789jkl012mno345';
|
||||
await adapter.sendMessage({ chatId: 'chat-1', text });
|
||||
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
const sent = sendSpy.mock.calls[0][0];
|
||||
expect(sent.text).toContain('[REDACTED]');
|
||||
expect(sent.text).not.toContain('user@example.com');
|
||||
expect(sent.text).toContain('sk-abc123def456ghi789jkl012mno345');
|
||||
});
|
||||
});
|
||||
@@ -149,6 +149,7 @@ export interface BotConfig {
|
||||
memfs?: boolean; // true -> --memfs, false -> --no-memfs, undefined -> leave unchanged
|
||||
|
||||
// Security
|
||||
redaction?: import('./redact.js').RedactionConfig;
|
||||
allowedUsers?: string[]; // Empty = allow all
|
||||
sendFileDir?: string; // Restrict <send-file> directive to this directory (default: data/outbound)
|
||||
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB)
|
||||
|
||||
@@ -595,6 +595,7 @@ async function main() {
|
||||
heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active',
|
||||
conversationOverrides: agentConfig.conversations?.perChannel,
|
||||
maxSessions: agentConfig.conversations?.maxSessions,
|
||||
redaction: agentConfig.security?.redaction,
|
||||
skills: {
|
||||
cronEnabled: agentConfig.features?.cron ?? globalConfig.cronEnabled,
|
||||
googleEnabled: !!agentConfig.integrations?.google?.enabled || !!agentConfig.polling?.gmail?.enabled,
|
||||
|
||||
Reference in New Issue
Block a user