// C2-06 (design 5.2, rulings after r11 Q222, Q223 and Q226): the citation guard. The broker runs it // before it stores an agent's message to the human in a business with a library project. It reads // only the citations, never what the text says. Codes are design 5.2's table. import { lstatSync } from 'node:fs'; import { git } from '../../project/src/git.mjs'; import { classify, excluded } from './scope.mjs'; import { listTree, readLibrary, readTexts, splitLines } from './reader.mjs'; import { LibraryError } from './problems.mjs'; import { mirrorPath, readSyncState } from './mirror.mjs'; import { plain } from './limits.mjs'; import { FIXED_TEXTS } from './reply-protocol.mjs'; export { GUARD_CODES, FIXED_TEXTS } from './reply-protocol.mjs'; // Web cites are for these definitions only (design 5.2). export const WEB_CITE_DEFINITIONS = Object.freeze(['research', 'companion']); // The reason reaches the runner (sage-3599): the guard's own text naming the check, the cite and the // limit, never a quote or file text, and at most REASON_MAX characters. export const REASON_MAX = 300; export class GuardError extends LibraryError { constructor(code, reason) { reason = reason.slice(0, REASON_MAX); super(code, reason); this.reason = reason; } } const refuse = (code, reason) => { throw new GuardError(code, reason); }; const SHA = /^[a-f0-9]{40}$/; const LIBRARY_CITE = ['path', 'commit', 'lines', 'quote']; const WEB_CITE = ['url', 'quote']; const only = (value, required, optional = []) => plain(value) && required.every(k => Object.hasOwn(value, k)) && Object.keys(value).every(k => required.includes(k) || optional.includes(k)); const text = value => typeof value === 'string' && value.length > 0 && !value.includes('\0'); function libraryCite(c) { return only(c, LIBRARY_CITE, ['as']) && text(c.path) && c.path.length <= 4096 && typeof c.commit === 'string' && SHA.test(c.commit) && Array.isArray(c.lines) && c.lines.length === 2 && c.lines.every(n => Number.isSafeInteger(n) && n >= 1) && c.lines[0] <= c.lines[1] && text(c.quote) && (!Object.hasOwn(c, 'as') || c.as === 'canon'); } const webCite = c => only(c, WEB_CITE) && text(c.url) && c.url.length <= 4096 && text(c.quote); // A body is free text, or a JSON object {text, citations} that must fit the schema. Returns // { text, citations, cited } where `cited` says the body was the JSON form. export function parseReply(body) { let value; try { value = JSON.parse(body); } catch { return { text: body, citations: [], cited: false }; } if (!plain(value)) return { text: body, citations: [], cited: false }; if (!only(value, ['text', 'citations']) || typeof value.text !== 'string' || !Array.isArray(value.citations)) refuse('reply-invalid', 'a JSON object reply holds exactly "text", a string, and "citations", a list'); value.citations.forEach((c, i) => { if (!libraryCite(c) && !webCite(c)) refuse('reply-invalid', `citation ${i + 1} is neither {path, commit, lines, quote, as?} nor {url, quote}`); }); return { text: value.text, citations: value.citations, cited: true }; } const squash = value => value.replace(/\s+/g, ' ').trim(); // Design 5.2's checks, in the table's order. `limits` comes from resolveReplyLimits. `library` reads // git for a library cite (libraryReader below). `web` is { allowed, pages } for this sender: whether // its definition may cite a url, and the session's kept fetches. Returns the stored check result, // { citations: [{ scope, agent, file }] } in body order (agreed with row 225, morgana-0198 and // morgana-0200; a url cite is { scope: 'web', agent: null }), or null for a // plain body. Throws GuardError. export async function checkReply(body, { limits, mapping, library, web }) { if (Object.values(FIXED_TEXTS).includes(body)) return null; const reply = parseReply(body); const { citations } = reply; if (limits.requireCitation && citations.length === 0) refuse('citation-missing', 'replyLimits.requireCitation is true, so a reply carries at least one citation'); if (citations.length > limits.citationsPerReply) refuse('citations-over-limit', `replyLimits.citationsPerReply is ${limits.citationsPerReply}; the reply carries ${citations.length}`); if (!reply.cited) return null; const found = []; for (const [i, c] of citations.entries()) { if (!Object.hasOwn(c, 'path')) { found.push(null); continue; } const at = `citation ${i + 1} (${c.path})`; const where = classify(mapping, c.path); if (where.scope === 'unmapped') refuse('citation-invalid', `${at} names a path that isn't mapped`); const lines = await library.lines(c.commit, c.path); if (lines === null) refuse('citation-invalid', `${at} isn't an indexed file at a commit reachable from the served commit`); if (c.lines[1] > lines.length) refuse('citation-invalid', `${at} cites lines past the file's ${lines.length}`); const quote = squash(c.quote); if (!quote || !squash(lines.slice(c.lines[0] - 1, c.lines[1]).join('\n')).includes(quote)) refuse('citation-invalid', `${at} quote isn't in lines ${c.lines[0]}-${c.lines[1]}`); found.push(where); } citations.forEach((c, i) => { if (c.as === 'canon' && found[i].scope !== 'canon') refuse('playground-cited-as-canon', `citation ${i + 1} (${c.path}) is ${found[i].scope}, not canon`); }); const universes = [...new Set(found.filter(Boolean).map(w => w.universe))]; if (universes.length > 1) { const hybrid = universes.find(u => mapping.universes[u]?.kind === 'hybrid'); const sources = hybrid ? mapping.universes[hybrid].sources : []; if (!hybrid || !universes.every(u => u === hybrid || sources.includes(u))) refuse('citation-cross-universe', `the citations span universes ${universes.join(', ')}`); // C2-15: beside a hybrid cite, a source path the hybrid excludes is outside the hybrid's world. citations.forEach((c, i) => { if (found[i] && found[i].universe !== hybrid && excluded(mapping, hybrid, c.path)) refuse('citation-cross-universe', `citation ${i + 1} (${c.path}) is excluded from hybrid ${hybrid}`); }); } citations.forEach((c, i) => { if (!Object.hasOwn(c, 'url')) return; if (!web.allowed) refuse('citation-unfetched', `citation ${i + 1}: only research and companion cite a url`); const quote = squash(c.quote); const pages = web.pages.filter(p => p.url === c.url || p.finalUrl === c.url); if (!pages.length) refuse('citation-unfetched', `citation ${i + 1}: this session fetched no such url through the bus`); if (!quote || !pages.some(page => squash(page.text).includes(quote))) refuse('citation-unfetched', `citation ${i + 1}: the quote isn't in the url's kept text`); }); const out = []; for (const [i, c] of citations.entries()) out.push(found[i] ? { scope: found[i].scope, ...await library.origin(c.commit, c.path) } : { scope: 'web', agent: null }); return { citations: out }; } // Git reads for one check against the bare mirror. `lines` is the file's lines at `commit` when the // commit is reachable from the served commit and readLibrary indexes the path there (sage-3595 item 4: // readLibrary at the cited commit stands in for the C2-05a index), else null. Reads are cached per // commit for the one check. export function libraryReader(mirror, served, mapping) { const indexed = new Map(), reach = new Map(); const once = (cache, key, read) => { if (!cache.has(key)) cache.set(key, read().catch(() => null)); return cache.get(key); }; const reachable = commit => once(reach, commit, async () => { if (!mirror || !served) return false; if (await git(mirror, ['cat-file', '-t', commit]) !== 'commit') return false; await git(mirror, ['merge-base', '--is-ancestor', commit, served]); return true; }); const index = commit => once(indexed, commit, async () => { const [read, entries] = await Promise.all([readLibrary({ repo: mirror, commit, mapping }), listTree(mirror, commit)]); return { files: new Set(read.files.map(file => file.path)), entries }; }); return { async lines(commit, path) { if (!await reachable(commit)) return null; const at = await index(commit); if (!at?.files.has(path)) return null; const texts = await readTexts(mirror, [at.entries.find(entry => entry.path === path)]).catch(() => new Map()); return texts.has(path) ? splitLines(texts.get(path)) : null; }, // One git call: the file's last commit at `commit` (morgana-0200: the Console compares it with the // served tip's, as the cited library commit needn't touch the file) and that commit's trailer. async origin(commit, path) { if (!mirror) return { agent: null, file: null }; try { const [file, trailer = ''] = (await git(mirror, ['--literal-pathspecs', 'log', '-1', '--format=%H%x00%(trailers:key=Mosaic-Agent,valueonly)', commit, '--', path])).split('\0'); return { agent: trailer.trim() || null, file: SHA.test(file) ? file : null }; } catch { return { agent: null, file: null }; } }, // The file's last commit at the served tip, for thread.messages (sage-3595), or null. async last(path) { if (!mirror || !served) return null; try { const value = await git(mirror, ['--literal-pathspecs', 'log', '-1', '--format=%H', served, '--', path]); return SHA.test(value) ? value : null; } catch { return null; } }, }; } // thread.messages' `last` (sage-3595): each path's last commit at the served tip, one git call per // distinct path. A path maps to null when there is no served commit or the read fails. export async function lastCommits(context, paths) { const { mirror, served } = await servedMirror(context); const reader = libraryReader(mirror, served, null); const out = new Map(); for (const path of new Set(paths)) out.set(path, await reader.last(path)); return out; } // Only an absent mirror or a legitimate empty sync state means there is no served commit. // A lock timeout or unsafe/unreadable state is availability failure, not evidence of an invalid cite. async function servedMirror(context) { const mapping = context.business.projects[context.project]; let mirror = null, served = null; try { mirror = mirrorPath(context); try { lstatSync(mirror); } catch (error) { if (error.code === 'ENOENT') return { mapping, mirror: null, served: null }; throw error; } served = (await readSyncState(mirror, { branch: mapping.branch })).served ?? null; } catch { refuse('citation-unavailable', 'The library is busy or unavailable. Retry the answer when the library is available.'); } return { mapping, mirror, served }; } // The broker's entry: the library project's mirror at its served commit, then checkReply. `context` // is { business, project, dataRoot }. `definition` is the sender's role definition, which decides only // whether a url cite may pass; `pages` is the broker's page store for the sender's session. export async function guardReply(context, body, { limits, definition, pages }) { const web = { allowed: WEB_CITE_DEFINITIONS.includes(definition), pages }; // Plain replies, web-only replies and the runner's exact fallback texts don't read Git. // In particular, a busy mirror must not prevent the fixed fallback from being delivered. const reply = parseReply(body); if (!reply.citations.some(c => Object.hasOwn(c, 'path'))) return checkReply(body, { limits, mapping: context.business.projects[context.project], library: null, web }); const { mapping, mirror, served } = await servedMirror(context); return checkReply(body, { limits, mapping, library: libraryReader(mirror, served, mapping), web }); }