feat(intake): whitelist brief parsing that degrades to raw answers

This commit is contained in:
Dave 2026-09-16 17:55:14 +02:00
parent 1c6e739b16
commit bac4694ab1
2 changed files with 264 additions and 0 deletions

View file

@ -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<string, unknown>;
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);
});
});

117
src/lib/intake/brief.ts Normal file
View file

@ -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<string, unknown>;
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,
};
}