From bac4694ab189faf4fb3a632c8d8dcdd163527039 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 17:55:14 +0200 Subject: [PATCH] feat(intake): whitelist brief parsing that degrades to raw answers --- src/lib/intake/brief.test.ts | 147 +++++++++++++++++++++++++++++++++++ src/lib/intake/brief.ts | 117 ++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 src/lib/intake/brief.test.ts create mode 100644 src/lib/intake/brief.ts diff --git a/src/lib/intake/brief.test.ts b/src/lib/intake/brief.test.ts new file mode 100644 index 0000000..261cec1 --- /dev/null +++ b/src/lib/intake/brief.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from "vitest"; +import { parseBrief } from "@/lib/intake/brief"; +import type { Turn } from "@/lib/intake/turn"; + +const turns: Turn[] = [ + { question: "What would you like added?", answer: "A POS at the counter." }, +]; + +const good = JSON.stringify({ + title: "Add a POS at the counter", + summary: "Till staff need to take card payments at the counter.", + subtasks: ["Choose a card reader", "Wire it to the till screen"], + openQuestions: ["Which card reader is already in use?"], + area: "stock", +}); + +describe("parseBrief — the happy path", () => { + it("reads all five fields", () => { + const brief = parseBrief(good, turns); + expect(brief.title).toBe("Add a POS at the counter"); + expect(brief.subtasks).toHaveLength(2); + expect(brief.openQuestions).toHaveLength(1); + expect(brief.area).toBe("stock"); + expect(brief.degraded).toBe(false); + }); + + it("tolerates a code fence around the JSON", () => { + const brief = parseBrief("```json\n" + good + "\n```", turns); + expect(brief.title).toBe("Add a POS at the counter"); + }); + + it("tolerates prose around the JSON", () => { + const brief = parseBrief(`Here you go:\n${good}\nHope that helps.`, turns); + expect(brief.title).toBe("Add a POS at the counter"); + }); +}); + +describe("parseBrief — it is a whitelist, not a parser", () => { + it("DROPS fields that could change how a card is created", () => { + // The single most important test in this phase. A brief asserting these + // must lose them at the boundary — they must never reach a caller that + // could act on them. + const hostile = JSON.stringify({ + title: "Urgent work", + summary: "Please do this.", + subtasks: ["step one"], + openQuestions: [], + area: "", + priority: "critical", + accepted: true, + column: "col_in_progress", + created_by_role: "owner", + archived: false, + number: 1, + }); + + const brief = parseBrief(hostile, turns) as Record; + + expect(brief.priority).toBeUndefined(); + expect(brief.accepted).toBeUndefined(); + expect(brief.column).toBeUndefined(); + expect(brief.created_by_role).toBeUndefined(); + expect(brief.archived).toBeUndefined(); + expect(brief.number).toBeUndefined(); + + // The legitimate content still comes through. + expect(brief.title).toBe("Urgent work"); + }); + + it("returns exactly the known keys and no others", () => { + const brief = parseBrief(good, turns); + expect(Object.keys(brief).sort()).toEqual( + ["area", "degraded", "openQuestions", "subtasks", "summary", "title"].sort() + ); + }); +}); + +describe("parseBrief — degrading rather than failing", () => { + it("falls back to the client's own answers when the JSON will not parse", () => { + // Seven questions answered and then an error message is the worst possible + // outcome. The client's words are still worth a card. + const brief = parseBrief("I'm sorry, I can't do that.", turns); + expect(brief.degraded).toBe(true); + expect(brief.title).toBeTruthy(); + expect(brief.summary).toContain("A POS at the counter."); + }); + + it("falls back on an empty response", () => { + expect(parseBrief("", turns).degraded).toBe(true); + }); + + it("fills a missing title from the first answer", () => { + const partial = JSON.stringify({ summary: "Something.", subtasks: [] }); + const brief = parseBrief(partial, turns); + expect(brief.title).toBeTruthy(); + }); + + it("produces a usable brief even with no turns at all", () => { + const brief = parseBrief("", []); + expect(brief.title).toBeTruthy(); + expect(brief.degraded).toBe(true); + }); +}); + +describe("parseBrief — hostile shapes", () => { + it("ignores non-string entries in the arrays", () => { + const messy = JSON.stringify({ + title: "T", + summary: "S", + subtasks: ["ok", 42, null, { nested: true }, "also ok"], + openQuestions: [[], "fine"], + area: "", + }); + const brief = parseBrief(messy, turns); + expect(brief.subtasks).toEqual(["ok", "also ok"]); + expect(brief.openQuestions).toEqual(["fine"]); + }); + + it("coerces a non-string title rather than trusting it", () => { + const brief = parseBrief(JSON.stringify({ title: { evil: true }, summary: "S" }), turns); + expect(typeof brief.title).toBe("string"); + }); + + it("caps every field", () => { + const huge = JSON.stringify({ + title: "t".repeat(5000), + summary: "s".repeat(50_000), + subtasks: Array.from({ length: 500 }, (_, i) => `step ${i}`), + openQuestions: Array.from({ length: 500 }, (_, i) => `q ${i}`), + area: "a".repeat(500), + }); + const brief = parseBrief(huge, turns); + expect(brief.title.length).toBeLessThanOrEqual(200); + expect(brief.summary.length).toBeLessThanOrEqual(5000); + expect(brief.subtasks.length).toBeLessThanOrEqual(30); + expect(brief.openQuestions.length).toBeLessThanOrEqual(20); + expect(brief.area.length).toBeLessThanOrEqual(60); + }); + + it("handles a JSON array at the top level", () => { + expect(parseBrief("[1,2,3]", turns).degraded).toBe(true); + }); + + it("handles JSON null", () => { + expect(parseBrief("null", turns).degraded).toBe(true); + }); +}); diff --git a/src/lib/intake/brief.ts b/src/lib/intake/brief.ts new file mode 100644 index 0000000..1365ece --- /dev/null +++ b/src/lib/intake/brief.ts @@ -0,0 +1,117 @@ +import type { Turn } from "@/lib/intake/turn"; + +/** + * Turn a model response into a brief. + * + * This is a WHITELIST, not a parser. It reads five known fields and discards + * everything else, so a brief asserting `"priority": "critical"` or + * `"accepted": true` loses those at the boundary — they never reach a caller + * that could act on them. + * + * That matters because this is the product's only untrusted input: the text + * being summarised was written by someone other than the developer. The + * security property to keep is that the worst a hostile brief can achieve is a + * strangely-worded card in the Inbox. + * + * It also never throws. Seven questions answered and then an error message is + * the worst outcome available — a brief that will not parse degrades to the + * client's own answers, which are still worth a card. + */ + +const MAX_TITLE = 200; +const MAX_SUMMARY = 5000; +const MAX_SUBTASKS = 30; +const MAX_SUBTASK = 300; +const MAX_QUESTIONS_OUT = 20; +const MAX_AREA = 60; + +export interface Brief { + title: string; + summary: string; + subtasks: string[]; + openQuestions: string[]; + /** A project area this belongs to, or "". Feeds the size band. */ + area: string; + /** True when the model's output was unusable and this came from the answers. */ + degraded: boolean; +} + +function asString(value: unknown, max: number): string { + if (typeof value !== "string") return ""; + return value.trim().slice(0, max); +} + +function asStringArray(value: unknown, maxItems: number, maxEach: number): string[] { + if (!Array.isArray(value)) return []; + const out: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") continue; + const text = entry.trim().slice(0, maxEach); + if (!text) continue; + out.push(text); + if (out.length >= maxItems) break; + } + return out; +} + +/** The first balanced JSON object in the text, ignoring fences and prose. */ +function extractJson(raw: string): unknown { + const text = (raw ?? "").trim(); + if (!text) return null; + + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start === -1 || end === -1 || end < start) return null; + + try { + return JSON.parse(text.slice(start, end + 1)); + } catch { + return null; + } +} + +/** A usable brief built from nothing but the client's own words. */ +function fromAnswers(turns: Turn[]): Brief { + const answers = turns.map((turn) => turn.answer).filter(Boolean); + const first = answers[0] ?? ""; + + return { + title: asString(first, MAX_TITLE) || "Feature request", + summary: answers.join("\n\n").slice(0, MAX_SUMMARY) || "No details were given.", + subtasks: [], + openQuestions: [], + area: "", + degraded: true, + }; +} + +export function parseBrief(raw: string, turns: Turn[]): Brief { + const parsed = extractJson(raw); + + // Arrays and null are objects to typeof but are not a brief. + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return fromAnswers(turns); + } + + const record = parsed as Record; + + const title = + asString(record.title, MAX_TITLE) || + asString(turns[0]?.answer ?? "", MAX_TITLE) || + "Feature request"; + + const summary = + asString(record.summary, MAX_SUMMARY) || + turns.map((t) => t.answer).join("\n\n").slice(0, MAX_SUMMARY); + + // Only these five keys are read. Anything else in the object — priority, + // accepted, column — is discarded here and cannot travel further. + return { + title, + summary, + subtasks: asStringArray(record.subtasks, MAX_SUBTASKS, MAX_SUBTASK), + openQuestions: asStringArray(record.openQuestions, MAX_QUESTIONS_OUT, MAX_SUBTASK), + area: asString(record.area, MAX_AREA), + degraded: false, + }; +}