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

251 lines
7.2 KiB
TypeScript

/**
* IPC guard utilities for runtime validation and coercion.
*
* Main goals:
* - Reject malformed IDs and unbounded inputs at IPC boundaries
* - Keep validation logic consistent across handlers
*/
import { isValidProjectId } from '@main/utils/pathDecoder';
import { isWindowsReservedFileName } from '@main/utils/pathValidation';
const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const SUBAGENT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const NOTIFICATION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const TRIGGER_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
const TASK_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$/;
const MEMBER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const FROM_FIELD_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
const MAX_QUERY_LENGTH = 512;
const MAX_RESULTS = 200;
const MAX_PAGE_LIMIT = 200;
interface ValidationResult<T> {
valid: boolean;
value?: T;
error?: string;
}
const RESERVED_MEMBER_NAMES = new Set<string>(['user']);
const RESERVED_TEAMMATE_NAMES = new Set<string>(['team-lead']);
function validateString(
value: unknown,
fieldName: string,
maxLength: number = 256
): ValidationResult<string> {
if (typeof value !== 'string') {
return { valid: false, error: `${fieldName} must be a string` };
}
const trimmed = value.trim();
if (trimmed.length === 0) {
return { valid: false, error: `${fieldName} cannot be empty` };
}
if (trimmed.length > maxLength) {
return { valid: false, error: `${fieldName} exceeds max length (${maxLength})` };
}
return { valid: true, value: trimmed };
}
function rejectWindowsReserved(value: string, fieldName: string): ValidationResult<never> | null {
return isWindowsReservedFileName(value)
? { valid: false, error: `${fieldName} is reserved on Windows` }
: null;
}
export function validateProjectId(projectId: unknown): ValidationResult<string> {
const basic = validateString(projectId, 'projectId');
if (!basic.valid) {
return basic;
}
if (!isValidProjectId(basic.value!)) {
return { valid: false, error: 'projectId is not a valid encoded Claude project path' };
}
return { valid: true, value: basic.value };
}
export function validateSessionId(sessionId: unknown): ValidationResult<string> {
const basic = validateString(sessionId, 'sessionId', 128);
if (!basic.valid) {
return basic;
}
if (!SESSION_ID_PATTERN.test(basic.value!)) {
return { valid: false, error: 'sessionId contains invalid characters' };
}
return { valid: true, value: basic.value };
}
export function validateSubagentId(subagentId: unknown): ValidationResult<string> {
const basic = validateString(subagentId, 'subagentId', 128);
if (!basic.valid) {
return basic;
}
if (!SUBAGENT_ID_PATTERN.test(basic.value!)) {
return { valid: false, error: 'subagentId contains invalid characters' };
}
return { valid: true, value: basic.value };
}
export function validateNotificationId(notificationId: unknown): ValidationResult<string> {
const basic = validateString(notificationId, 'notificationId', 128);
if (!basic.valid) {
return basic;
}
if (!NOTIFICATION_ID_PATTERN.test(basic.value!)) {
return { valid: false, error: 'notificationId contains invalid characters' };
}
return { valid: true, value: basic.value };
}
export function validateTriggerId(triggerId: unknown): ValidationResult<string> {
const basic = validateString(triggerId, 'triggerId', 128);
if (!basic.valid) {
return basic;
}
if (!TRIGGER_ID_PATTERN.test(basic.value!)) {
return { valid: false, error: 'triggerId contains invalid characters' };
}
return { valid: true, value: basic.value };
}
export function validateTeamName(teamName: unknown): ValidationResult<string> {
const basic = validateString(teamName, 'teamName', 128);
if (!basic.valid) {
return basic;
}
if (!TEAM_NAME_PATTERN.test(basic.value!)) {
return { valid: false, error: 'teamName contains invalid characters' };
}
const reserved = rejectWindowsReserved(basic.value!, 'teamName');
if (reserved) {
return reserved;
}
return { valid: true, value: basic.value };
}
export function validateTaskId(taskId: unknown): ValidationResult<string> {
const basic = validateString(taskId, 'taskId', 64);
if (!basic.valid) {
return basic;
}
if (!TASK_ID_PATTERN.test(basic.value!)) {
return { valid: false, error: 'taskId contains invalid characters' };
}
const reserved = rejectWindowsReserved(basic.value!, 'taskId');
if (reserved) {
return reserved;
}
return { valid: true, value: basic.value };
}
export function validateMemberName(memberName: unknown): ValidationResult<string> {
const basic = validateString(memberName, 'member', 128);
if (!basic.valid) {
return basic;
}
if (!MEMBER_NAME_PATTERN.test(basic.value!)) {
return { valid: false, error: 'member contains invalid characters' };
}
const windowsReserved = rejectWindowsReserved(basic.value!, 'member');
if (windowsReserved) {
return windowsReserved;
}
const lower = basic.value!.toLowerCase();
if (RESERVED_MEMBER_NAMES.has(lower)) {
return { valid: false, error: `member name "${basic.value!}" is reserved` };
}
return { valid: true, value: basic.value };
}
/**
* Teammate names are user-created members (not the lead process).
* This validation forbids reserved system names (lead + human).
*/
export function validateTeammateName(memberName: unknown): ValidationResult<string> {
const basic = validateMemberName(memberName);
if (!basic.valid) {
return basic;
}
const lower = basic.value!.toLowerCase();
if (RESERVED_TEAMMATE_NAMES.has(lower)) {
return { valid: false, error: `member name "${basic.value!}" is reserved` };
}
return basic;
}
export function validateFromField(from: unknown): ValidationResult<string> {
const basic = validateString(from, 'from', 128);
if (!basic.valid) {
return basic;
}
if (!FROM_FIELD_PATTERN.test(basic.value!)) {
return { valid: false, error: 'from contains invalid characters' };
}
return { valid: true, value: basic.value };
}
export function validateSearchQuery(query: unknown): ValidationResult<string> {
if (typeof query !== 'string') {
return { valid: false, error: 'query must be a string' };
}
const trimmed = query.trim();
if (trimmed.length === 0) {
return { valid: false, error: 'query cannot be empty' };
}
if (trimmed.length > MAX_QUERY_LENGTH) {
return { valid: false, error: `query exceeds max length (${MAX_QUERY_LENGTH})` };
}
return { valid: true, value: trimmed };
}
function coerceLimit(value: unknown, defaultValue: number, maxValue: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return defaultValue;
}
const normalized = Math.floor(value);
if (normalized <= 0) {
return defaultValue;
}
return Math.min(normalized, maxValue);
}
export function coerceSearchMaxResults(value: unknown, defaultValue: number = 50): number {
return coerceLimit(value, defaultValue, MAX_RESULTS);
}
export function coercePageLimit(value: unknown, defaultValue: number = 20): number {
return coerceLimit(value, defaultValue, MAX_PAGE_LIMIT);
}