fix(#337): Add Zod validation for Redis deserialization

- Created Zod schemas for TaskState, AgentState, and OrchestratorEvent
- Added ValkeyValidationError class for detailed error context
- Validate task and agent state data after JSON.parse
- Validate events in subscribeToEvents handler
- Corrupted/tampered data now rejected with clear errors including:
  - Key name for context
  - Data snippet (truncated to 100 chars)
  - Underlying Zod validation error
- Prevents silent propagation of invalid data (SEC-ORCH-6)
- Added 20 new tests for validation scenarios

Refs #337

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-05 15:54:48 -06:00
parent 6a4f58dc1c
commit 6552edaa11
4 changed files with 551 additions and 24 deletions

View File

@@ -1,4 +1,5 @@
import Redis from "ioredis";
import { ZodError } from "zod";
import type {
TaskState,
AgentState,
@@ -8,6 +9,7 @@ import type {
EventHandler,
} from "./types";
import { isValidTaskTransition, isValidAgentTransition } from "./types";
import { TaskStateSchema, AgentStateSchema, OrchestratorEventSchema } from "./schemas";
export interface ValkeyClientConfig {
host: string;
@@ -24,6 +26,21 @@ export interface ValkeyClientConfig {
*/
export type EventErrorHandler = (error: Error, rawMessage: string, channel: string) => void;
/**
* Error thrown when Redis data fails validation
*/
export class ValkeyValidationError extends Error {
constructor(
message: string,
public readonly key: string,
public readonly dataSnippet: string,
public readonly validationError: ZodError
) {
super(message);
this.name = "ValkeyValidationError";
}
}
/**
* Valkey client for state management and pub/sub
*/
@@ -66,7 +83,7 @@ export class ValkeyClient {
return null;
}
return JSON.parse(data) as TaskState;
return this.parseAndValidateTaskState(key, data);
}
async setTaskState(state: TaskState): Promise<void> {
@@ -119,7 +136,8 @@ export class ValkeyClient {
for (const key of keys) {
const data = await this.client.get(key);
if (data) {
tasks.push(JSON.parse(data) as TaskState);
const task = this.parseAndValidateTaskState(key, data);
tasks.push(task);
}
}
@@ -138,7 +156,7 @@ export class ValkeyClient {
return null;
}
return JSON.parse(data) as AgentState;
return this.parseAndValidateAgentState(key, data);
}
async setAgentState(state: AgentState): Promise<void> {
@@ -190,7 +208,8 @@ export class ValkeyClient {
for (const key of keys) {
const data = await this.client.get(key);
if (data) {
agents.push(JSON.parse(data) as AgentState);
const agent = this.parseAndValidateAgentState(key, data);
agents.push(agent);
}
}
@@ -211,17 +230,26 @@ export class ValkeyClient {
this.subscriber.on("message", (channel: string, message: string) => {
try {
const event = JSON.parse(message) as OrchestratorEvent;
const parsed: unknown = JSON.parse(message);
const event = OrchestratorEventSchema.parse(parsed);
void handler(event);
} catch (error) {
const errorObj = error instanceof Error ? error : new Error(String(error));
// Log the error
// Log the error with context
if (this.logger) {
this.logger.error(
`Failed to parse event from channel ${channel}: ${errorObj.message}`,
errorObj
);
const snippet = message.length > 100 ? `${message.substring(0, 100)}...` : message;
if (error instanceof ZodError) {
this.logger.error(
`Failed to validate event from channel ${channel}: ${errorObj.message} (data: ${snippet})`,
errorObj
);
} else {
this.logger.error(
`Failed to parse event from channel ${channel}: ${errorObj.message}`,
errorObj
);
}
}
// Invoke error handler if provided
@@ -262,4 +290,56 @@ export class ValkeyClient {
private getAgentKey(agentId: string): string {
return `orchestrator:agent:${agentId}`;
}
/**
* Parse and validate task state data from Redis
* @throws ValkeyValidationError if data is invalid
*/
private parseAndValidateTaskState(key: string, data: string): TaskState {
try {
const parsed: unknown = JSON.parse(data);
return TaskStateSchema.parse(parsed);
} catch (error) {
if (error instanceof ZodError) {
const snippet = data.length > 100 ? `${data.substring(0, 100)}...` : data;
const validationError = new ValkeyValidationError(
`Invalid task state data at key ${key}: ${error.message}`,
key,
snippet,
error
);
if (this.logger) {
this.logger.error(validationError.message, validationError);
}
throw validationError;
}
throw error;
}
}
/**
* Parse and validate agent state data from Redis
* @throws ValkeyValidationError if data is invalid
*/
private parseAndValidateAgentState(key: string, data: string): AgentState {
try {
const parsed: unknown = JSON.parse(data);
return AgentStateSchema.parse(parsed);
} catch (error) {
if (error instanceof ZodError) {
const snippet = data.length > 100 ? `${data.substring(0, 100)}...` : data;
const validationError = new ValkeyValidationError(
`Invalid agent state data at key ${key}: ${error.message}`,
key,
snippet,
error
);
if (this.logger) {
this.logger.error(validationError.message, validationError);
}
throw validationError;
}
throw error;
}
}
}