From 734ef10fbb40346d97c903b1905ce153a6898c8a Mon Sep 17 00:00:00 2001 From: ismeth Date: Fri, 20 Feb 2026 14:33:16 +0100 Subject: [PATCH] fix(athena): add schema validation for unique names and sanitization --- src/config/schema/athena.test.ts | 152 ++++++++++++++++++++++++++++++- src/config/schema/athena.ts | 13 ++- 2 files changed, 159 insertions(+), 6 deletions(-) diff --git a/src/config/schema/athena.test.ts b/src/config/schema/athena.test.ts index 14a1ba369..d32956ad8 100644 --- a/src/config/schema/athena.test.ts +++ b/src/config/schema/athena.test.ts @@ -181,6 +181,86 @@ describe("CouncilMemberSchema", () => { //#then expect(result.success).toBe(false) }) + + test("trims leading and trailing whitespace from name", () => { + //#given + const config = { model: "anthropic/claude-opus-4-6", name: " member-a " } + + //#when + const result = CouncilMemberSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.name).toBe("member-a") + } + }) + + test("accepts name with spaces like 'Claude Opus 4'", () => { + //#given + const config = { model: "anthropic/claude-opus-4-6", name: "Claude Opus 4" } + + //#when + const result = CouncilMemberSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + }) + + test("accepts name with dots like 'Claude 4.6'", () => { + //#given + const config = { model: "anthropic/claude-opus-4-6", name: "Claude 4.6" } + + //#when + const result = CouncilMemberSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + }) + + test("accepts name with hyphens like 'my-model-1'", () => { + //#given + const config = { model: "anthropic/claude-opus-4-6", name: "my-model-1" } + + //#when + const result = CouncilMemberSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + }) + + test("rejects name with special characters like '@'", () => { + //#given + const config = { model: "anthropic/claude-opus-4-6", name: "member@1" } + + //#when + const result = CouncilMemberSchema.safeParse(config) + + //#then + expect(result.success).toBe(false) + }) + + test("rejects name with exclamation mark", () => { + //#given + const config = { model: "anthropic/claude-opus-4-6", name: "member!" } + + //#when + const result = CouncilMemberSchema.safeParse(config) + + //#then + expect(result.success).toBe(false) + }) + + test("rejects name starting with a space after trim", () => { + //#given + const config = { model: "anthropic/claude-opus-4-6", name: " " } + + //#when + const result = CouncilMemberSchema.safeParse(config) + + //#then + expect(result.success).toBe(false) + }) }) describe("CouncilConfigSchema", () => { @@ -249,13 +329,60 @@ describe("CouncilConfigSchema", () => { //#then expect(result.success).toBe(false) }) + + test("rejects council with duplicate member names", () => { + //#given + const config = { + members: [ + { model: "anthropic/claude-opus-4-6", name: "analyst" }, + { model: "openai/gpt-5.3-codex", name: "analyst" }, + ], + } + + //#when + const result = CouncilConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(false) + }) + + test("rejects council with case-insensitive duplicate names", () => { + //#given + const config = { + members: [ + { model: "anthropic/claude-opus-4-6", name: "Claude" }, + { model: "openai/gpt-5.3-codex", name: "claude" }, + ], + } + + //#when + const result = CouncilConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(false) + }) + + test("accepts council with unique member names", () => { + //#given + const config = { + members: [ + { model: "anthropic/claude-opus-4-6", name: "analyst-a" }, + { model: "openai/gpt-5.3-codex", name: "analyst-b" }, + ], + } + + //#when + const result = CouncilConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + }) }) describe("AthenaConfigSchema", () => { - test("accepts Athena config with model and council", () => { + test("accepts Athena config with council", () => { //#given const config = { - model: "anthropic/claude-opus-4-6", council: { members: [ { model: "openai/gpt-5.3-codex", name: "member-a" }, @@ -273,7 +400,26 @@ describe("AthenaConfigSchema", () => { test("rejects Athena config without council", () => { //#given - const config = { model: "anthropic/claude-opus-4-6" } + const config = {} + + //#when + const result = AthenaConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(false) + }) + + test("rejects Athena config with unknown model field", () => { + //#given + const config = { + model: "anthropic/claude-opus-4-6", + council: { + members: [ + { model: "openai/gpt-5.3-codex", name: "member-a" }, + { model: "xai/grok-code-fast-1", name: "member-b" }, + ], + }, + } //#when const result = AthenaConfigSchema.safeParse(config) diff --git a/src/config/schema/athena.ts b/src/config/schema/athena.ts index c0ad3e194..ebc7e022f 100644 --- a/src/config/schema/athena.ts +++ b/src/config/schema/athena.ts @@ -13,18 +13,25 @@ const ModelStringSchema = z export const CouncilMemberSchema = z.object({ model: ModelStringSchema, variant: z.string().optional(), - name: z.string().min(1), + name: z.string().min(1).trim().regex(/^[a-zA-Z0-9][a-zA-Z0-9 .\-]*$/, { + message: "Council member name must contain only letters, numbers, spaces, hyphens, and dots", + }), temperature: z.number().min(0).max(2).optional(), }).strict() export const CouncilConfigSchema = z.object({ - members: z.array(CouncilMemberSchema).min(2), + members: z.array(CouncilMemberSchema).min(2).refine( + (members) => { + const names = members.map(m => m.name.toLowerCase()) + return new Set(names).size === names.length + }, + { message: "Council member names must be unique (case-insensitive)" }, + ), }).strict() export type CouncilMemberConfig = z.infer export type CouncilConfig = z.infer export const AthenaConfigSchema = z.object({ - model: z.string().optional(), council: CouncilConfigSchema, }).strict()