88 lines
3.5 KiB
TypeScript
88 lines
3.5 KiB
TypeScript
// 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<string, unknown>;
|
|
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 } };
|
|
}
|