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