/** * The rules every change runs, in a fixed order. Pure functions: the same * change against the same accepted state always gets the same answer, and no * model decides anything. * * The rules live here in code, not in the vault, so no agent can loosen them. */ import { notePath, typeDef, type CheckedNote } from "@gk/schema"; import type { Author, HeldReason, StoredNote } from "./store.ts"; export interface RuleSettings { /** A note with at least this much body text… */ textLossMinChars: number; /** …is held when an edit keeps less than this share of it. */ textLossKeepRatio: number; /** An agent making more changes than this inside the window is held. */ agentBatchLimit: number; agentBatchWindowMinutes: number; /** Note types an agent may publish without review. */ agentAutoPublish: string[]; /** Email addresses that may appear in notes. */ allowedEmails: string[]; } export const defaultRules: RuleSettings = { textLossMinChars: 400, textLossKeepRatio: 0.5, agentBatchLimit: 10, agentBatchWindowMinutes: 10, agentAutoPublish: ["status", "library-item"], allowedEmails: [], }; export interface Proposed { path: string; name: string; slug: string; text: string; author: Author; checked: CheckedNote; } export interface RuleContext { /** The accepted version at this path, if any. */ current: StoredNote | null; /** Another live note with the same file name, if any. */ nameTakenBy: string | null; /** Another live note of the same type with the same slug, if any. */ slugTakenBy: string | null; /** The agent's changes inside the batch window, before this one. */ recentAgentChanges: number; settings: RuleSettings; } /** * Authors held to the agent rules. A sync job writes as `sync` and may do * nothing an agent cannot: the same contact, publish, edit and batch limits. */ export const isLimited = (a: Author) => a === "agent" || a === "sync"; export type Verdict = { decision: "accept" } | { decision: "hold"; reasons: HeldReason[] }; /** Strings that look like credentials. Matching is on new text only. */ const SECRET_PATTERNS: [string, RegExp][] = [ ["a private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/], ["an AWS key", /\bAKIA[0-9A-Z]{16}\b/], ["a GitHub token", /\b(?:ghp|gho|ghs|ghu|ghr)_[A-Za-z0-9]{30,}\b|\bgithub_pat_[A-Za-z0-9_]{30,}\b/], ["a Slack token", /\bxox[abpors]-[A-Za-z0-9-]{10,}\b/], ["an API key", /\bsk-[A-Za-z0-9_-]{20,}\b/], ["a bearer token", /\bBearer\s+[A-Za-z0-9._~+/-]{20,}=*/], ["a password or key value", /\b(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*["']?[^\s"']{8,}/i], ]; const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; const PHONE = /(?:\+?1[\s.-]?)?\(?\b\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b/g; function newMatches(re: RegExp, text: string, before: string): string[] { const flags = re.flags.includes("g") ? re.flags : `${re.flags}g`; const g = new RegExp(re.source, flags); const old = new Set(before.match(g) ?? []); return [...new Set(text.match(g) ?? [])].filter((m) => !old.has(m)); } const bodyOf = (text: string) => text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim(); export function evaluate(p: Proposed, ctx: RuleContext): Verdict { const reasons: HeldReason[] = []; const { current, settings } = ctx; const live = current && !current.removed ? current : null; // 1. The note must be valid. Nothing else is judged on a broken note. if (!p.checked.ok) { return { decision: "hold", reasons: [ { code: "invalid", message: p.checked.problems.map((x) => (x.field ? `${x.field}: ${x.message}` : x.message)).join("; "), }, ], }; } const data = p.checked.data; // 2. Settings notes live at one fixed path, so there is only ever one. if (typeDef(p.checked.type)?.singleton && notePath(data) !== p.path) { reasons.push({ code: "misplaced", message: `A ${p.checked.type} note belongs at ${notePath(data)}` }); } // 3. Names and slugs must stay unambiguous. if (ctx.nameTakenBy) { reasons.push({ code: "duplicate-name", message: `Another note already uses this file name: ${ctx.nameTakenBy}` }); } if (ctx.slugTakenBy) { reasons.push({ code: "duplicate-slug", message: `Another ${p.checked.type} already uses the address "${p.slug}": ${ctx.slugTakenBy}` }); } // 4. A large loss of text in one edit. if (live) { const before = bodyOf(live.text).length; const after = bodyOf(p.text).length; if (before >= settings.textLossMinChars && after < before * settings.textLossKeepRatio) { reasons.push({ code: "text-loss", message: `The text shrank from ${before} to ${after} characters in one edit`, }); } } // 5. Anything that looks like a secret, or contact details from an agent. const before = live?.text ?? ""; for (const [what, re] of SECRET_PATTERNS) { if (newMatches(re, p.text, before).length) reasons.push({ code: "secret", message: `The note now contains what looks like ${what}` }); } const who = p.author === "sync" ? "A sync" : "An agent"; if (isLimited(p.author)) { const allowed = new Set(settings.allowedEmails.map((e) => e.toLowerCase())); const emails = newMatches(EMAIL, p.text, before).filter((e) => !allowed.has(e.toLowerCase())); if (emails.length) reasons.push({ code: "contact", message: `${who} added an email address: ${emails.join(", ")}` }); if (newMatches(PHONE, p.text, before).length) reasons.push({ code: "contact", message: `${who} added a phone number` }); } // 6. Agents stay inside their lane. if (isLimited(p.author)) { // A sync may change notes it wrote itself; anything else needs agentEdits. if (live && live.author !== p.author && live.data.agentEdits !== true) { reasons.push({ code: "agent-edit", message: `${who} changed a note you wrote, which needs your review` }); } const publishing = data.publish === true && !(live?.publish ?? false); if (publishing && !settings.agentAutoPublish.includes(p.checked.type)) { reasons.push({ code: "agent-publish", message: `${who} wants to publish a ${p.checked.type}, which needs your approval` }); } if (ctx.recentAgentChanges >= settings.agentBatchLimit) { reasons.push({ code: "batch", message: `More than ${settings.agentBatchLimit} ${p.author} changes in ${settings.agentBatchWindowMinutes} minutes`, }); } } return reasons.length ? { decision: "hold", reasons } : { decision: "accept" }; }