@sema-agent/core
Version:
Stateless, task-oriented AI agent core
389 lines (388 loc) • 21.9 kB
JavaScript
import { ownerStoreOf } from "../permission-rule-provider.js";
import { ORG_ADJUDICATION_TIMEOUT_MS } from "../permission-rule-org.js";
import { PATH_RULE_FACES, READ_RULE_TOOL, eligiblePersisted, noticeRuleText } from "../permission-rule-model.js";
import { bindPathRules, persistedRowAnchors, projectScopeIdentity } from "../path-rule-binding.js";
import { DEFAULT_GATE_AWAIT_TIMEOUT_MS, gateWaitFailureOf, withGateTimeout } from "../gate-bounded-await.js";
import { thrownText } from "../thrown-value.js";
import { inlineUntrusted } from "../untrusted-text.js";
import { deliverEngineNotice } from "../engine-notice.js";
import { compileReadDeny, compileWriteDeny, readDenySeat, rowsWhereTheyLead } from "../../tools/fs/read-deny.js";
import { carryHomeViews, placeReadDenyAtHome, readDenyOver } from "../../tools/fs/read-deny-home.js";
import { composeReadChecks } from "../../tools/fs/file-read-check.js";
import { readStationMemberOf } from "../../tools/fs/shell-read-check.js";
import { rootAdmittingArtifactStore } from "../../tools/fs/safety.js";
import { emitTrace } from "../trace.js";
const PARTITIONS = ["session", "durable"];
const partitionOf = (source) => (source === "session" ? "session" : "durable");
const tightening = (rule) => rule.behavior === "deny" || rule.behavior === "ask";
export const PERSON_READ_BOUND_MS = ORG_ADJUDICATION_TIMEOUT_MS - 1_000;
export const PATH_TABLES_READY_BOUND_MS = DEFAULT_GATE_AWAIT_TIMEOUT_MS;
const COMPILED_TABLES_KEPT = 8;
export const EMPTY_READ_ROOTS = Object.assign(() => [], { forCall: () => () => [], ready: () => Promise.resolve() });
export function createPersonPathRows(cfg) {
const provider = cfg.provider;
const anonymous = cfg.principal === undefined || cfg.principal === "";
const holdsPersonRows = provider !== undefined && provider.partitions.durable && (!anonymous || cfg.localOwnerDeclared);
const partitions = PARTITIONS.filter((p) => (p === "durable" ? provider?.partitions.durable === true : provider?.partitions.session === true));
const unit = { durable: { failedSeq: 0 }, session: { failedSeq: 0 } };
let seqs = 0;
const pins = new Map();
let compile;
const compiled = new Map();
let unitTables;
const unanchoredSaid = new Set();
const land = (o) => {
if (o.cut === true)
return;
let changed = false;
for (const p of partitions) {
const cell = unit[p];
const okSeq = cell.ok?.seq ?? 0;
if (o.failed.has(p) || o.view === undefined) {
if (o.seq > okSeq && o.seq > cell.failedSeq) {
cell.failedSeq = o.seq;
changed = true;
}
}
else if (o.seq >= cell.failedSeq && o.seq > okSeq) {
cell.ok = { seq: o.seq, rows: o.view.rules.filter((r) => partitionOf(r.source) === p) };
changed = true;
}
}
if (changed && compile !== undefined)
refreshUnitTables();
};
const lawed = (p, read) => read !== undefined ? read : (unit[p].ok?.rows ?? []).filter(tightening);
const latestUnread = () => holdsPersonRows && partitions.some((p) => unit[p].failedSeq > (unit[p].ok?.seq ?? 0));
const unitLatestRules = () => partitions.flatMap((p) => {
const cell = unit[p];
return lawed(p, cell.ok !== undefined && cell.failedSeq <= cell.ok.seq ? cell.ok.rows : undefined);
});
const readOnce = (at) => {
const seq = ++seqs;
if (provider === undefined)
return Promise.resolve({ rules: [] });
let raw;
try {
raw = ownerStoreOf(provider, cfg.principal, cfg.localOwnerDeclared).effective({ sessionId: cfg.sessionId });
}
catch (err) {
raw = Promise.reject(err);
}
const outcomeOf = (view) => ({ seq, view: holdsPersonRows ? view : { ...view, rules: [] }, failed: holdsPersonRows ? failedOf(view) : new Set() });
void raw.then((view) => {
for (const line of view.disclosures)
cfg.onDisclosure(line);
cfg.onRowsDropped(view.droppedRows);
if (holdsPersonRows && view.unreadable !== undefined)
announce(at, view.unreadable, view.unreadableSources);
land(outcomeOf(view));
}, () => undefined);
return withGateTimeout(raw, PERSON_READ_BOUND_MS, cfg.signal, "personRules").then((view) => readOf(outcomeOf(view)), (err) => {
const cut = gateWaitFailureOf(err) === "aborted";
const outcome = { seq, failed: new Set(partitions), why: thrownText(err), ...(cut ? { cut: true } : {}) };
land(outcome);
if (holdsPersonRows && !cut)
announce(at, outcome.why);
return readOf(outcome);
});
};
const failedOf = (view) => view.unreadable !== undefined && (view.unreadableSources ?? []).length === 0 ? new Set(partitions) : new Set((view.unreadableSources ?? []).map(partitionOf).filter((p) => partitions.includes(p)));
const defect = (message) => {
cfg.onDisclosure(message);
cfg.onDefect?.(new Error(message));
};
const readOf = (o) => {
const rules = partitions.flatMap((p) => lawed(p, o.view !== undefined && !o.failed.has(p) ? o.view.rules.filter((r) => partitionOf(r.source) === p) : undefined));
const unreadable = o.view !== undefined ? o.view.unreadable : (o.why ?? "the store did not answer");
return { rules, ...(o.view !== undefined ? { org: o.view.org } : {}), ...(unreadable !== undefined ? { unreadable } : {}) };
};
const announce = (at, why, sources) => {
cfg.onDisclosure(`the person's persisted rules could not be read (${at === "prepare" ? "when the run started" : "for a call"}${sources !== undefined && sources.length > 0 ? `; ${sourcesWord(sources)} rows` : ""}): their allow rows take no part until a read succeeds, the deny and ask rows of the run's last successful read still apply — ${why}`);
cfg.onUnread(at, sources);
};
const tableRowsOf = async (rules) => {
if (rules.length === 0)
return { denyRows: [], readDirectories: [] };
const scopeIdentity = await projectScopeIdentity(rules, cfg.rootCanonical, cfg.leads);
const call = { tool: READ_RULE_TOOL, cwd: cfg.root, sessionId: cfg.sessionId, ...(scopeIdentity !== undefined ? { scopeIdentity } : {}) };
const readDirectories = [];
const denySpecs = [];
for (const rule of rules) {
if (rule.status !== "live")
continue;
if (rule.behavior === "allow" && rule.tool === READ_RULE_TOOL && rule.match === "subpath" && eligiblePersisted(rule, call, "allow")) {
if (!readDirectories.includes(rule.command))
readDirectories.push(rule.command);
continue;
}
const faces = PATH_RULE_FACES[rule.tool]?.deny;
if (rule.behavior !== "deny" || faces === undefined || (rule.match !== "path" && rule.match !== "subpath") || !eligiblePersisted(rule, { ...call, tool: rule.tool }, "deny"))
continue;
denySpecs.push({ parsed: rule, ruleText: rule.rule, faces, rule });
}
const denyRows = [];
for (const row of bindPathRules(denySpecs, (spec) => persistedRowAnchors(spec.rule.scope, cfg.chainTop))) {
if (row.unanchored !== undefined) {
if (!unanchoredSaid.has(row.rule)) {
unanchoredSaid.add(row.rule);
cfg.onDisclosure(`the persisted deny rule ${inlineUntrusted(row.rule, 200)} needs a ${row.unanchored.join(" / ")} base this task does not declare — the shell's file commands and the search tools do not judge it on this leg (the gate asks)`);
}
continue;
}
denyRows.push(row);
}
return { denyRows, readDirectories };
};
const tablesOf = (rules) => {
const make = compile;
if (make === undefined)
return Promise.resolve(undefined);
const compiling = (async () => {
const person = await tableRowsOf(rules);
const key = JSON.stringify([person.denyRows.map((r) => [r.rule, scopeKey(r.spec.rule)]), person.readDirectories]);
let tables = compiled.get(key);
if (tables === undefined) {
tables = withGateTimeout(make(person), PATH_TABLES_READY_BOUND_MS, undefined, "pathTablesCompile");
compiled.set(key, tables);
if (compiled.size > COMPILED_TABLES_KEPT)
compiled.delete(compiled.keys().next().value);
tables.catch(() => compiled.delete(key));
}
return await tables;
})();
return withGateTimeout(compiling, PATH_TABLES_READY_BOUND_MS, undefined, "pathTablesCompile");
};
const contentOf = (rules) => JSON.stringify(rules.map(({ adds: _adds, ...row }) => row));
const refreshUnitTables = () => {
const rules = unitLatestRules();
const key = contentOf(rules);
if (unitTables !== undefined && unitTables.key === key && unitTables.state !== "failed")
return;
const cell = { key, state: "compiling", settled: Promise.resolve() };
cell.settled = tablesOf(rules).then((tables) => {
cell.tables = tables;
cell.state = "ready";
}, (err) => {
cell.failed = thrownText(err);
cell.state = "failed";
defect(`the path tables over the person's newest rows could not be compiled; the checks that are no call's refuse until a later read compiles: ${cell.failed}`);
});
unitTables = cell;
};
const unitReady = async (signal) => {
const deadline = Date.now() + PATH_TABLES_READY_BOUND_MS;
for (let cell = unitTables; cell?.state === "compiling" && signal?.aborted !== true; cell = unitTables) {
const left = deadline - Date.now();
if (left <= 0)
return;
await withGateTimeout(cell.settled, left, signal, "pathTablesReady").catch(() => undefined);
}
};
const pinOf = (toolCallId) => {
const p = toolCallId === undefined ? undefined : pins.get(toolCallId);
if (p === undefined || cfg.occurrences?.get(toolCallId) === p.attempt)
return p;
pins.delete(toolCallId);
return undefined;
};
const enter = (toolCallId, attempt) => {
const rows = readOnce("call").then((read) => (entry.read = read));
const entry = { attempt, rows, tables: holdsPersonRows ? rows.then((read) => settleTables(entry, read)) : Promise.resolve(), ...(holdsPersonRows ? {} : { settled: {} }) };
pins.set(toolCallId, entry);
return entry;
};
const pin = (toolCallId) => {
for (const id of [...pins.keys()])
pinOf(id);
const known = pinOf(toolCallId);
if (known !== undefined)
return known.rows;
const attempt = cfg.occurrences?.get(toolCallId);
return attempt === undefined ? readOnce("call") : enter(toolCallId, attempt).rows;
};
const settleTables = async (entry, read) => {
try {
const tables = await tablesOf(read.rules);
entry.settled = tables !== undefined ? { tables } : {};
}
catch (err) {
entry.settled = { failed: thrownText(err) };
defect(`the path tables of a call's read of the person's rows could not be compiled — the call's file and shell checks refuse it rather than judge it with other tables: ${entry.settled.failed}`);
}
};
const tablesFor = (toolCallId) => {
const p = pinOf(toolCallId);
if (p?.settled?.failed !== undefined)
throw new Error(`this call's path tables could not be compiled, so the call is refused: ${p.settled.failed}`);
if (p !== undefined && p.settled === undefined)
throw new Error("a call's path tables were read before they were compiled (an engine defect: a check ran ahead of its call's read)");
if (p?.settled?.tables !== undefined)
return p.settled.tables;
const u = unitTables;
if (u === undefined)
throw new Error("the run's path tables were read before they were compiled (an engine defect)");
if (u.state === "ready" && u.tables !== undefined)
return u.tables;
throw new Error(u.state === "failed"
? `the path tables over the person's newest rows could not be compiled, so this check refuses rather than judge with the tables of the rows they replace: ${u.failed}`
: "the path tables over the person's newest rows are still being compiled, so this check refuses rather than judge with the tables of the rows they replace");
};
return {
holdsPersonRows,
prime: () => holdsPersonRows
? readOnce("prepare").then(() => undefined)
: undefined,
pin,
tablesReady: (toolCallId, signal) => {
const p = pinOf(toolCallId);
const cut = signal === undefined || cfg.signal === undefined ? (signal ?? cfg.signal) : AbortSignal.any([signal, cfg.signal]);
return p !== undefined ? withGateTimeout(p.tables, undefined, cut, "callTables").catch(() => undefined) : unitReady(cut);
},
holds: (toolCallId) => cfg.occurrences?.get(toolCallId) !== undefined,
unreadFor: (toolCallId) => {
const read = pinOf(toolCallId)?.read;
return read !== undefined ? holdsPersonRows && read.unreadable !== undefined : latestUnread();
},
attachTables: async (make) => {
compile = make;
const rules = unitLatestRules();
const tables = await tablesOf(rules);
unitTables ??= { key: contentOf(rules), state: "ready", tables: tables, settled: Promise.resolve() };
const judging = () => {
if (latestUnread())
throw new Error("a partition of the person's persisted rules could not be read when the run last read it, so this check refuses rather than judge past rows it cannot see");
return tablesFor(undefined);
};
const readDeny = Object.assign(readDenyOver(() => tablesFor(undefined).readDeny, tables.readDeny.homeViews), {
matchPath: (path, opts) => judging().readDeny.matchPath(path, opts),
matchTarget: (canonicalKey, lexicalView, opts) => judging().readDeny.matchTarget(canonicalKey, lexicalView, opts),
unexcludableUnder: (scope) => judging().readDeny.unexcludableUnder?.(scope) ?? false,
rowUnderRoot: (scope) => judging().readDeny.rowUnderRoot?.(scope) ?? null,
rowAtOrBelow: (path) => judging().readDeny.rowAtOrBelow?.(path) ?? null,
forCall: (id) => tablesFor(id).readDeny,
});
const writeDeny = {
face: "write",
get ruleRows() {
return tablesFor(undefined).writeDeny.ruleRows;
},
matchPath: (path) => judging().writeDeny.matchPath(path),
matchTarget: (canonicalKey, lexicalView, opts) => judging().writeDeny.matchTarget(canonicalKey, lexicalView, opts),
homeViews: readDeny.homeViews,
forCall: (id) => tablesFor(id).writeDeny,
};
return { forCall: tablesFor, readDeny, writeDeny, readDirectories: (id) => tablesFor(id).readDirectories, ruleRows: () => tablesFor(undefined).ruleRows };
},
};
}
export function pathTablesCompile(seats) {
return async (person) => {
const rows = [...seats.legRows, ...person.denyRows];
const led = rowsWhereTheyLead(rows, await seats.leads.resolve(rows));
const placed = await placeReadDenyAtHome(seats.env, (homes, linked) => compileReadDeny(seats.additions, "readDenyPatterns", seats.builtin, led, homes, linked), undefined, undefined, seats.configRoot);
return { readDeny: placed.matcher, writeDeny: carryHomeViews(compileWriteDeny(led), placed.matcher.homeViews), readDirectories: [...new Set(await seats.canonicalize(person.readDirectories))], ruleRows: rows };
};
}
export function readRootsOver(seats) {
const said = new Set();
const rootsOf = (personDirs) => {
const own = seats.own();
const store = seats.store();
const held = store === undefined ? [] : personDirs.filter((k) => !own.includes(k) && rootAdmittingArtifactStore(store, [k]) !== undefined);
for (const dir of held)
if (!said.has(dir))
(said.add(dir), seats.onHeld(dir, store));
return [...new Set([...own, ...personDirs.filter((k) => !held.includes(k))])];
};
return Object.assign(() => rootsOf(seats.tables.readDirectories()), { forCall: (toolCallId) => () => rootsOf(seats.tables.readDirectories(toolCallId)), ready: (signal) => seats.ready(undefined, signal) });
}
const SEATS = new WeakMap();
export function denySeatsOf(t) {
let seats = SEATS.get(t);
if (seats === undefined) {
seats = { read: readDenySeat(t.readDeny), write: t.writeDeny.ruleRows.length > 0 ? { writeDenyMatch: (p) => t.writeDeny.matchPath(p)?.pattern ?? null } : {} };
SEATS.set(t, seats);
}
return seats;
}
const VOUCHED = { disposition: "vouch", finding: "none" };
export function onPinnedRead(rows, stations) {
const check = composeReadChecks(stations);
if (check === undefined || !rows.holdsPersonRows)
return check;
const members = new Set(stations.flatMap((s) => [...s.tools]));
return {
judge(call, args, cwd, signal, callOpts) {
const id = callOpts.toolCallId;
if (readStationMemberOf(call, members) === undefined)
return check.judge(call, args, cwd, signal, callOpts);
const ready = id !== undefined && rows.holds(id) ? rows.pin(id).then(() => rows.tablesReady(id)) : rows.tablesReady(undefined, signal);
return ready.then(() => check.judge(call, args, cwd, signal, callOpts) ?? VOUCHED);
},
unanswered: (call, cause) => check.unanswered(call, cause),
};
}
export function afterCallTables(rows, tool) {
return {
...tool,
execute: async (toolCallId, params, signal, onUpdate) => {
await rows.tablesReady(toolCallId, signal);
return await tool.execute(toolCallId, params, signal, onUpdate);
},
};
}
export function personPathRowsOfTask(t) {
const { sessionId, taskId } = t.ids;
return createPersonPathRows({
provider: t.deps.permissionRuleStore,
localOwnerDeclared: t.deps.localOwnerRules === true,
principal: t.principal,
sessionId,
root: t.root,
rootCanonical: t.rootCanonical,
leads: t.leads,
chainTop: t.chainTop,
occurrences: t.occurrences,
onDisclosure: (message) => emitTrace(t.deps.tracer, () => ({ kind: "permission.rule_store_unreadable", version: 1, taskId, message, ts: Date.now() })),
onUnread: t.notices.onUnread,
onRowsDropped: t.notices.onRowsDropped,
onDefect: (err) => t.deps.onError?.(err, { phase: "config", sessionId }),
signal: AbortSignal.any([...t.signals]),
});
}
function scopeKey(rule) {
const scope = rule?.scope;
return scope === undefined ? "" : scope.kind === "project" ? `project:${scope.root}` : scope.kind;
}
function sourcesWord(sources) {
return sources.length <= 1 ? sources.join("") : `${sources.slice(0, -1).join(", ")} and ${sources[sources.length - 1]}`;
}
export function ruleStoreUnreadableNotice(onNotice, ids) {
let sent = false;
return (at, sources) => {
if (sent)
return;
sent = true;
const which = sources !== undefined && sources.length > 0 ? `your persisted permission rules from the ${sourcesWord(sources)} sources` : "your persisted permission rules";
deliverEngineNotice(onNotice, {
code: "permission.rule_store_unavailable",
message: `${which} could not be read ${at === "prepare" ? "when this run started" : "for a call on this run"}. Until a read of them succeeds, none of their allow rows (Read directory rows included) applies, while the deny and ask rows most recently read from them this run still apply — a deny row you removed since that read keeps refusing until then. While they cannot be read, every call your rules judge (a file read, a write, a command) is asked about rather than allowed, until a read of them succeeds. Every later check reads them again.`,
detail: { at, sessionId: ids.sessionId, runId: ids.runId },
});
};
}
export function ruleRowsDroppedNotice(onNotice, ids) {
let sent = false;
return (rows) => {
if (sent || rows === undefined || rows.length === 0)
return;
sent = true;
const one = rows.length === 1;
deliverEngineNotice(onNotice, {
code: "permission.rule_row_dropped",
message: `${rows.length} of your persisted permission rule rows ${one ? "is" : "are"} scoped to a project directory that is not written as a complete absolute path, so ${one ? "it takes" : "they take"} no part in this run's checks — a deny or ask row among them protects nothing. Rewrite ${one ? "it" : "them"} with the project's full path, or remove ${one ? "it" : "them"} (this notice's detail names ${one ? "it" : "them"}).`,
detail: { rows: rows.slice(0, 8).map((r) => ({ rule: noticeRuleText(r.rule), scopeRoot: inlineUntrusted(r.scopeRoot, 200) })), count: rows.length, sessionId: ids.sessionId, runId: ids.runId },
});
};
}