@@ -0,0 +1,87 @@
|
||||
// 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 } };
|
||||
}
|
||||
Reference in New Issue
Block a user