@sema-agent/core
Version:
Stateless, task-oriented AI agent core
391 lines (390 loc) • 22.4 kB
JavaScript
import { posix } from "node:path";
import { isWinFormPath } from "../tools/fs/safety.js";
import { NAMESPACED_NAME_SHAPES } from "./protocol-table.js";
import { callerToolNameProblem } from "./caller-tool-name.js";
import { compileAllowList, compiledEntryName, inertEntryPhrase, readRuleEntry, renderListEntry, rootFacingToolNames } from "./allow-entry.js";
import { valueTypeLabel } from "./tool-name-list.js";
import { noticeRuleText } from "./permission-rule-model.js";
const SESSION_RULE_BUCKETS = { toolAllow: "allow", toolDeny: "deny", allowDirs: "dirs" };
const BUCKETS = Object.keys(SESSION_RULE_BUCKETS);
const ENTRY_FIELDS = BUCKETS.filter((f) => SESSION_RULE_BUCKETS[f] !== "dirs");
const STORE_STAMPS = new Set(["rev", "gen", "grammar"]);
export const SESSION_RULES_GRAMMAR = 2;
export class SessionPolicyError extends Error {
code;
detail;
constructor(code, message, detail) {
super(message);
this.code = code;
this.detail = detail;
this.name = "SessionPolicyError";
}
}
const NO_DECLARED_TOOLS = new Set();
export function readMountedToolNames(value) {
if (value === undefined)
return NO_DECLARED_TOOLS;
const refuse = (why) => {
throw new TypeError(`session rules store option mountedToolNames ${why} — it lists the names this deployment mounts its own tools under`);
};
if (typeof value === "string" || typeof value?.[Symbol.iterator] !== "function")
refuse("must be an iterable of tool names (an array or a Set), not a single value");
const names = new Set();
for (const n of value) {
if (typeof n !== "string")
refuse(`holds ${JSON.stringify(n) ?? String(n)}, which is not a tool name`);
const problem = callerToolNameProblem(n);
if (problem !== undefined)
refuse(`holds ${JSON.stringify(n)}: ${problem}`);
names.add(n);
}
return names;
}
function lexWithin(root, p) {
const fold = (x) => {
let n = isWinFormPath(x) ? x.replace(/\\/g, "/") : x;
if (/^[A-Za-z]:(\/|$)/.test(n))
n = n[0].toLowerCase() + n.slice(1);
return n;
};
const nr = posix.normalize(fold(root)).replace(/\/+$/, "") || "/";
const np = posix.normalize(fold(p)).replace(/\/+$/, "") || "/";
if (nr === "/")
return true;
return np === nr || np.startsWith(nr + "/");
}
function grammarOf(value) {
const stamp = value.grammar;
if (stamp === undefined)
return { grammar: value.rev !== undefined ? 0 : SESSION_RULES_GRAMMAR };
if (typeof stamp !== "number" || !Number.isInteger(stamp) || stamp < 0) {
const got = typeof stamp === "number" ? "a number that is not a whole number of 0 or more" : valueTypeLabel(stamp);
return { got, sentence: `session rules carry a grammar stamp that is ${got} — the stamp is the whole number of the rule grammar the rules were written in (this engine reads grammar ${SESSION_RULES_GRAMMAR})` };
}
if (stamp > SESSION_RULES_GRAMMAR) {
return {
got: `${stamp}, a grammar newer than this engine's (${SESSION_RULES_GRAMMAR})`,
sentence: `session rules were written in rule grammar ${stamp}, newer than this engine reads (grammar ${SESSION_RULES_GRAMMAR}) — refusing rather than read their entries in an older grammar; an engine that reads grammar ${stamp} reads them`,
};
}
return { grammar: stamp };
}
export function readSessionRulesShape(value) {
const fault = (field, got, sentence) => ({ fault: { field, got }, sentence });
if (typeof value !== "object" || value === null || Array.isArray(value)) {
const got = Array.isArray(value) ? "an array" : valueTypeLabel(value);
return fault("", got, `session rules must be an object of rule buckets (${BUCKETS.join(", ")}), got ${got}`);
}
const read = grammarOf(value);
if ("got" in read)
return fault("grammar", read.got, read.sentence);
const rules = { grammar: read.grammar };
for (const [key, v] of Object.entries(value)) {
if (STORE_STAMPS.has(key) || v === undefined)
continue;
if (!Object.hasOwn(SESSION_RULE_BUCKETS, key)) {
return fault(key, "not a rule bucket", `session rules hold ${JSON.stringify(key)}, which is not a rule bucket — the buckets are ${BUCKETS.join(", ")}, and a shell command rule is a toolDeny / toolAllow entry written Bash(<command>) (Bash(rm:*) matches a command whose first word is rm)`);
}
const field = key;
const listed = Array.isArray(v) ? Array.from(v) : undefined;
const got = listed === undefined
? valueTypeLabel(v)
: !listed.every((e) => typeof e === "string")
? "an array holding an entry that is not a string"
: SESSION_RULE_BUCKETS[field] === "dirs" && listed.some((e) => e.trim() === "")
? "an array holding an empty or blank entry"
: undefined;
if (got !== undefined)
return fault(field, got, `session rules ${field} must be an array of ${SESSION_RULE_BUCKETS[field] === "dirs" ? "directory paths" : "rule entries"}, got ${got} — a bucket is written as a list (["Bash(rm:*)"], not "Bash(rm:*)")`);
rules[field] = listed;
}
return { rules };
}
export function normalizeRules(rules) {
return canonicalRules(shapeOrRefuse(rules));
}
function shapeOrRefuse(rules) {
const read = readSessionRulesShape(rules);
if ("fault" in read)
throw new SessionPolicyError("invalid_shape", read.sentence, read.fault);
return read.rules;
}
function canonicalRules(read) {
const out = {};
for (const field of BUCKETS) {
const list = read[field];
if (list === undefined)
continue;
out[field] = SESSION_RULE_BUCKETS[field] === "dirs" ? list.map((d) => posix.normalize(d).replace(/\/+$/, "") || "/") : [...list];
}
return out;
}
function earlierGrammarReads(grammar, field, entry, reading) {
if (grammar >= SESSION_RULES_GRAMMAR)
return false;
if (reading.kind === "inert" && reading.reason === "grammar")
return true;
if (grammar !== 0 || SESSION_RULE_BUCKETS[field] !== "allow")
return false;
return reading.kind === "row" ? compiledEntryName(reading.compiled) !== entry : reading.reason === "wildcard_all";
}
function readsAsNoRule(entry, field) {
const reading = readRuleEntry(entry, SESSION_RULE_BUCKETS[field]);
return reading.kind === "inert" && reading.reason === "grammar";
}
const NO_RULE_REASONS = new Set(["grammar", "earlier_grammar"]);
export function unreadableDenyEntries(rules, roster) {
return compileSessionRuleBucket(rules, "toolDeny", roster).inert.filter((i) => NO_RULE_REASONS.has(i.reason));
}
function readsAsWildcard(entry) {
return compileAllowList([entry], "allow").inert.some((i) => i.reason === "wildcard_all");
}
const STAR_IS_NO_ALLOW_RULE = "it is no allow rule in any grammar and a write holding it is refused";
function starWayOut(onlyStars, others, left) {
return onlyStars
? `"*" is no allow rule in any grammar (a write holding it is refused); to drop it, write the session's rules afresh without it ${REWRITE_THROUGH}; ${left}`
: `${others}, leaving "*" out — ${STAR_IS_NO_ALLOW_RULE}; ${left}`;
}
export function compileSessionRuleBucket(rules, field, roster) {
const direction = SESSION_RULE_BUCKETS[field];
const entries = rules[field] ?? [];
const earlier = new Set(entries.filter((e) => earlierGrammarReads(rules.grammar, field, e, readRuleEntry(e, direction, roster))));
const whole = compileAllowList(entries, direction, roster);
const voided = direction === "allow" && whole.inert.some((i) => i.reason === "wildcard_all");
if (earlier.size === 0 || (voided && rules.grammar > 0))
return whole;
const list = compileAllowList(entries.filter((e) => !earlier.has(e)), direction, roster);
return { rows: list.rows, inert: [...list.inert, ...entries.filter((e) => earlier.has(e)).map((entry) => ({ entry, reason: "earlier_grammar" }))] };
}
const ADVISORY_ENTRIES_SHOWN = 5;
function shownEntries(entries, render) {
const rest = entries.length > ADVISORY_ENTRIES_SHOWN ? ` and ${entries.length - ADVISORY_ENTRIES_SHOWN} more` : "";
return `${entries.slice(0, ADVISORY_ENTRIES_SHOWN).map(render).join(", ")}${rest}`;
}
const REWRITE_THROUGH = `through this engine (an operator write of a new value without the stored row's rev; the write stamps grammar ${SESSION_RULES_GRAMMAR})`;
export function earlierGrammarAdvisory(sessionId, entries, field, grammar) {
const read = earlierGrammarReadings(entries, field);
const noRule = read.filter((r) => r.reader === "no_rule").map((r) => r.entry);
const namedNoTool = read.filter((r) => r.reader === "named_no_tool").map((r) => r.entry);
const sentences = [...(namedNoTool.length > 0 ? [namedNoToolInGrammar0(namedNoTool)] : []), ...(noRule.length > 0 ? [noRuleInThisGrammar(noRule, field, grammar)] : [])];
return `session permission rules of session ${JSON.stringify(sessionId)}: ${sentences.join(" ")}`;
}
function earlierGrammarReadings(entries, field) {
return entries.map((entry) => ({ entry, reader: readsAsNoRule(entry, field) ? "no_rule" : "named_no_tool" }));
}
export function earlierGrammarNotice(input) {
const { rulesSessionId, sessionId, entries, field, grammar } = input;
return {
code: "config.session_rules_earlier_grammar",
message: earlierGrammarAdvisory(rulesSessionId, entries, field, grammar),
detail: {
bucket: field,
storedGrammar: grammar,
entries: earlierGrammarReadings(entries, field).map((r) => ({ entry: noticeRuleText(r.entry), reader: r.reader })),
rewrite: { by: "operator_write_without_rev", stampsGrammar: SESSION_RULES_GRAMMAR },
rulesSessionId,
sessionId,
},
};
}
function namedNoToolInGrammar0(entries) {
const one = entries.length === 1;
const wildcard = entries.some(readsAsWildcard);
const rules = entries.some((e) => !readsAsWildcard(e));
const left = `left as stored, ${one ? "it keeps" : "they keep"} admitting nothing.`;
const way = !wildcard
? `To make ${one ? "it an allow rule" : "them allow rules"}, write the session's rules afresh ${REWRITE_THROUGH}; ${left}`
: starWayOut(!rules, `To make the parenthesized ones allow rules, write the session's rules afresh ${REWRITE_THROUGH}`, left);
return (`toolAllow ${shownEntries(entries, renderListEntry)} ${one ? "was" : "were"} stored before the rule grammar stamp, when session rules compared an entry as a tool name as written, so ${wildcard ? 'an entry that is no bare tool name (a parenthesized rule, or "*")' : "a parenthesized entry"} named no tool — ` +
`${one ? "it admits" : "they admit"} nothing, as when written${wildcard ? "; the rest of the list is read as written, not voided" : ""}. ${way}`);
}
function noRuleInThisGrammar(entries, field, grammar) {
const one = entries.length === 1;
const stored = `${field} ${shownEntries(entries, (entry) => inertEntryPhrase({ entry, reason: "grammar" }))} ${one ? "was" : "were"} stored in rule grammar ${grammar}${grammar === 0 ? ", before the rule grammar stamp" : ""}, and this engine's rule grammar (${SESSION_RULES_GRAMMAR}) reads ${one ? "it" : "them"} as no rule`;
const way = `write the session's rules afresh ${REWRITE_THROUGH}, leaving ${one ? "it" : "them"} out or spelling the tool's name as a rule spells one — the write judge refuses an entry it cannot read`;
if (SESSION_RULE_BUCKETS[field] === "deny") {
return `${stored}: rules whose toolDeny holds an entry no grammar reads cannot be read, so every call is refused until they are rewritten. To lift that, ${way}, and a non-operator write cannot loosen rules that refuse every call.`;
}
const stars = entries.filter(readsAsWildcard).length;
const left = `left as stored, ${one ? "it keeps" : "they keep"} admitting nothing.`;
const wayOut = stars === 0
? `To allow what ${one ? "it" : "they"} meant, ${way}; ${left}`
: starWayOut(stars === entries.length, `To allow what the others meant, write the session's rules afresh ${REWRITE_THROUGH}, spelling each tool's name as a rule spells one`, left);
return `${stored} — ${one ? "it admits" : "they admit"} nothing, and the rest of the list is read as written. ${wayOut}`;
}
export function earlierGrammarRefusalClause(entries, grammar) {
if (entries.length === 0)
return "";
const noRule = entries.filter((e) => readsAsNoRule(e, "toolAllow")).length;
const namedNoTool = entries.filter((e) => !readsAsNoRule(e, "toolAllow"));
const stars = namedNoTool.filter(readsAsWildcard).length;
const others = namedNoTool.length - stars;
const named = [...(stars > 0 ? ['"*"'] : []), ...(others > 0 ? [`${others} parenthesized ${others === 1 ? "entry" : "entries"}`] : [])];
const clauses = [
...(namedNoTool.length > 0 ? [`where ${named.join(" and ")} ${namedNoTool.length === 1 ? "names no tool and admits" : "name no tool and admit"} nothing`] : []),
...(noRule > 0 ? [`${namedNoTool.length > 0 ? "and " : "where "}${noRule} ${noRule === 1 ? "entry is" : "entries are"} no rule in this engine's rule grammar (${SESSION_RULES_GRAMMAR}) and ${noRule === 1 ? "admits" : "admit"} nothing`] : []),
];
return `; the session's rules are stored in rule grammar ${grammar}, ${clauses.join(" ")}`;
}
export function earlierGrammarAnnouncer(sink) {
const announced = new Set();
return (entries, field, grammar) => {
const key = JSON.stringify([field, grammar, entries]);
if (sink === undefined || entries.length === 0 || announced.has(key))
return;
announced.add(key);
try {
sink(entries, field, grammar);
}
catch {
}
};
}
function effectiveEntries(rules, field, mountedToolNames) {
return compileSessionRuleBucket(rules, field, mountedToolNames).rows.map((r) => r.entry);
}
function assertEntriesRead(rules, mountedToolNames) {
const known = rootFacingToolNames(mountedToolNames);
for (const field of ENTRY_FIELDS) {
for (const entry of rules[field] ?? []) {
const reading = readRuleEntry(entry, SESSION_RULE_BUCKETS[field], known);
const read = earlierGrammarReads(rules.grammar, field, entry, reading) ? { kind: "inert", reason: "earlier_grammar" } : reading;
if (read.kind === "row")
continue;
const { kind: _kind, ...why } = read;
void _kind;
const way = why.reason === "earlier_grammar"
? readsAsNoRule(entry, field)
? ` These rules are written in grammar ${rules.grammar} (a stored row), and this engine's grammar (${SESSION_RULES_GRAMMAR}) reads this entry as no rule (grammar) — ` +
(field === "toolAllow" && readsAsWildcard(entry) ? `leave the entry out: it spells "*", and ${STAR_IS_NO_ALLOW_RULE}.` : "leave the entry out, or spell the tool's name as a rule spells one.")
: ` These rules are written in grammar ${rules.grammar} (a stored row from before the grammar stamp), in which this entry names no tool.` +
(readsAsWildcard(entry)
? ` It is no allow rule in grammar ${SESSION_RULES_GRAMMAR} either (a write holding it is refused) — leave the entry out.`
: ` To make it an allow rule, write the rules afresh — a value without the stored row's rev; the write stamps grammar ${SESSION_RULES_GRAMMAR} — or leave the entry out.`)
: why.verdict === "retired" || why.reason === "near_miss"
? ` If this deployment mounts its own tool under ${renderListEntry(entry)}, declare it in the store's mountedToolNames.`
: why.currentName === undefined
? ` An entry is a tool name (Read; a protocol tool is named ${NAMESPACED_NAME_SHAPES}), a protocol namespace (mcp__<server>), or a rule in its tool's own grammar (Bash(<command>), Edit(<path>), Skill(<name>), WebFetch(domain:<host>)).`
: "";
const sentence = why.guidance !== undefined ? ` — ${why.guidance}` : "";
const does = SESSION_RULE_BUCKETS[field] === "allow"
? "admits nothing as written"
: NO_RULE_REASONS.has(why.reason)
? "refuses every call as stored (rules whose toolDeny holds an entry no grammar reads cannot be read) — a write holding it is refused"
: "refuses nothing as written";
const carriedBy = why.reason === "near_miss" && why.currentName !== undefined && !mountedToolNames.has(why.currentName) ? { carriedBy: "catalog" } : {};
throw new SessionPolicyError("inert_entry", `session rules ${field} holds ${inertEntryPhrase({ entry, ...why, ...carriedBy })}, which ${does}${sentence}; nothing was written.${way}`, { field, entry, ...why });
}
}
}
export function judgeSessionRulesWrite(prior, rules, opts, mountedToolNames = NO_DECLARED_TOOLS) {
if (opts?.expectedRev !== undefined && (prior?.rev ?? 0) !== opts.expectedRev) {
throw new SessionPolicyError("conflict", `session rules rev mismatch (expected ${opts.expectedRev}, have ${prior?.rev ?? 0})`);
}
const read = shapeOrRefuse(rules);
const clean = canonicalRules(read);
assertEntriesRead(read, mountedToolNames);
if (prior !== null && opts?.operator !== true) {
const stored = readSessionRulesShape(prior);
if ("fault" in stored) {
throw new SessionPolicyError("corrupt", `the stored session rules cannot be read as rules (${stored.sentence}) — refusing to judge a non-operator write against them as fewer rules; an operator write replaces them`);
}
const loosens = loosenReasons(stored.rules, clean, mountedToolNames);
if (loosens.length > 0) {
throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`);
}
}
return { ...clean, grammar: SESSION_RULES_GRAMMAR };
}
export function loosenReasons(priorValue, nextValue, mountedToolNames = NO_DECLARED_TOOLS) {
const shapeOf = (side, value) => {
const read = readSessionRulesShape(value);
if ("fault" in read)
throw new SessionPolicyError("invalid_shape", `the ${side} rules: ${read.sentence}`, read.fault);
return read.rules;
};
const prior = shapeOf("prior", priorValue);
const next = shapeOf("next", nextValue);
if (unreadableDenyEntries(next, mountedToolNames).length > 0)
return [];
const unreadPrior = unreadableDenyEntries(prior, mountedToolNames);
if (unreadPrior.length > 0) {
return [`the prior rules refuse every call — their toolDeny holds ${shownEntries(unreadPrior.map((i) => i.entry), (entry) => inertEntryPhrase({ entry, reason: "grammar" }))}, which no grammar reads`];
}
const reasons = [];
const keptDeny = new Set(effectiveEntries(next, "toolDeny", mountedToolNames));
for (const x of effectiveEntries(prior, "toolDeny", mountedToolNames))
if (!keptDeny.has(x))
reasons.push(`removed toolDeny ${renderListEntry(x)}`);
if (prior.toolAllow !== undefined) {
if (next.toolAllow === undefined)
reasons.push("removed toolAllow allowlist (all allowed)");
else {
const had = new Set(effectiveEntries(prior, "toolAllow", mountedToolNames));
for (const x of effectiveEntries(next, "toolAllow", mountedToolNames))
if (!had.has(x))
reasons.push(`widened toolAllow with ${renderListEntry(x)}`);
}
}
const pd = prior.allowDirs;
if (pd !== undefined && pd.length > 0) {
const nd = next.allowDirs;
if (nd === undefined || nd.length === 0)
reasons.push("removed allowDirs confinement");
else
for (const dir of nd)
if (!pd.some((d) => lexWithin(d, dir)))
reasons.push(`widened allowDirs with "${dir}"`);
}
return reasons;
}
export function continuesLineage(a, b) {
return (a.gen ?? 0) === (b.gen ?? 0);
}
export class InMemorySessionPolicyStore {
map = new Map();
mountedToolNames;
constructor(opts) {
this.mountedToolNames = readMountedToolNames(opts?.mountedToolNames);
}
highWater = new Map();
key(sessionId, principal) {
return JSON.stringify([sessionId, principal ?? null]);
}
async getRules(sessionId, principal) {
const r = this.map.get(this.key(sessionId, principal));
return r ? structuredClone(r) : null;
}
async putRules(sessionId, principal, rules, opts) {
const key = this.key(sessionId, principal);
const prior = this.map.get(key);
const clean = judgeSessionRulesWrite(prior ?? null, rules, opts, this.mountedToolNames);
const mark = this.highWater.get(key) ?? { rev: 0, gen: 0 };
const floor = Math.max(prior?.rev ?? 0, mark.rev);
const gen = prior?.gen ?? mark.gen;
const next = { ...structuredClone(clean), rev: floor + 1, gen };
this.map.set(key, next);
this.highWater.set(key, { rev: next.rev, gen });
return structuredClone(next);
}
async listBySession(sessionId) {
const out = [];
for (const [k, rules] of this.map) {
const [sid, principal] = JSON.parse(k);
if (sid === sessionId)
out.push({ principal: principal ?? undefined, rules: structuredClone(rules) });
}
return out;
}
async deleteBySession(sessionId) {
for (const [k, row] of [...this.map.entries()]) {
const [sid] = JSON.parse(k);
if (sid !== sessionId)
continue;
const mark = this.highWater.get(k) ?? { rev: 0, gen: 0 };
this.highWater.set(k, { rev: Math.max(mark.rev, row.rev), gen: mark.gen + 1 });
this.map.delete(k);
}
}
}
export const __testables = { lexWithin };