From 7a2ab85fb64510fe675d67af60acad2d46b78391 Mon Sep 17 00:00:00 2001 From: Emanuele Santonastaso Date: Fri, 27 Mar 2026 21:39:57 +0100 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20add=20Zod=20validation=20schemas=20?= =?UTF-8?q?for=20API=20routes=20=E2=80=94=20Issue=20#359?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated by Hephaestus (Aegis dev agent) --- src/__tests__/input-validation.test.ts | 243 +++++++++++++++++++++++++ src/validation.ts | 104 +++++++++++ 2 files changed, 347 insertions(+) create mode 100644 src/__tests__/input-validation.test.ts create mode 100644 src/validation.ts diff --git a/src/__tests__/input-validation.test.ts b/src/__tests__/input-validation.test.ts new file mode 100644 index 00000000..5ed61190 --- /dev/null +++ b/src/__tests__/input-validation.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect } from 'vitest'; +import { + authKeySchema, + sendMessageSchema, + commandSchema, + bashSchema, + screenshotSchema, + permissionHookSchema, + stopHookSchema, + batchSessionSchema, + pipelineSchema, + UUID_REGEX, + clamp, + parseIntSafe, + isValidUUID, +} from '../validation.js'; + +describe('authKeySchema', () => { + it('accepts valid input', () => { + expect(authKeySchema.safeParse({ name: 'my-key', rateLimit: 100 }).success).toBe(true); + }); + it('rejects missing name', () => { + expect(authKeySchema.safeParse({ rateLimit: 100 }).success).toBe(false); + }); + it('rejects negative rateLimit', () => { + expect(authKeySchema.safeParse({ name: 'k', rateLimit: -1 }).success).toBe(false); + }); + it('rejects zero rateLimit', () => { + expect(authKeySchema.safeParse({ name: 'k', rateLimit: 0 }).success).toBe(false); + }); + it('accepts without rateLimit', () => { + expect(authKeySchema.safeParse({ name: 'k' }).success).toBe(true); + }); + it('rejects extra fields', () => { + expect(authKeySchema.safeParse({ name: 'k', extra: true }).success).toBe(false); + }); +}); + +describe('sendMessageSchema', () => { + it('accepts string text', () => { + expect(sendMessageSchema.safeParse({ text: 'hello' }).success).toBe(true); + }); + it('rejects missing text', () => { + expect(sendMessageSchema.safeParse({}).success).toBe(false); + }); + it('rejects non-string text', () => { + expect(sendMessageSchema.safeParse({ text: 123 }).success).toBe(false); + }); + it('rejects empty string text', () => { + expect(sendMessageSchema.safeParse({ text: '' }).success).toBe(false); + }); +}); + +describe('commandSchema', () => { + it('accepts string command', () => { + expect(commandSchema.safeParse({ command: '/help' }).success).toBe(true); + }); + it('rejects missing command', () => { + expect(commandSchema.safeParse({}).success).toBe(false); + }); + it('rejects empty string command', () => { + expect(commandSchema.safeParse({ command: '' }).success).toBe(false); + }); +}); + +describe('bashSchema', () => { + it('accepts string command', () => { + expect(bashSchema.safeParse({ command: 'ls -la' }).success).toBe(true); + }); + it('rejects missing command', () => { + expect(bashSchema.safeParse({}).success).toBe(false); + }); +}); + +describe('screenshotSchema', () => { + it('accepts url only', () => { + expect(screenshotSchema.safeParse({ url: 'https://example.com' }).success).toBe(true); + }); + it('accepts all fields', () => { + expect(screenshotSchema.safeParse({ + url: 'https://example.com', + fullPage: true, + width: 1280, + height: 720, + }).success).toBe(true); + }); + it('rejects missing url', () => { + expect(screenshotSchema.safeParse({}).success).toBe(false); + }); + it('rejects negative width', () => { + expect(screenshotSchema.safeParse({ url: 'https://example.com', width: -1 }).success).toBe(false); + }); + it('rejects zero height', () => { + expect(screenshotSchema.safeParse({ url: 'https://example.com', height: 0 }).success).toBe(false); + }); + it('rejects excessively large dimensions', () => { + expect(screenshotSchema.safeParse({ url: 'https://example.com', width: 50000 }).success).toBe(false); + }); +}); + +describe('permissionHookSchema', () => { + it('accepts valid body', () => { + expect(permissionHookSchema.safeParse({ + tool_name: 'Bash', + tool_input: { command: 'ls' }, + permission_mode: 'default', + }).success).toBe(true); + }); + it('accepts empty body', () => { + expect(permissionHookSchema.safeParse({}).success).toBe(true); + }); + it('rejects non-string tool_name', () => { + expect(permissionHookSchema.safeParse({ tool_name: 123 }).success).toBe(false); + }); +}); + +describe('stopHookSchema', () => { + it('accepts valid body', () => { + expect(stopHookSchema.safeParse({ stop_reason: 'end_turn' }).success).toBe(true); + }); + it('accepts empty body', () => { + expect(stopHookSchema.safeParse({}).success).toBe(true); + }); + it('rejects non-string stop_reason', () => { + expect(stopHookSchema.safeParse({ stop_reason: 42 }).success).toBe(false); + }); +}); + +describe('batchSessionSchema', () => { + it('accepts valid batch', () => { + const result = batchSessionSchema.safeParse({ + sessions: [{ workDir: '/tmp' }], + }); + expect(result.success).toBe(true); + }); + it('rejects empty sessions array', () => { + expect(batchSessionSchema.safeParse({ sessions: [] }).success).toBe(false); + }); + it('rejects batch exceeding 50', () => { + const sessions = Array.from({ length: 51 }, () => ({ workDir: '/tmp' })); + expect(batchSessionSchema.safeParse({ sessions }).success).toBe(false); + }); + it('accepts batch of exactly 50', () => { + const sessions = Array.from({ length: 50 }, () => ({ workDir: '/tmp' })); + expect(batchSessionSchema.safeParse({ sessions }).success).toBe(true); + }); + it('rejects session without workDir', () => { + expect(batchSessionSchema.safeParse({ sessions: [{ name: 'x' }] }).success).toBe(false); + }); +}); + +describe('pipelineSchema', () => { + it('accepts valid pipeline', () => { + expect(pipelineSchema.safeParse({ + name: 'my-pipeline', + workDir: '/tmp', + stages: [{ name: 'build', prompt: 'npm run build' }], + }).success).toBe(true); + }); + it('rejects missing name', () => { + expect(pipelineSchema.safeParse({ + workDir: '/tmp', + stages: [{ name: 'build', prompt: 'npm run build' }], + }).success).toBe(false); + }); + it('rejects missing workDir', () => { + expect(pipelineSchema.safeParse({ + name: 'p', + stages: [{ name: 'build', prompt: 'npm run build' }], + }).success).toBe(false); + }); + it('rejects empty stages', () => { + expect(pipelineSchema.safeParse({ + name: 'p', + workDir: '/tmp', + stages: [], + }).success).toBe(false); + }); + it('rejects stage without prompt', () => { + expect(pipelineSchema.safeParse({ + name: 'p', + workDir: '/tmp', + stages: [{ name: 'build' }], + }).success).toBe(false); + }); +}); + +describe('UUID_REGEX', () => { + it('matches valid UUID', () => { + expect(UUID_REGEX.test('550e8400-e29b-41d4-a716-446655440000')).toBe(true); + }); + it('rejects non-UUID', () => { + expect(UUID_REGEX.test('not-a-uuid')).toBe(false); + }); + it('rejects empty string', () => { + expect(UUID_REGEX.test('')).toBe(false); + }); + it('rejects path traversal', () => { + expect(UUID_REGEX.test('../../etc/passwd')).toBe(false); + }); +}); + +describe('clamp', () => { + it('returns value within range', () => { + expect(clamp(50, 1, 100, 80)).toBe(50); + }); + it('clamps to min', () => { + expect(clamp(0, 1, 100, 80)).toBe(1); + }); + it('clamps to max', () => { + expect(clamp(200, 1, 100, 80)).toBe(100); + }); + it('returns fallback for NaN', () => { + expect(clamp(NaN, 1, 100, 80)).toBe(80); + }); + it('returns fallback for Infinity', () => { + expect(clamp(Infinity, 1, 100, 80)).toBe(100); + }); +}); + +describe('parseIntSafe', () => { + it('parses valid number', () => { + expect(parseIntSafe('42', 0)).toBe(42); + }); + it('returns fallback for undefined', () => { + expect(parseIntSafe(undefined, 99)).toBe(99); + }); + it('returns fallback for NaN string', () => { + expect(parseIntSafe('abc', 99)).toBe(99); + }); + it('returns fallback for Infinity string', () => { + expect(parseIntSafe('Infinity', 99)).toBe(99); + }); +}); + +describe('isValidUUID', () => { + it('returns true for valid UUID', () => { + expect(isValidUUID('550e8400-e29b-41d4-a716-446655440000')).toBe(true); + }); + it('returns false for invalid', () => { + expect(isValidUUID('abc')).toBe(false); + }); +}); diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 00000000..f97c043b --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,104 @@ +/** + * validation.ts — Zod schemas for API request body validation. + * + * Issue #359: Centralized validation for all POST route bodies. + */ + +import { z } from 'zod'; + +/** Regex for UUID v4 format: 8-4-4-4-12 hex digits */ +export const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** POST /v1/auth/keys */ +export const authKeySchema = z.object({ + name: z.string().min(1), + rateLimit: z.number().int().positive().optional(), +}).strict(); + +/** POST /v1/sessions/:id/send */ +export const sendMessageSchema = z.object({ + text: z.string().min(1), +}).strict(); + +/** POST /v1/sessions/:id/command */ +export const commandSchema = z.object({ + command: z.string().min(1), +}).strict(); + +/** POST /v1/sessions/:id/bash */ +export const bashSchema = z.object({ + command: z.string().min(1), +}).strict(); + +/** POST /v1/sessions/:id/screenshot */ +export const screenshotSchema = z.object({ + url: z.string().min(1), + fullPage: z.boolean().optional(), + width: z.number().int().positive().max(7680).optional(), + height: z.number().int().positive().max(4320).optional(), +}).strict(); + +/** POST /v1/sessions/:id/hooks/permission */ +export const permissionHookSchema = z.object({ + session_id: z.string().optional(), + tool_name: z.string().optional(), + tool_input: z.unknown().optional(), + permission_mode: z.string().optional(), + hook_event_name: z.string().optional(), +}).strict(); + +/** POST /v1/sessions/:id/hooks/stop */ +export const stopHookSchema = z.object({ + session_id: z.string().optional(), + stop_reason: z.string().optional(), + hook_event_name: z.string().optional(), +}).strict(); + +const batchSessionSpecSchema = z.object({ + name: z.string().max(200).optional(), + workDir: z.string().min(1), + prompt: z.string().max(100_000).optional(), + permissionMode: z.enum(['default', 'bypassPermissions', 'plan']).optional(), + autoApprove: z.boolean().optional(), + stallThresholdMs: z.number().int().positive().max(3_600_000).optional(), +}); + +/** POST /v1/sessions/batch — max 50 sessions per batch */ +export const batchSessionSchema = z.object({ + sessions: z.array(batchSessionSpecSchema).min(1).max(50), +}).strict(); + +const pipelineStageSchema = z.object({ + name: z.string().min(1), + workDir: z.string().min(1).optional(), + prompt: z.string().min(1), + dependsOn: z.array(z.string()).optional(), + permissionMode: z.enum(['default', 'bypassPermissions', 'plan']).optional(), + autoApprove: z.boolean().optional(), +}); + +/** POST /v1/pipelines */ +export const pipelineSchema = z.object({ + name: z.string().min(1), + workDir: z.string().min(1), + stages: z.array(pipelineStageSchema).min(1), +}).strict(); + +/** Clamp a numeric value to [min, max]. Returns default if input is NaN. */ +export function clamp(value: number, min: number, max: number, fallback: number): number { + if (Number.isNaN(value)) return fallback; + return Math.max(min, Math.min(max, value)); +} + +/** Parse an env string to integer with NaN/isFinite guard. Returns fallback on failure. */ +export function parseIntSafe(value: string | undefined, fallback: number): number { + if (value === undefined) return fallback; + const parsed = parseInt(value, 10); + if (!Number.isFinite(parsed)) return fallback; + return parsed; +} + +/** Validate that a string looks like a UUID. */ +export function isValidUUID(id: string): boolean { + return UUID_REGEX.test(id); +} From 004ebb5787c6e1f9bbff68cc95ffe30b76c42b5d Mon Sep 17 00:00:00 2001 From: Emanuele Santonastaso Date: Fri, 27 Mar 2026 21:47:12 +0100 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20add=20Zod=20safeParse=20validation?= =?UTF-8?q?=20to=20all=20API=20routes=20=E2=80=94=20Issue=20#359?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual request body validation with Zod safeParse schemas from src/validation.ts across all POST routes in server.ts. Also use parseIntSafe for zombie reaper env var parsing. Generated by Hephaestus (Aegis dev agent) --- src/server.ts | 108 +++++++++++++++++++++++++++----------------------- 1 file changed, 58 insertions(+), 50 deletions(-) diff --git a/src/server.ts b/src/server.ts index 45e8f690..02f2f131 100644 --- a/src/server.ts +++ b/src/server.ts @@ -41,6 +41,11 @@ import { registerHookRoutes } from './hooks.js'; import { registerWsTerminalRoute } from './ws-terminal.js'; import { SwarmMonitor } from './swarm-monitor.js'; import { execSync } from 'node:child_process'; +import { + authKeySchema, sendMessageSchema, commandSchema, bashSchema, + screenshotSchema, permissionHookSchema, stopHookSchema, + batchSessionSchema, pipelineSchema, parseIntSafe, +} from './validation.js'; const __filename = fileURLToPath(import.meta.url); @@ -252,10 +257,11 @@ app.get('/v1/swarm', async () => { // API key management (Issue #39) // Security: reject all auth key operations when auth is not enabled -app.post<{ Body: { name?: string; rateLimit?: number } }>('/v1/auth/keys', async (req, reply) => { +app.post('/v1/auth/keys', async (req, reply) => { if (!auth.authEnabled) return reply.status(403).send({ error: 'Auth is not enabled' }); - const { name, rateLimit } = req.body || {}; - if (!name) return reply.status(400).send({ error: 'name is required' }); + const parsed = authKeySchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); + const { name, rateLimit } = parsed.data; const result = await auth.createKey(name, rateLimit); return reply.status(201).send(result); }); @@ -571,11 +577,12 @@ app.get<{ Params: { id: string } }>('/sessions/:id/health', async (req, reply) = }); // Send message (with delivery verification — Issue #1) -app.post<{ Params: { id: string }; Body: { text: string } }>( +app.post<{ Params: { id: string } }>( '/v1/sessions/:id/send', async (req, reply) => { - const { text } = req.body; - if (!text) return reply.status(400).send({ error: 'text is required' }); + const parsed = sendMessageSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); + const { text } = parsed.data; try { const result = await sessions.sendMessage(req.params.id, text); await channels.message({ @@ -590,11 +597,12 @@ app.post<{ Params: { id: string }; Body: { text: string } }>( } }, ); -app.post<{ Params: { id: string }; Body: { text: string } }>( +app.post<{ Params: { id: string } }>( '/sessions/:id/send', async (req, reply) => { - const { text } = req.body; - if (!text) return reply.status(400).send({ error: 'text is required' }); + const parsed = sendMessageSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); + const { text } = parsed.data; try { const result = await sessions.sendMessage(req.params.id, text); await channels.message({ @@ -772,11 +780,12 @@ app.get<{ Params: { id: string } }>('/sessions/:id/pane', async (req, reply) => }); // Slash command -app.post<{ Params: { id: string }; Body: { command: string } }>( +app.post<{ Params: { id: string } }>( '/v1/sessions/:id/command', async (req, reply) => { - const { command } = req.body; - if (!command) return reply.status(400).send({ error: 'command is required' }); + const parsed = commandSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); + const { command } = parsed.data; try { const cmd = command.startsWith('/') ? command : `/${command}`; await sessions.sendMessage(req.params.id, cmd); @@ -786,11 +795,12 @@ app.post<{ Params: { id: string }; Body: { command: string } }>( } }, ); -app.post<{ Params: { id: string }; Body: { command: string } }>( +app.post<{ Params: { id: string } }>( '/sessions/:id/command', async (req, reply) => { - const { command } = req.body; - if (!command) return reply.status(400).send({ error: 'command is required' }); + const parsed = commandSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); + const { command } = parsed.data; try { const cmd = command.startsWith('/') ? command : `/${command}`; await sessions.sendMessage(req.params.id, cmd); @@ -802,11 +812,12 @@ app.post<{ Params: { id: string }; Body: { command: string } }>( ); // Bash mode -app.post<{ Params: { id: string }; Body: { command: string } }>( +app.post<{ Params: { id: string } }>( '/v1/sessions/:id/bash', async (req, reply) => { - const { command } = req.body; - if (!command) return reply.status(400).send({ error: 'command is required' }); + const parsed = bashSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); + const { command } = parsed.data; try { const cmd = command.startsWith('!') ? command : `!${command}`; await sessions.sendMessage(req.params.id, cmd); @@ -816,11 +827,12 @@ app.post<{ Params: { id: string }; Body: { command: string } }>( } }, ); -app.post<{ Params: { id: string }; Body: { command: string } }>( +app.post<{ Params: { id: string } }>( '/sessions/:id/bash', async (req, reply) => { - const { command } = req.body; - if (!command) return reply.status(400).send({ error: 'command is required' }); + const parsed = bashSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); + const { command } = parsed.data; try { const cmd = command.startsWith('!') ? command : `!${command}`; await sessions.sendMessage(req.params.id, cmd); @@ -911,10 +923,10 @@ function validateScreenshotUrl(rawUrl: string): string | null { // Screenshot capture (Issue #22) app.post<{ Params: { id: string }; - Body: { url?: string; fullPage?: boolean; width?: number; height?: number }; }>('/v1/sessions/:id/screenshot', async (req, reply) => { - const { url, fullPage, width, height } = req.body || {}; - if (!url) return reply.status(400).send({ error: 'url is required' }); + const parsed = screenshotSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); + const { url, fullPage, width, height } = parsed.data; const urlError = validateScreenshotUrl(url); if (urlError) return reply.status(400).send({ error: urlError }); @@ -939,10 +951,10 @@ app.post<{ }); app.post<{ Params: { id: string }; - Body: { url?: string; fullPage?: boolean; width?: number; height?: number }; }>('/sessions/:id/screenshot', async (req, reply) => { - const { url, fullPage, width, height } = req.body || {}; - if (!url) return reply.status(400).send({ error: 'url is required' }); + const parsed = screenshotSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); + const { url, fullPage, width, height } = parsed.data; const urlError = validateScreenshotUrl(url); if (urlError) return reply.status(400).send({ error: urlError }); @@ -1043,7 +1055,9 @@ app.post<{ const session = sessions.getSession(req.params.id); if (!session) return reply.status(404).send({ error: 'Session not found' }); - const { tool_name, tool_input, permission_mode } = req.body; + const parsed = permissionHookSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); + const { tool_name, tool_input, permission_mode } = parsed.data; // Update session status session.status = 'permission_prompt'; @@ -1078,7 +1092,9 @@ app.post<{ const session = sessions.getSession(req.params.id); if (!session) return reply.status(404).send({ error: 'Session not found' }); - const { stop_reason } = req.body; + const parsed = stopHookSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); + const { stop_reason } = parsed.data; // Update session status session.status = 'idle'; @@ -1102,14 +1118,10 @@ app.post<{ }); // Batch create (Issue #36) -app.post<{ Body: { sessions: BatchSessionSpec[] } }>('/v1/sessions/batch', async (req, reply) => { - const { sessions: specs } = req.body || {}; - if (!specs || !Array.isArray(specs) || specs.length === 0) { - return reply.status(400).send({ error: 'sessions array is required' }); - } - if (specs.some(s => !s.workDir)) { - return reply.status(400).send({ error: 'Each session must have a workDir' }); - } +app.post('/v1/sessions/batch', async (req, reply) => { + const parsed = batchSessionSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); + const specs = parsed.data.sessions; for (const spec of specs) { const safeWorkDir = validateWorkDir(spec.workDir); if (typeof safeWorkDir === 'object') { @@ -1122,21 +1134,17 @@ app.post<{ Body: { sessions: BatchSessionSpec[] } }>('/v1/sessions/batch', async }); // Pipeline create (Issue #36) -app.post<{ Body: PipelineConfig }>('/v1/pipelines', async (req, reply) => { - const config = req.body; - if (!config?.name || !config?.stages || !Array.isArray(config.stages) || config.stages.length === 0) { - return reply.status(400).send({ error: 'name and stages array are required' }); - } - if (!config.workDir) { - return reply.status(400).send({ error: 'workDir is required' }); - } - const safeWorkDir = validateWorkDir(config.workDir); +app.post('/v1/pipelines', async (req, reply) => { + const parsed = pipelineSchema.safeParse(req.body); + if (!parsed.success) return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues }); + const pipeConfig = parsed.data; + const safeWorkDir = validateWorkDir(pipeConfig.workDir); if (typeof safeWorkDir === 'object') { return reply.status(400).send({ error: `Invalid workDir: ${safeWorkDir.error}` }); } - config.workDir = safeWorkDir; + pipeConfig.workDir = safeWorkDir; try { - const pipeline = await pipelines.createPipeline(config); + const pipeline = await pipelines.createPipeline(pipeConfig); return reply.status(201).send(pipeline); } catch (e: unknown) { return reply.status(400).send({ error: e instanceof Error ? e.message : String(e) }); @@ -1186,8 +1194,8 @@ async function reapStaleSessions(maxAgeMs: number): Promise { // ── Zombie Reaper (Issue #283) ────────────────────────────────────── -const ZOMBIE_REAP_DELAY_MS = parseInt(process.env.ZOMBIE_REAP_DELAY_MS || '60000', 10); -const ZOMBIE_REAP_INTERVAL_MS = parseInt(process.env.ZOMBIE_REAP_INTERVAL_MS || '60000', 10); +const ZOMBIE_REAP_DELAY_MS = parseIntSafe(process.env.ZOMBIE_REAP_DELAY_MS, 60000); +const ZOMBIE_REAP_INTERVAL_MS = parseIntSafe(process.env.ZOMBIE_REAP_INTERVAL_MS, 60000); async function reapZombieSessions(): Promise { const now = Date.now(); From ffd5fa852c219a61a28c5ba7ee86ad01442ada5a Mon Sep 17 00:00:00 2001 From: Emanuele Santonastaso Date: Fri, 27 Mar 2026 21:49:25 +0100 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20clamp=20WebSocket=20viewport=20dimen?= =?UTF-8?q?sions=20to=201-1000=20=E2=80=94=20Issue=20#359?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated by Hephaestus (Aegis dev agent) --- src/ws-terminal.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ws-terminal.ts b/src/ws-terminal.ts index 09e675d4..3327e339 100644 --- a/src/ws-terminal.ts +++ b/src/ws-terminal.ts @@ -22,6 +22,7 @@ import type { SessionManager } from './session.js'; import type { TmuxManager } from './tmux.js'; import type { AuthManager } from './auth.js'; import type WebSocket from 'ws'; +import { clamp } from './validation.js'; const POLL_INTERVAL_MS = 500; const KEEPALIVE_INTERVAL_TICKS = 60; // 30s at 500ms intervals @@ -195,8 +196,8 @@ export function registerWsTerminalRoute( if (msg.type === 'input' && typeof msg.text === 'string') { await sessions.sendMessage(sessionId, msg.text); } else if (msg.type === 'resize') { - const cols = typeof msg.cols === 'number' ? msg.cols : 80; - const rows = typeof msg.rows === 'number' ? msg.rows : 24; + const cols = clamp(typeof msg.cols === 'number' ? msg.cols : 80, 1, 1000, 80); + const rows = clamp(typeof msg.rows === 'number' ? msg.rows : 24, 1, 1000, 24); await tmux.resizePane(session.windowId, cols, rows); } else { sendError(socket, `Unknown message type: ${(msg as { type: string }).type}`); From 4e22b18fcd45261ccfc7e30df7bd249c6e95732e Mon Sep 17 00:00:00 2001 From: Emanuele Santonastaso Date: Fri, 27 Mar 2026 21:55:14 +0100 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20validate=20UUID=20format=20for=20ses?= =?UTF-8?q?sion=20IDs=20and=20use=20exact=20workDir=20matching=20=E2=80=94?= =?UTF-8?q?=20Issue=20#359?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated by Hephaestus (Aegis dev agent) --- src/__tests__/mcp-server.test.ts | 48 +++++++++++++++----------------- src/mcp-server.ts | 13 ++++++++- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/__tests__/mcp-server.test.ts b/src/__tests__/mcp-server.test.ts index 03bf3a41..dd87dfe9 100644 --- a/src/__tests__/mcp-server.test.ts +++ b/src/__tests__/mcp-server.test.ts @@ -12,6 +12,7 @@ import { AegisClient, createMcpServer } from '../mcp-server.js'; describe('AegisClient', () => { const client = new AegisClient('http://127.0.0.1:9100', 'test-token'); + const UUID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); @@ -58,32 +59,34 @@ describe('AegisClient', () => { expect(result[0].id).toBe('s1'); }); - it('listSessions filters by workDir substring', async () => { + it('listSessions filters by workDir exact and prefix match', async () => { const mockSessions = [ { id: 's1', status: 'idle', windowName: 'cc-1', workDir: '/home/user/my-project' }, - { id: 's2', status: 'working', windowName: 'cc-2', workDir: '/home/user/other' }, + { id: 's2', status: 'working', windowName: 'cc-2', workDir: '/home/user/my-project/src' }, + { id: 's3', status: 'working', windowName: 'cc-3', workDir: '/home/user/other-project' }, ]; (fetch as any).mockResolvedValue({ ok: true, - json: () => Promise.resolve({ sessions: mockSessions, total: 2 }), + json: () => Promise.resolve({ sessions: mockSessions, total: 3 }), }); - const result = await client.listSessions({ workDir: 'my-project' }); - expect(result).toHaveLength(1); + const result = await client.listSessions({ workDir: '/home/user/my-project' }); + expect(result).toHaveLength(2); expect(result[0].id).toBe('s1'); + expect(result[1].id).toBe('s2'); }); it('getSession sends GET /v1/sessions/:id', async () => { - const mockSession = { id: 's1', status: 'idle' }; + const mockSession = { id: UUID, status: 'idle' }; (fetch as any).mockResolvedValue({ ok: true, json: () => Promise.resolve(mockSession), }); - const result = await client.getSession('s1'); - expect(result.id).toBe('s1'); + const result = await client.getSession(UUID); + expect(result.id).toBe(UUID); expect(fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:9100/v1/sessions/s1', + `http://127.0.0.1:9100/v1/sessions/${UUID}`, expect.anything(), ); }); @@ -95,7 +98,7 @@ describe('AegisClient', () => { json: () => Promise.resolve(mockHealth), }); - const result = await client.getHealth('s1'); + const result = await client.getHealth(UUID); expect(result.alive).toBe(true); }); @@ -106,7 +109,7 @@ describe('AegisClient', () => { json: () => Promise.resolve(mockTranscript), }); - const result = await client.getTranscript('s1'); + const result = await client.getTranscript(UUID); expect(result.entries).toHaveLength(1); }); @@ -117,10 +120,10 @@ describe('AegisClient', () => { json: () => Promise.resolve(mockResult), }); - const result = await client.sendMessage('s1', 'Hello session!'); + const result = await client.sendMessage(UUID, 'Hello session!'); expect(result.delivered).toBe(true); expect(fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:9100/v1/sessions/s1/send', + `http://127.0.0.1:9100/v1/sessions/${UUID}/send`, expect.objectContaining({ method: 'POST', body: JSON.stringify({ text: 'Hello session!' }), @@ -154,7 +157,7 @@ describe('AegisClient', () => { json: () => Promise.resolve({ error: 'Session not found' }), }); - await expect(client.getSession('nonexistent')).rejects.toThrow('Session not found'); + await expect(client.getSession(UUID)).rejects.toThrow('Session not found'); }); it('throws on non-ok response with fallback error', async () => { @@ -165,7 +168,7 @@ describe('AegisClient', () => { json: () => Promise.reject(new Error('not json')), }); - await expect(client.getSession('s1')).rejects.toThrow('Internal Server Error'); + await expect(client.getSession(UUID)).rejects.toThrow('Internal Server Error'); }); it('works without auth token', async () => { @@ -181,17 +184,12 @@ describe('AegisClient', () => { expect(callHeaders.Authorization).toBeUndefined(); }); - it('URL-encodes session IDs', async () => { - (fetch as any).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ id: 'a/b' }), - }); + it('rejects invalid session IDs', async () => { + await expect(client.getSession('not-a-uuid')).rejects.toThrow('Invalid session ID: not-a-uuid'); + }); - await client.getSession('a/b'); - expect(fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:9100/v1/sessions/a%2Fb', - expect.anything(), - ); + it('rejects path-traversal session IDs', async () => { + await expect(client.getSession('a/b')).rejects.toThrow('Invalid session ID: a/b'); }); }); diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 3d8cf769..d526311e 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -15,6 +15,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; +import { isValidUUID } from './validation.js'; const VERSION = '1.2.0'; @@ -23,6 +24,12 @@ const VERSION = '1.2.0'; export class AegisClient { constructor(private baseUrl: string, private authToken?: string) {} + private validateSessionId(id: string): void { + if (!isValidUUID(id)) { + throw new Error(`Invalid session ID: ${id}`); + } + } + private async request(path: string, opts?: RequestInit): Promise { const headers: Record = { 'Content-Type': 'application/json', @@ -43,24 +50,28 @@ export class AegisClient { sessions = sessions.filter((s: any) => s.status === filter.status); } if (filter?.workDir) { - sessions = sessions.filter((s: any) => s.workDir?.includes(filter.workDir!)); + sessions = sessions.filter((s: any) => s.workDir === filter.workDir || s.workDir?.startsWith(filter.workDir! + '/')); } return sessions; } async getSession(id: string): Promise { + this.validateSessionId(id); return this.request(`/v1/sessions/${encodeURIComponent(id)}`); } async getHealth(id: string): Promise { + this.validateSessionId(id); return this.request(`/v1/sessions/${encodeURIComponent(id)}/health`); } async getTranscript(id: string): Promise { + this.validateSessionId(id); return this.request(`/v1/sessions/${encodeURIComponent(id)}/read`); } async sendMessage(id: string, text: string): Promise { + this.validateSessionId(id); return this.request(`/v1/sessions/${encodeURIComponent(id)}/send`, { method: 'POST', body: JSON.stringify({ text }), From 94130069cd9813ffab9cadc841b0311c6079afcd Mon Sep 17 00:00:00 2001 From: Emanuele Santonastaso Date: Fri, 27 Mar 2026 21:57:13 +0100 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20add=20NaN/isFinite=20guards=20on=20c?= =?UTF-8?q?onfig=20env=20var=20parsing=20=E2=80=94=20Issue=20#359?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace raw parseInt(value, 10) with parseIntSafe() in applyEnvOverrides so that invalid or non-finite env var values fall back to the current config value instead of producing NaN. Generated by Hephaestus (Aegis dev agent) --- src/config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 78d4ea2c..a4cf9a4b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,7 @@ import { readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { resolve, join } from 'node:path'; import { homedir } from 'node:os'; +import { parseIntSafe } from './validation.js'; export interface Config { /** HTTP server port */ @@ -164,7 +165,7 @@ function applyEnvOverrides(config: Config): Config { case 'reaperIntervalMs': case 'sseMaxConnections': case 'sseMaxPerIp': - config[key] = parseInt(value, 10); + config[key] = parseIntSafe(value, config[key]); break; case 'webhooks': // Support comma-separated webhooks From 4246d0dd7a53a73516087026a5b0c52c655ef986 Mon Sep 17 00:00:00 2001 From: Emanuele Santonastaso Date: Fri, 27 Mar 2026 22:00:05 +0100 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20validate=20port=20numbers=20in=20CLI?= =?UTF-8?q?=20with=20parseIntSafe=20=E2=80=94=20Issue=20#359?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated by Hephaestus (Aegis dev agent) --- src/cli.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index abfc2367..e9e7e41a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,8 @@ import { execSync } from 'node:child_process'; +import { parseIntSafe } from './validation.js'; + const VERSION = '1.2.0'; function checkDependency(name: string, command: string): boolean { @@ -33,13 +35,13 @@ async function handleCreate(args: string[]): Promise { // Parse brief text (first non-flag argument) let brief = ''; let cwd = process.cwd(); - let port = parseInt(process.env.AEGIS_PORT || '9100', 10); + let port = parseIntSafe(process.env.AEGIS_PORT, 9100); for (let i = 0; i < args.length; i++) { if (args[i] === '--cwd' && args[i + 1]) { cwd = args[++i]; } else if (args[i] === '--port' && args[i + 1]) { - port = parseInt(args[++i], 10); + port = parseIntSafe(args[++i], 9100); } else if (!args[i].startsWith('-')) { brief = args[i]; } @@ -166,10 +168,10 @@ async function main(): Promise { // Subcommand: mcp if (args[0] === 'mcp') { const mcpArgs = args.slice(1); - let mcpPort = parseInt(process.env.AEGIS_PORT || '9100', 10); + let mcpPort = parseIntSafe(process.env.AEGIS_PORT, 9100); const mcpPortIdx = mcpArgs.indexOf('--port'); if (mcpPortIdx !== -1 && mcpArgs[mcpPortIdx + 1]) { - mcpPort = parseInt(mcpArgs[mcpPortIdx + 1], 10); + mcpPort = parseIntSafe(mcpArgs[mcpPortIdx + 1], 9100); } const mcpAuth = process.env.AEGIS_AUTH_TOKEN; const { startMcpServer } = await import('./mcp-server.js'); @@ -216,7 +218,7 @@ async function main(): Promise { // Don't exit — server can still start, just sessions won't work } - const port = parseInt(process.env.AEGIS_PORT || '9100', 10); + const port = parseIntSafe(process.env.AEGIS_PORT, 9100); printBanner(port); console.log(` Dependencies:`);