(cherry picked from commit367fd0d240) (cherry picked from commit068e1e0f1c)
207 lines
11 KiB
JavaScript
207 lines
11 KiB
JavaScript
// 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 });
|
|
}
|