UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

391 lines (390 loc) • 22.4 kB
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 };