UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

203 lines (202 loc) • 11.7 kB
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" }; }, }; }