feat(security): add interactive prompt system for secret handling

This commit is contained in:
Rome Thorstenson 2026-01-30 07:23:10 -08:00
parent 9ff93bf433
commit 83bca1871b
2 changed files with 446 additions and 0 deletions

View File

@ -0,0 +1,176 @@
/**
* State management for pending interactive security prompts.
* Tracks users who are in the middle of responding to secret detection alerts.
*/
import type { DetectedSecret } from './entropy.js';
export type PendingPromptAction = 'redact' | 'cancel' | 'allow';
export type PendingSecurityPrompt = {
/** Unique key for this prompt (channel:senderId or sessionKey). */
key: string;
/** Detected secrets that triggered the prompt. */
secrets: DetectedSecret[];
/** When the prompt was created. */
createdAt: number;
/** Timeout in milliseconds. */
timeoutMs: number;
/** Promise resolver for the user's choice. */
resolve: (action: PendingPromptAction | null) => void;
/** Timeout handle for cleanup. */
timeoutHandle: ReturnType<typeof setTimeout>;
};
// Global in-memory store for pending prompts
const pendingPrompts = new Map<string, PendingSecurityPrompt>();
/**
* Generate a unique key for a pending prompt.
*/
export function generatePromptKey(channel: string, senderId: string): string {
return `${channel}:${senderId}`;
}
/**
* Register a pending security prompt and return a promise that resolves when the user responds.
*
* @param key - Unique key (channel:senderId)
* @param secrets - Detected secrets
* @param timeoutMs - How long to wait for user response
* @returns Promise that resolves with user's choice or null on timeout
*/
export function registerPendingPrompt(
key: string,
secrets: DetectedSecret[],
timeoutMs: number,
): Promise<PendingPromptAction | null> {
// Cancel any existing prompt for this user
const existing = pendingPrompts.get(key);
if (existing) {
clearTimeout(existing.timeoutHandle);
existing.resolve(null); // Timeout the old one
}
return new Promise<PendingPromptAction | null>((resolve) => {
const timeoutHandle = setTimeout(() => {
// Timeout reached - clean up and resolve with null
pendingPrompts.delete(key);
resolve(null);
}, timeoutMs);
const prompt: PendingSecurityPrompt = {
key,
secrets,
createdAt: Date.now(),
timeoutMs,
resolve,
timeoutHandle,
};
pendingPrompts.set(key, prompt);
});
}
/**
* Check if there's a pending prompt for a given key.
*/
export function hasPendingPrompt(key: string): boolean {
return pendingPrompts.has(key);
}
/**
* Get a pending prompt by key.
*/
export function getPendingPrompt(key: string): PendingSecurityPrompt | undefined {
return pendingPrompts.get(key);
}
/**
* Resolve a pending prompt with the user's choice.
* This should be called when the user responds to the security alert.
*
* @param key - Unique key for the prompt
* @param action - User's chosen action
* @returns True if a pending prompt was found and resolved
*/
export function resolvePendingPrompt(
key: string,
action: PendingPromptAction | null,
): boolean {
const prompt = pendingPrompts.get(key);
if (!prompt) {
return false;
}
// Clean up
clearTimeout(prompt.timeoutHandle);
pendingPrompts.delete(key);
// Resolve the promise
prompt.resolve(action);
return true;
}
/**
* Cancel a pending prompt (e.g., if the user sends a different message).
*
* @param key - Unique key for the prompt
* @returns True if a pending prompt was found and cancelled
*/
export function cancelPendingPrompt(key: string): boolean {
return resolvePendingPrompt(key, null);
}
/**
* Parse a user's response to a security prompt.
* Expects "1", "2", or "3" (or variations like "1.", "option 1", etc.)
*
* @param text - User's message text
* @returns Parsed action or null if invalid
*/
export function parsePromptResponse(text: string): PendingPromptAction | null {
const normalized = text.trim().toLowerCase();
// Direct number matches
if (normalized === '1' || normalized === '1.' || normalized.includes('option 1')) {
return 'redact';
}
if (normalized === '2' || normalized === '2.' || normalized.includes('option 2')) {
return 'cancel';
}
if (normalized === '3' || normalized === '3.' || normalized.includes('option 3')) {
return 'allow';
}
// Keyword matches
if (normalized.includes('redact') || normalized.includes('hide')) {
return 'redact';
}
if (normalized.includes('cancel') || normalized.includes('abort')) {
return 'cancel';
}
if (normalized.includes('allow') || normalized.includes('continue')) {
return 'allow';
}
return null;
}
/**
* Clear all pending prompts (useful for testing or shutdown).
*/
export function clearAllPendingPrompts(): void {
for (const prompt of pendingPrompts.values()) {
clearTimeout(prompt.timeoutHandle);
prompt.resolve(null);
}
pendingPrompts.clear();
}
/**
* Get count of pending prompts (useful for debugging/monitoring).
*/
export function getPendingPromptCount(): number {
return pendingPrompts.size;
}

View File

@ -0,0 +1,270 @@
/**
* Secret detection and handling for inbound messages.
* Integrates entropy detection with interactive user prompts and storage.
*/
import type { MoltbotConfig } from '../config/config.js';
import type { FinalizedMsgContext } from '../auto-reply/templating.js';
import type { ReplyDispatcher } from '../auto-reply/reply/reply-dispatcher.js';
import {
detectHighEntropyStrings,
redactSecrets,
type DetectedSecret,
} from './entropy.js';
import {
logHighEntropyDetected,
logSecretRedacted,
logSecretAllowedByUser,
logInteractivePromptTimeout,
logInteractivePromptCancelled,
} from './events.js';
export type SecretProcessingResult = {
/** Whether secrets were detected. */
detected: boolean;
/** Whether the message was modified (redacted or replaced). */
modified: boolean;
/** Updated context with safe body text. */
ctx: FinalizedMsgContext;
/** Whether the message should be blocked (user cancelled). */
blocked: boolean;
};
/**
* Get default action from config with fallback.
*/
function getDefaultAction(
cfg: MoltbotConfig,
): 'redact' | 'block' | 'allow' {
return cfg.security?.secrets?.handling?.defaultAction || 'redact';
}
/**
* Get timeout for interactive prompts from config.
*/
function getConfirmationTimeoutMs(cfg: MoltbotConfig): number {
return cfg.security?.secrets?.handling?.confirmationTimeoutMs || 15000;
}
/**
* Check if secret detection is enabled in config.
*/
function isSecretDetectionEnabled(cfg: MoltbotConfig): boolean {
return cfg.security?.secrets?.detection?.enabled !== false; // Default: true
}
/**
* Check if interactive mode is enabled in config.
*/
function isInteractiveModeEnabled(cfg: MoltbotConfig): boolean {
return cfg.security?.secrets?.handling?.interactive !== false; // Default: true
}
/**
* Check if the channel supports interactive prompts.
* For Phase 1, we support interactive mode for most channels.
*/
function supportsInteractiveMode(ctx: FinalizedMsgContext): boolean {
const channel = ctx.Provider || ctx.Surface;
// Most channels support sending replies for interactive prompts
// Exceptions might be added later for channels with limitations
return !!channel;
}
/**
* Send an interactive prompt to the user and wait for response.
* Returns the selected action or null if timeout/cancelled.
*/
async function promptUserForAction(
secrets: DetectedSecret[],
ctx: FinalizedMsgContext,
dispatcher: ReplyDispatcher,
timeoutMs: number,
): Promise<'redact' | 'cancel' | 'allow' | null> {
const {
generatePromptKey,
registerPendingPrompt,
} = await import('./interactive-prompts.js');
// Build secret summary
const secretSummary = secrets
.slice(0, 3) // Show up to 3 secrets
.map((s) => {
const preview = s.value.slice(0, 12) + '...' + s.value.slice(-6);
const label = s.pattern || s.type;
return `${preview} (${label})`;
})
.join('\n');
const moreSummary =
secrets.length > 3 ? `\n• ...and ${secrets.length - 3} more` : '';
const promptMessage = `🔒 **Security Alert**
Your message contains what appears to be ${secrets.length} secret${secrets.length > 1 ? 's' : ''} or API key${secrets.length > 1 ? 's' : ''}:
${secretSummary}${moreSummary}
**Options:**
1 Redact - Replace with [REDACTED] before processing
2 Cancel - Don't process this message
3 Continue anyway - Send to AI as-is (not recommended)
Reply with **1**, **2**, or **3** (timeout in ${Math.round(timeoutMs / 1000)}s)`;
// Generate unique key for this user
const channel = ctx.Provider || ctx.Surface || 'unknown';
const senderId = ctx.SenderId || 'unknown';
const promptKey = generatePromptKey(channel, senderId);
// Register the pending prompt (returns a promise that will be resolved when user responds)
const responsePromise = registerPendingPrompt(promptKey, secrets, timeoutMs);
// Send the prompt message
await dispatcher.sendText(promptMessage);
// Wait for user response (or timeout)
const action = await responsePromise;
return action;
}
/**
* Apply the selected action to the context.
*/
function applyAction(
action: 'redact' | 'allow',
secrets: DetectedSecret[],
ctx: FinalizedMsgContext,
cfg: MoltbotConfig,
): { ctx: FinalizedMsgContext; modified: boolean } {
if (action === 'allow') {
// No modification
logSecretAllowedByUser(secrets.length, {
channel: ctx.Provider || ctx.Surface,
senderId: ctx.SenderId,
});
return { ctx, modified: false };
}
if (action === 'redact') {
// Redact secrets from all body fields
const redactedBody = redactSecrets(ctx.Body || '', secrets);
const redactedBodyForAgent = redactSecrets(ctx.BodyForAgent || '', secrets);
const redactedCommandBody = redactSecrets(ctx.CommandBody || '', secrets);
const redactedBodyForCommands = redactSecrets(ctx.BodyForCommands || '', secrets);
logSecretRedacted(secrets.length, 'auto', {
channel: ctx.Provider || ctx.Surface,
senderId: ctx.SenderId,
});
return {
ctx: {
...ctx,
Body: redactedBody,
BodyForAgent: redactedBodyForAgent,
CommandBody: redactedCommandBody,
BodyForCommands: redactedBodyForCommands,
},
modified: true,
};
}
// Shouldn't reach here
return { ctx, modified: false };
}
/**
* Process a message for secret detection and handling.
* This is the main entry point for secret security processing.
*
* @param ctx - Finalized message context
* @param cfg - Moltbot configuration
* @param dispatcher - Reply dispatcher for interactive prompts
* @returns SecretProcessingResult with updated context and metadata
*/
export async function processSecretsInMessage(
ctx: FinalizedMsgContext,
cfg: MoltbotConfig,
dispatcher: ReplyDispatcher,
): Promise<SecretProcessingResult> {
// Check if detection is enabled
if (!isSecretDetectionEnabled(cfg)) {
return { detected: false, modified: false, ctx, blocked: false };
}
// Detect secrets in the main body (BodyForAgent is most comprehensive)
const body = ctx.BodyForAgent || ctx.Body || '';
const detectionConfig = cfg.security?.secrets?.detection;
const result = detectHighEntropyStrings(body, {
minEntropyThreshold: detectionConfig?.minEntropyThreshold,
minLength: detectionConfig?.minLength,
customPatterns: detectionConfig?.customPatterns,
});
if (!result.hasSecrets) {
return { detected: false, modified: false, ctx, blocked: false };
}
// Secrets detected - log event
logHighEntropyDetected(
result.secrets.length,
result.secrets.map((s) => s.type),
{
channel: ctx.Provider || ctx.Surface,
senderId: ctx.SenderId,
},
);
// Determine how to handle the secrets
let action: 'store' | 'redact' | 'allow' | null = null;
const interactive = isInteractiveModeEnabled(cfg);
const supportsInteractive = supportsInteractiveMode(ctx);
if (interactive && supportsInteractive) {
// Try interactive prompt
const timeoutMs = getConfirmationTimeoutMs(cfg);
const userChoice = await promptUserForAction(
result.secrets,
ctx,
dispatcher,
timeoutMs,
);
if (userChoice === 'cancel') {
logInteractivePromptCancelled({
channel: ctx.Provider || ctx.Surface,
senderId: ctx.SenderId,
});
return { detected: true, modified: false, ctx, blocked: true };
}
if (userChoice && userChoice !== 'cancel') {
action = userChoice;
} else {
// Timeout or not implemented - fall back to default
const defaultAction = getDefaultAction(cfg);
logInteractivePromptTimeout(defaultAction, {
channel: ctx.Provider || ctx.Surface,
senderId: ctx.SenderId,
});
action = defaultAction === 'block' ? 'redact' : defaultAction; // Treat block as redact in non-interactive
}
} else {
// Non-interactive mode or channel doesn't support it
const defaultAction = getDefaultAction(cfg);
action = defaultAction === 'block' ? 'redact' : defaultAction; // Treat block as redact
}
// Apply the action
const { ctx: updatedCtx, modified } = applyAction(action, result.secrets, ctx, cfg);
return {
detected: true,
modified,
ctx: updatedCtx,
blocked: false,
};
}