@sema-agent/core
Version:
Stateless, task-oriented AI agent core
203 lines (202 loc) • 11.7 kB
JavaScript
import { canonicalizeTarget, isWinFormPath, pathFamilyOf, writeTargetPath } from "../../tools/fs/safety.js";
import { SESSION_RULES_GRAMMAR, compileSessionRuleBucket, continuesLineage, earlierGrammarAnnouncer, earlierGrammarRefusalClause, readSessionRulesShape, unreadableDenyEntries } from "../session-policy-store.js";
import { compiledEntryName, judgeAllowEntries, uncoveredCommandSegment } from "../allow-entry.js";
import { inlineUntrusted } from "../untrusted-text.js";
import { callTargetReadingOf, pathTargetBaseOf, readCallTarget } from "../effective-path-target.js";
import { egressThrownText } from "../untrusted-egress.js";
import { wireOf } from "../tool-name-readings.js";
import { ruleDenialSentence } from "../rule-denial-sentence.js";
import { DEFAULT_GATE_AWAIT_TIMEOUT_MS, gateWaitFailureOf, withGateTimeout } from "../gate-bounded-await.js";
import { declaredPathTargetOf, isProtectedWrite, pathConfinableWriteToolNames } from "../tool-registry.js";
import { judgePathRows } from "../path-guard-rows.js";
import { domainRow } from "../fs-write-gate-policy.js";
export const PATH_CONFINABLE_WRITE_TOOLS = pathConfinableWriteToolNames();
export function isWithin(root, p) {
if (!root)
return false;
const norm = (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 r = norm(root);
const c = norm(p);
if (c === r)
return true;
const base = r.endsWith("/") ? r : r + "/";
return c.startsWith(base);
}
const deny = (tool, detail) => ({
action: "deny",
message: ruleDenialSentence({ source: "session", tool, ...(detail !== undefined ? { detail } : {}) }),
decisionReason: "rule",
});
const HANDOFF_BOUND = 256;
const ANNOUNCED_REASONS_BOUND = 8;
export function createLiveSessionRules(store, floor, hooks) {
const announced = new Set();
const announce = (reason) => {
if (announced.has(reason) || announced.size >= ANNOUNCED_REASONS_BOUND)
return;
announced.add(reason);
hooks?.onUnreadable?.(reason);
};
let enforced = floor.seed;
let servedRow = false;
let vanished = false;
const read = async (signal) => {
let live;
try {
live = await withGateTimeout(store.getRules(floor.sessionId, floor.principal), DEFAULT_GATE_AWAIT_TIMEOUT_MS, signal, "sessionRulesRead");
}
catch (err) {
const wait = gateWaitFailureOf(err);
announce(wait !== undefined
? `the session-policy store read for session ${JSON.stringify(floor.sessionId)} ended without an answer (${wait})`
: `the session-policy store threw while re-reading session ${JSON.stringify(floor.sessionId)}: ${egressThrownText("same-principal", err)}`);
return enforced;
}
if (live === null) {
if (servedRow && !vanished) {
vanished = true;
announce(`the rules row of session ${JSON.stringify(floor.sessionId)} was deleted while this leg enforced it`);
}
return enforced;
}
if (enforced.rev > 0) {
if (!continuesLineage(live, enforced)) {
announce(`session ${JSON.stringify(floor.sessionId)} has a rules row of a new lineage (generation ${live.gen}, rev ${live.rev}), which does not continue the row this leg enforces (generation ${enforced.gen})`);
return enforced;
}
if (vanished && live.gen === undefined) {
announce(`session ${JSON.stringify(floor.sessionId)} has a new rules row at rev ${live.rev} after the row this leg enforced was deleted, and its backend carries no lineage to continue it by`);
return enforced;
}
}
if (live.rev <= enforced.rev) {
if (live.rev < enforced.rev) {
announce(`the session-policy store served session ${JSON.stringify(floor.sessionId)} at rev ${live.rev}, below the enforced rev ${enforced.rev}`);
}
return enforced;
}
enforced = live;
servedRow = true;
hooks?.onEnforced?.(live);
return live;
};
const handed = new Map();
return async (toolCallId, signal) => {
const handedRead = handed.get(toolCallId);
if (handedRead !== undefined) {
handed.delete(toolCallId);
const claimed = await handedRead;
if (continuesLineage(claimed, enforced) && claimed.rev >= enforced.rev)
return claimed;
}
const p = read(signal);
handed.set(toolCallId, p);
if (handed.size > HANDOFF_BOUND) {
const oldest = handed.keys().next().value;
if (oldest !== undefined)
handed.delete(oldest);
}
return p;
};
}
function compileSessionRules(rules) {
const shaped = readSessionRulesShape(rules);
const refuseEvery = (what) => `the session rules cannot be read as rules (they hold ${what}) — every call is refused until they are rewritten`;
if ("fault" in shaped) {
const { field, got } = shaped.fault;
const what = got === "not a rule bucket" ? "a key that is no rule bucket" : field === "" ? `a value that is ${got}, not an object of rule buckets` : `${field} holding ${got}`;
return { deny: [], allow: undefined, earlier: { grammar: SESSION_RULES_GRAMMAR, toolAllow: [], toolDeny: [] }, allowDirs: undefined, pathRows: false, names: { deny: [], allow: [] }, unreadable: refuseEvery(what) };
}
const { toolAllow, allowDirs, grammar } = shaped.rules;
const unreadDeny = unreadableDenyEntries(shaped.rules).length;
const denyList = compileSessionRuleBucket(shaped.rules, "toolDeny");
const allowList = toolAllow === undefined ? undefined : compileSessionRuleBucket(shaped.rules, "toolAllow");
const rowsOf = (behavior, list) => list.rows.map((r) => ({ behavior, entry: r.entry, compiled: r.compiled }));
const deny = rowsOf("deny", denyList);
const allow = allowList === undefined ? undefined : rowsOf("allow", allowList);
const named = (list, earlier) => (list?.inert ?? []).filter((i) => (i.reason === "earlier_grammar") === earlier).map((i) => i.entry);
return {
deny,
allow,
earlier: { grammar, toolAllow: named(allowList, true), toolDeny: named(denyList, true) },
allowDirs: allowDirs !== undefined && allowDirs.length > 0 ? allowDirs : undefined,
pathRows: [...deny, ...(allow ?? [])].some((r) => r.compiled.kind === "path"),
names: { deny: [...deny.map((r) => compiledEntryName(r.compiled)), ...named(denyList, false)], allow: [...(allow ?? []).map((r) => compiledEntryName(r.compiled)), ...named(allowList, false)] },
...(unreadDeny > 0 ? { unreadable: refuseEvery(`${unreadDeny === 1 ? "a toolDeny entry" : `${unreadDeny} toolDeny entries`} that rule grammar ${SESSION_RULES_GRAMMAR} reads as no rule`) } : {}),
};
}
export function createSessionRulePolicy(rules, opts) {
const { env, rootPath, toolEffects } = opts;
const anchors = opts.anchors ?? (rootPath !== undefined ? { root: rootPath } : {});
const source = "seed" in rules ? rules : { seed: rules };
const seed = source.seed;
const readRules = source.read ?? (() => Promise.resolve(seed));
const announceEarlier = earlierGrammarAnnouncer(opts.onEarlierGrammar);
let compiledFor;
let compiled;
const compile = (r) => {
if (compiled === undefined || compiledFor !== r) {
compiled = compileSessionRules(r);
for (const field of ["toolAllow", "toolDeny"])
announceEarlier(compiled.earlier[field], field, compiled.earlier.grammar);
compiledFor = r;
}
return compiled;
};
const seedNames = compile(seed).names;
return {
nameSets: [{ ...(seedNames.deny.length > 0 ? { deny: seedNames.deny } : {}), ...(seedNames.allow.length > 0 ? { allow: seedNames.allow } : {}) }],
async check(req, signal) {
const { deny: denyRows, allow: allowRows, earlier, allowDirs, unreadable, pathRows } = compile(await readRules(req.toolCallId, signal));
if (unreadable !== undefined)
return deny(req.toolName, unreadable);
if (denyRows.length > 0 || allowRows !== undefined) {
const located = req.root === undefined && rootPath !== undefined ? { ...req, root: rootPath } : req;
const call = pathRows && callTargetReadingOf(req) === undefined ? { ...req, targetReading: await readCallTarget(env, located, signal) } : req;
const judged = { root: rootPath, sessionId: undefined, liveCwd: () => req.cwd, anchors };
const refusing = denyRows.length > 0 ? judgeAllowEntries(denyRows, call, "deny", judged) : undefined;
if (refusing !== undefined)
return deny(req.toolName);
if (allowRows !== undefined && judgeAllowEntries(allowRows, call, "allow", judged, "narrowing") === undefined) {
const segment = uncoveredCommandSegment(allowRows, call, judged, "narrowing");
return deny(req.toolName, `it is not in the session-rule allowlist${segment === undefined ? "" : ` (no entry covers ${JSON.stringify(inlineUntrusted(segment, 200))})`}${earlierGrammarRefusalClause(earlier.toolAllow, earlier.grammar)}`);
}
}
if (allowDirs) {
if (!isProtectedWrite(req)) {
const eff = toolEffects?.get(wireOf("policy.sessionRules.effect", req)) ?? "write";
if (eff !== "read") {
return deny(req.toolName, "it is write-capable and cannot be path-confined, while the rule confines writes to allowDirs");
}
}
else {
const declared = declaredPathTargetOf(req);
if (declared === undefined) {
return deny(req.toolName, "it declares no path target to confine, while the rule confines writes to allowDirs");
}
const path = writeTargetPath(req, declared);
if (typeof path !== "string" || path.length === 0) {
return deny(req.toolName, "the call has no resolvable path to confine, while the rule confines writes to allowDirs");
}
const canon = await canonicalizeTarget(env, path, signal, pathFamilyOf({ root: rootPath, cwd: req.cwd ?? env.cwd }), pathTargetBaseOf(declared, { root: rootPath, cwd: req.cwd }));
const roots = [];
for (const raw of allowDirs) {
const r = await canonicalizeTarget(env, raw, signal, pathFamilyOf({ root: rootPath ?? env.cwd }), rootPath);
if (r.ok)
roots.push(r.key);
}
const views = canon.ok ? { views: [{ path: canon.key, kind: "canonical" }] } : { views: [], unreadable: canon.message };
if (judgePathRows([domainRow("session", roots)], views, "allow") === undefined) {
return deny(req.toolName, canon.ok ? `the write to ${JSON.stringify(path)} is outside the session-rule allowDirs` : `the real target of the write to ${JSON.stringify(path)} could not be resolved against the session-rule allowDirs`);
}
}
}
return { action: "allow" };
},
};
}