feat: outbound message redaction for secrets and PII (#416)

This commit is contained in:
Cameron
2026-02-26 15:39:49 -08:00
committed by GitHub
parent 9cf929a716
commit 3bf245a84e
7 changed files with 260 additions and 0 deletions

View File

@@ -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?: {

View File

@@ -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
View 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
View 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;
}

View 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');
});
});

View File

@@ -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)

View File

@@ -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,