@sema-agent/core
Version:
Stateless, task-oriented AI agent core
178 lines (177 loc) • 8.02 kB
JavaScript
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 };