agent-ecosystem/src/main/ipc/validation.ts

158 lines
5.2 KiB
TypeScript

/**
* IPC Handlers for Validation Operations.
*
* Handlers:
* - validate-path: Validate if a file/directory path exists relative to project
* - validate-mentions: Batch validate path mentions (@file references)
* - session:scrollToLine: Deep link handler for scrolling to a specific line in a session
*/
import { createLogger } from '@shared/utils/logger';
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
import * as fsp from 'fs/promises';
import * as path from 'path';
const logger = createLogger('IPC:validation');
/**
* Registers all validation-related IPC handlers.
*/
export function registerValidationHandlers(ipcMain: IpcMain): void {
ipcMain.handle('validate-path', handleValidatePath);
ipcMain.handle('validate-mentions', handleValidateMentions);
ipcMain.handle('session:scrollToLine', handleScrollToLine);
logger.info('Validation handlers registered');
}
/**
* Removes all validation IPC handlers.
*/
export function removeValidationHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler('validate-path');
ipcMain.removeHandler('validate-mentions');
ipcMain.removeHandler('session:scrollToLine');
logger.info('Validation handlers removed');
}
// =============================================================================
// Security Helpers
// =============================================================================
/**
* Checks if a path is contained within a base directory.
* Prevents path traversal attacks (e.g., ../../etc/passwd).
*/
function isPathContained(fullPath: string, basePath: string): boolean {
const normalizedFull = normalizeForContainment(fullPath);
const normalizedBase = normalizeForContainment(basePath);
const relative = path.relative(normalizedBase, normalizedFull);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
function normalizeForContainment(value: string): string {
const resolved = path.resolve(path.normalize(value));
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}
function resolveProjectPath(projectPath: string, requestedPath: string): string {
return path.isAbsolute(requestedPath)
? path.resolve(path.normalize(requestedPath))
: path.resolve(projectPath, requestedPath);
}
// =============================================================================
// Handler Implementations
// =============================================================================
/**
* Handler for 'validate-path' IPC call.
* Validates if a file/directory path exists relative to project.
*/
async function handleValidatePath(
_event: IpcMainInvokeEvent,
relativePath: string,
projectPath: string
): Promise<{ exists: boolean; isDirectory?: boolean }> {
try {
const fullPath = resolveProjectPath(projectPath, relativePath);
// Security: Ensure path doesn't escape project directory
if (!isPathContained(fullPath, projectPath)) {
logger.warn('validate-path blocked path traversal attempt:', relativePath);
return { exists: false };
}
// Single async stat — no TOCTOU, doesn't block the main thread
const stats = await fsp.stat(fullPath);
return {
exists: true,
isDirectory: stats.isDirectory(),
};
} catch {
return { exists: false };
}
}
/**
* Handler for 'validate-mentions' IPC call.
* Batch validates path mentions (@file references).
* Slash commands do not need validation.
*/
async function handleValidateMentions(
_event: IpcMainInvokeEvent,
mentions: { type: 'path'; value: string }[],
projectPath: string
): Promise<Record<string, boolean>> {
// Validate all mentions in parallel with async I/O
// (was sequential sync existsSync — blocked main thread per mention)
const entries = await Promise.all(
mentions.map(async (mention) => {
const fullPath = resolveProjectPath(projectPath, mention.value);
// Security: Skip paths that escape project directory
if (!isPathContained(fullPath, projectPath)) {
return [`@${mention.value}`, false] as const;
}
try {
await fsp.access(fullPath);
return [`@${mention.value}`, true] as const;
} catch {
return [`@${mention.value}`, false] as const;
}
})
);
return Object.fromEntries(entries);
}
/**
* Handler for 'session:scrollToLine' IPC call.
* Used for deep linking from notifications to specific lines in a session.
* The actual scrolling happens in the renderer; this handler validates and returns the data.
*/
async function handleScrollToLine(
_event: IpcMainInvokeEvent,
sessionId: string,
lineNumber: number
): Promise<{ success: boolean; sessionId: string; lineNumber: number }> {
try {
if (!sessionId) {
logger.error('session:scrollToLine called with empty sessionId');
return { success: false, sessionId: '', lineNumber: 0 };
}
if (typeof lineNumber !== 'number' || lineNumber < 0) {
logger.error('session:scrollToLine called with invalid lineNumber');
return { success: false, sessionId, lineNumber: 0 };
}
return { success: true, sessionId, lineNumber };
} catch (error) {
logger.error(`Error in session:scrollToLine:`, error);
return { success: false, sessionId: '', lineNumber: 0 };
}
}