// lib/proposal.ts — structured improvement-proposal sanitization (PRD R7, AC7). // // An improvement proposal is RECORDED, never applied: the only consumer is // the append-only journal. The sanitizer is the boundary that makes "record // structured proposals" safe to expose to the model — strict shape, bounded // text, normalized control characters, unknown fields rejected (same // strictness doctrine as the manifest validator: tolerating extra fields // means being unable to catch drift). export interface SanitizedProposal { target: string; // what the proposal concerns: role slug, tool name, or policy field summary: string; // <= 500 chars, single line motivation: string; // <= 2000 chars, newlines normalized to spaces evidence?: string[]; // 0..8 items, each <= 500 chars } export type ProposalRejection = | "not-an-object" | "unknown-field" | "missing-field" | "invalid-target" | "oversized" | "invalid-evidence"; export interface ProposalResult { ok: boolean; proposal?: SanitizedProposal; reason?: ProposalRejection; detail?: string; } const FIELDS = new Set(["target", "summary", "motivation", "evidence"]); const SLUG_RE = /^[a-z][a-z0-9_.:-]*$/i; const LIMITS = { summary: 500, motivation: 2000, evidenceItems: 8, evidenceItem: 500 }; function normalize(s: unknown): string | null { if (typeof s !== "string") return null; // collapse all line-breaking and control characters to single spaces so a // proposal can never smuggle structure past the JSONL line boundary return s.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim(); } export function sanitizeProposal(input: unknown): ProposalResult { if (typeof input !== "object" || input === null || Array.isArray(input)) { return { ok: false, reason: "not-an-object" }; } const obj = input as Record; for (const key of Object.keys(obj)) { if (!FIELDS.has(key)) return { ok: false, reason: "unknown-field", detail: `unexpected field "${key}"` }; } for (const field of ["target", "summary", "motivation"]) { if (!(field in obj)) return { ok: false, reason: "missing-field", detail: `missing field "${field}"` }; } const target = normalize(obj.target); if (target === null || target === "" || !SLUG_RE.test(target) || target.length > 100) { return { ok: false, reason: "invalid-target" }; } const summary = normalize(obj.summary); if (summary === null || summary === "") return { ok: false, reason: "invalid-target", detail: "summary empty" }; if (summary.length > LIMITS.summary) return { ok: false, reason: "oversized", detail: "summary" }; const motivation = normalize(obj.motivation); if (motivation === null || motivation === "") { return { ok: false, reason: "invalid-target", detail: "motivation empty" }; } if (motivation.length > LIMITS.motivation) return { ok: false, reason: "oversized", detail: "motivation" }; let evidence: string[] | undefined; if ("evidence" in obj) { const raw = obj.evidence; if (!Array.isArray(raw) || raw.length > LIMITS.evidenceItems) { return { ok: false, reason: "invalid-evidence", detail: "evidence must be an array of at most 8 items" }; } const items: string[] = []; for (const item of raw) { const n = normalize(item); if (n === null || n === "") return { ok: false, reason: "invalid-evidence" }; if (n.length > LIMITS.evidenceItem) return { ok: false, reason: "oversized", detail: "evidence item" }; items.push(n); } evidence = items; } return { ok: true, proposal: { target, summary, motivation, evidence } }; }