diff --git a/src/config/types.ts b/src/config/types.ts index 3a59746..830bc5d 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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?: { diff --git a/src/core/bot.ts b/src/core/bot.ts index a7a2bbd..45ed44d 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -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}`); } diff --git a/src/core/redact.test.ts b/src/core/redact.test.ts new file mode 100644 index 0000000..c455f82 --- /dev/null +++ b/src/core/redact.test.ts @@ -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); + }); + }); +}); diff --git a/src/core/redact.ts b/src/core/redact.ts new file mode 100644 index 0000000..d11006d --- /dev/null +++ b/src/core/redact.ts @@ -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) + /(? { + 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'); + }); +}); diff --git a/src/core/types.ts b/src/core/types.ts index 514216b..ce6431c 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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 directive to this directory (default: data/outbound) sendFileMaxSize?: number; // Max file size in bytes for (default: 50MB) diff --git a/src/main.ts b/src/main.ts index 5682090..b8ead50 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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,