UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

186 lines (185 loc) • 9.1 kB
import { canonicalizeTarget, isWinFormPath, pathFamilyOf, writeTargetPath } from "../../tools/fs/safety.js"; import { createCoarseCommandNamePolicy, namespacedCoveringEntries, namespacedCoveringHit } from "../tool-policy.js"; import { continuesLineage } from "../session-policy-store.js"; import { thrownText } from "../thrown-value.js"; import { heldBy, 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 { pathTargetBaseOf } from "../effective-path-target.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)}: ${thrownText(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) { return { toolDeny: new Set(rules.toolDeny ?? []), toolAllow: rules.toolAllow ? new Set(rules.toolAllow) : undefined, toolDenyCovering: namespacedCoveringEntries(rules.toolDeny), toolAllowCovering: namespacedCoveringEntries(rules.toolAllow), cmdPolicy: rules.commandAllow || rules.commandDeny ? createCoarseCommandNamePolicy({ ...(rules.commandAllow ? { allow: rules.commandAllow } : {}), ...(rules.commandDeny ? { deny: rules.commandDeny } : {}), defaultAction: "deny", }) : undefined, allowDirs: rules.allowDirs && rules.allowDirs.length > 0 ? rules.allowDirs : undefined, }; } export function createSessionRulePolicy(rules, opts) { const { env, rootPath, toolEffects } = opts; const source = "seed" in rules ? rules : { seed: rules }; const seed = source.seed; const readRules = source.read ?? (() => Promise.resolve(seed)); let compiledFor; let compiled; const compile = (r) => { if (compiled === undefined || compiledFor !== r) { compiled = compileSessionRules(r); compiledFor = r; } return compiled; }; return { nameSets: [{ ...(seed.toolDeny?.length ? { deny: [...seed.toolDeny] } : {}), ...(seed.toolAllow?.length ? { allow: [...seed.toolAllow] } : {}) }], async check(req, signal) { const { toolDeny, toolAllow, toolDenyCovering, toolAllowCovering, cmdPolicy, allowDirs } = compile(await readRules(req.toolCallId, signal)); const listed = (set) => heldBy("policy.sessionRules", set, req); if (listed(toolDeny) || namespacedCoveringHit(toolDenyCovering, req)) return deny(req.toolName); if (toolAllow && !listed(toolAllow) && !namespacedCoveringHit(toolAllowCovering, req)) { return deny(req.toolName, "it is not in the session-rule allowlist"); } if (cmdPolicy) { const d = await cmdPolicy.check(req, signal); if (d.action === "deny") return { ...d, message: ruleDenialSentence({ source: "session", tool: req.toolName, ...(d.message !== undefined ? { detail: d.message } : {}) }) }; if (d.action !== "allow") return d; } 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 })); if (!canon.ok) { return deny(req.toolName, `the real target of the write to ${JSON.stringify(path)} could not be resolved against the session-rule allowDirs`); } 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); } if (!roots.some((root) => isWithin(root, canon.key))) { return deny(req.toolName, `the write to ${JSON.stringify(path)} is outside the session-rule allowDirs (${allowDirs.join(", ")})`); } } } return { action: "allow" }; }, }; }