Files
stack/extensions/mosaic-core/lib/proposal.ts
T

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 } };
}