UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

178 lines (177 loc) • 8.02 kB
import { posix } from "node:path"; import { isWinFormPath } from "../tools/fs/safety.js"; import { NAMESPACED_NAME_SHAPES, carriesNamespaceSeparator } from "./protocol-table.js"; import { judgeRuleToolName } from "./tool-name-aliases.js"; export class SessionPolicyError extends Error { code; detail; constructor(code, message, detail) { super(message); this.code = code; this.detail = detail; this.name = "SessionPolicyError"; } } const HOLDS_TOOL_NAMES = { toolAllow: true, toolDeny: true, allowDirs: false, commandAllow: false, commandDeny: false }; const TOOL_NAME_FIELDS = Object.keys(HOLDS_TOOL_NAMES).filter((f) => HOLDS_TOOL_NAMES[f]); 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" || n.length === 0) refuse(`holds ${JSON.stringify(n) ?? String(n)}, which is not a non-empty tool name`); if (carriesNamespaceSeparator(n)) refuse(`holds ${JSON.stringify(n)}: "__" is reserved for protocol tool names and no deployment tool may carry it`); names.add(n); } return names; } export function assertSessionRuleNamesCurrent(rules, mountedToolNames = NO_DECLARED_TOOLS) { for (const field of TOOL_NAME_FIELDS) { for (const name of rules[field] ?? []) { const verdict = judgeRuleToolName(name, mountedToolNames); if (verdict.kind === "current") continue; const quoted = JSON.stringify(name); const successor = verdict.kind === "retired" ? verdict.currentName : undefined; throw new SessionPolicyError("legacy_tool_name", verdict.kind === "retired" ? `session rules ${field} names ${quoted}, a retired tool name (${verdict.guidance}) — with it in place no run of this session could start, so nothing was written.${successor !== undefined ? ` Use the current name ${JSON.stringify(successor)}.` : ""} If this deployment mounts its own tool under ${quoted}, declare it in the store's mountedToolNames.` : `session rules ${field} names ${quoted}, which carries the "__" namespace separator but no protocol prefix — no tool is named that way (protocol tools are named ${NAMESPACED_NAME_SHAPES}), and with it in place no run of this session could start, so nothing was written.`, { field, name, verdict: verdict.kind, ...(successor !== undefined ? { currentName: successor } : {}) }); } } } 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 + "/"); } export function normalizeRules(rules) { const out = {}; if (rules.toolAllow !== undefined) out.toolAllow = [...rules.toolAllow]; if (rules.toolDeny !== undefined) out.toolDeny = [...rules.toolDeny]; if (rules.commandAllow !== undefined) out.commandAllow = [...rules.commandAllow]; if (rules.commandDeny !== undefined) out.commandDeny = [...rules.commandDeny]; if (rules.allowDirs !== undefined) out.allowDirs = rules.allowDirs.map((d) => posix.normalize(d).replace(/\/+$/, "") || "/"); return out; } export function loosenReasons(prior, next, mountedToolNames = NO_DECLARED_TOOLS) { const reasons = []; for (const field of ["toolDeny", "commandDeny"]) { const n = new Set(next[field] ?? []); for (const x of prior[field] ?? []) { if (n.has(x) || (field === "toolDeny" && judgeRuleToolName(x, mountedToolNames).kind !== "current")) continue; reasons.push(`removed ${field} "${x}"`); } } for (const field of ["toolAllow", "commandAllow"]) { const p = prior[field]; if (p === undefined) continue; const n = next[field]; if (n === undefined) { reasons.push(`removed ${field} allowlist (all allowed)`); continue; } const ps = new Set(p); for (const x of n) if (!ps.has(x)) reasons.push(`widened ${field} with "${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 function stripRev(rules) { const { ...rest } = rules; delete rest.rev; return rest; } 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); 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 clean = normalizeRules(stripRev(rules)); assertSessionRuleNamesCurrent(clean, this.mountedToolNames); if (prior && opts?.operator !== true) { const loosens = loosenReasons(prior, clean, this.mountedToolNames); if (loosens.length > 0) { throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`); } } 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 };