import { getCurrentTimestamp } from "./time.js";
/**
* Error classes for preserving context in error chains
*
* These custom errors ensure we never lose debug information when catching
* and re-throwing errors. All include:
* - Original error as 'cause' (preserves stack trace)
* - Contextual information (host, command, operation)
* - Structured message format
*/
/**
* Base error for host operations (SSH, Docker API)
*/
export class HostOperationError extends Error {
constructor(
message: string,
public readonly hostName: string,
public readonly operation: string,
cause?: unknown
) {
const fullMessage = `[Host: ${hostName}] [Op: ${operation}] ${message}`;
super(fullMessage, { cause });
this.name = "HostOperationError";
// Preserve original error cause for debugging
if (cause instanceof Error) {
this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
}
}
}
/**
* SSH command execution error with full context
*/
export class SSHCommandError extends Error {
constructor(
message: string,
public readonly hostName: string,
public readonly command: string,
public readonly exitCode?: number,
public readonly stderr?: string,
public readonly stdout?: string,
cause?: unknown
) {
const fullMessage = [
`[SSH] [Host: ${hostName}] ${message}`,
`Command: ${command}`,
exitCode !== undefined ? `Exit code: ${exitCode}` : null,
stderr ? `Stderr: ${stderr}` : null,
]
.filter(Boolean)
.join("\n");
super(fullMessage, { cause });
this.name = "SSHCommandError";
if (cause instanceof Error) {
this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
}
}
}
/**
* Docker Compose operation error
*/
export class ComposeOperationError extends Error {
constructor(
message: string,
public readonly hostName: string,
public readonly project: string,
public readonly action: string,
cause?: unknown
) {
const fullMessage = `[Compose] [Host: ${hostName}] [Project: ${project}] [Action: ${action}] ${message}`;
super(fullMessage, { cause });
this.name = "ComposeOperationError";
if (cause instanceof Error) {
this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
}
}
}
/**
* Validation error for input schema failures
*/
export class ValidationError extends Error {
constructor(
message: string,
public readonly handlerName: string,
public readonly issues: string[],
cause?: unknown
) {
const fullMessage = `[Validation] [Handler: ${handlerName}] ${message}\nIssues: ${issues.join(", ")}`;
super(fullMessage, { cause });
this.name = "ValidationError";
if (cause instanceof Error) {
this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
}
}
}
/**
* Additional context for error logging
*/
export interface ErrorContext {
requestId?: string;
userId?: string;
operation?: string;
metadata?: Record<string, unknown>;
}
/**
* Fields that may contain sensitive operational details and should be redacted in logs
*
* SECURITY (CWE-532): Redact fields that could contain credentials or secrets.
* Operational identifiers (host, container_id, project) are intentionally NOT redacted
* because they are needed for debugging and do not contain secrets.
*/
const SENSITIVE_PARAM_FIELDS = new Set([
// Command execution
"command", // Shell commands may contain credentials or secrets
"args", // Command arguments
// File system paths
"path", // File paths may reveal system structure
"target", // File/directory targets
"source", // Source paths
"destination", // Destination paths
"compose_file", // Compose file paths reveal infrastructure layout
// Search/filter patterns
"grep", // Search patterns may contain sensitive data
"label_filter", // Docker labels may contain internal info
"name_filter", // Container/image names may be sensitive
// Environment
"env", // Environment variables (if we add this feature)
]);
/**
* Sanitize params for safe logging by redacting potentially sensitive fields
*
* Preserves safe operational fields (action, subaction, host, response_format, etc.)
* while redacting fields that may contain sensitive data (paths, commands, filters).
*
* @param params - Parameters to sanitize
* @returns Sanitized copy of params with sensitive fields redacted
*
* @example
* sanitizeParams({
* action: 'scout',
* command: 'cat /etc/passwd',
* host: 'web-01',
* path: '/etc/secrets'
* })
* // Returns: { action: 'scout', command: '[REDACTED]', host: 'web-01', path: '[REDACTED]' }
*/
export function sanitizeParams(params: unknown): unknown {
// Handle non-object types
if (typeof params !== "object" || params === null) {
return params;
}
// Handle arrays (unlikely but defensive)
if (Array.isArray(params)) {
return "[REDACTED]";
}
// Sanitize object fields
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params)) {
if (SENSITIVE_PARAM_FIELDS.has(key)) {
sanitized[key] = "[REDACTED]";
} else if (typeof value === "object" && value !== null) {
// Nested objects are redacted entirely (avoiding deep traversal)
sanitized[key] = "[REDACTED]";
} else {
sanitized[key] = value;
}
}
return sanitized;
}
/**
* Log error with structured context
*
* NEVER use this to silently swallow errors - always re-throw after logging
* if the error should propagate.
*
* @param error - Error to log (any type)
* @param context - Additional context information
*/
export function logError(error: unknown, context?: ErrorContext): void {
const timestamp = getCurrentTimestamp();
const parts: string[] = [`[${timestamp}]`];
if (context?.requestId) {
parts.push(`[Request: ${context.requestId}]`);
}
if (context?.operation) {
parts.push(`[Operation: ${context.operation}]`);
}
// Extract error details
if (error instanceof HostOperationError) {
parts.push(`[Host: ${error.hostName}]`);
parts.push(`[Op: ${error.operation}]`);
} else if (error instanceof SSHCommandError) {
parts.push(`[Host: ${error.hostName}]`);
parts.push(`[Command: ${error.command}]`);
} else if (error instanceof ComposeOperationError) {
parts.push(`[Host: ${error.hostName}]`);
parts.push(`[Project: ${error.project}]`);
parts.push(`[Action: ${error.action}]`);
}
if (error instanceof Error) {
parts.push(error.name);
parts.push(error.message);
console.error(parts.join(" "));
if (error.stack) {
console.error(error.stack);
}
if (context?.metadata) {
console.error("Metadata:", JSON.stringify(context.metadata));
}
} else {
parts.push(String(error));
console.error(parts.join(" "));
}
}