@sema-agent/core
Version:
Stateless, task-oriented AI agent core
296 lines (295 loc) • 15 kB
JavaScript
import { readFileSync, readdirSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { assertSessionRuleNamesCurrent, loosenReasons, normalizeRules, readMountedToolNames, stripRev, SessionPolicyError, } from "../../core/session-policy-store.js";
import { atomicWriteFile, ensureDir, sanitizeScope } from "./fs-atomic.js";
import { guardCorruptSink } from "./corrupt-read-sink.js";
import { assertAdoptionBootGate, readRootAdoptionFile } from "./adoption/marker.js";
export const SESSION_POLICY_MARK_SUFFIX = ".hwm.json";
const MARK_SUFFIX = SESSION_POLICY_MARK_SUFFIX;
export function readSessionPolicyMark(path) {
let raw;
try {
raw = readFileSync(path, "utf8");
}
catch (err) {
if (err.code === "ENOENT")
return { mark: null };
throw err;
}
const corrupt = (reason) => ({ corrupt: { reason } });
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
return corrupt("is unparseable");
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
return corrupt("is not a JSON object");
const mark = parsed;
if (typeof mark.__hwm !== "number" || !Number.isFinite(mark.__hwm))
return corrupt("carries no numeric value");
if (mark.__gen !== undefined && (typeof mark.__gen !== "number" || !Number.isInteger(mark.__gen) || mark.__gen < 0))
return corrupt("carries a lineage that is not a whole number");
return { mark };
}
export function judgeSessionPolicyRowObject(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
return { corrupt: { grade: "unparseable", reason: "unparseable JSON (torn/zero-byte write?)" } };
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return { corrupt: { grade: "malformed", reason: "not a JSON object" } };
}
return { row: parsed };
}
export function judgeSessionPolicyRowShape(row) {
const bad = (reason) => ({ corrupt: { grade: "malformed", reason } });
if (typeof row.rev !== "number" || !Number.isFinite(row.rev) || row.rev < 1)
return bad("rev is not a positive number");
if (row.gen !== undefined && (typeof row.gen !== "number" || !Number.isInteger(row.gen) || row.gen < 0))
return bad("gen is not a whole number");
const strArr = (x) => x === undefined || (Array.isArray(x) && x.every((e) => typeof e === "string"));
if (!strArr(row.toolAllow) || !strArr(row.toolDeny) || !strArr(row.commandAllow) || !strArr(row.commandDeny) || !strArr(row.allowDirs)) {
return bad("malformed rule field");
}
const { __sid: _sid, __principal: _principal, ...clean } = row;
void _sid;
void _principal;
return { rules: clean };
}
export function attributeSessionPolicyRow(dir, file, row) {
const spell = (p) => (typeof p === "string" || p === null ? p : undefined);
if (row !== undefined && typeof row.__sid === "string")
return { owner: { sessionId: row.__sid, principal: spell(row.__principal) } };
const markRead = readSessionPolicyMark(join(dir, `${file.slice(0, -".json".length)}${MARK_SUFFIX}`));
const mark = "corrupt" in markRead ? null : markRead.mark;
return { owner: mark !== null && typeof mark.__sid === "string" ? { sessionId: mark.__sid, principal: spell(mark.__principal) } : undefined, markRead };
}
export class FileSessionPolicyStore {
dir;
onCorruptRead;
mountedToolNames;
anonymousAlias;
constructor(root, opts) {
assertAdoptionBootGate(root, "FileSessionPolicyStore");
this.mountedToolNames = readMountedToolNames(opts?.mountedToolNames);
this.dir = join(root, "session-policy");
ensureDir(this.dir);
this.onCorruptRead = guardCorruptSink(opts?.onCorruptRead);
const adoption = readRootAdoptionFile(root);
this.anonymousAlias =
adoption !== undefined && "adopted" in adoption && adoption.adopted.from.kind === "local-owner" ? adoption.adopted.toPrincipal : undefined;
}
disclose(info) {
this.onCorruptRead?.({ kind: "file", ...info });
}
refuseDamaged(sessionId, principal, path, grade, reason) {
this.disclose({ sessionId, ...(principal !== undefined ? { principal } : {}), path, reason });
throw new SessionPolicyError("corrupt", `session rules file for ${JSON.stringify(sessionId)} is ${grade} (${reason}) — refusing rather than read the rules it held as none (session rules only tighten). Repair it: fix the file, or delete it (deleting it = explicitly no rules, and gives up the rules it held). The file's location is reported to the store's corrupt-read disclosure (onCorruptRead), not in this message.`);
}
refuseDamagedMark(sessionId, principal, reason) {
this.disclose({ sessionId, ...(principal !== undefined ? { principal } : {}), path: this.markPathFor(sessionId, principal), reason: `high-water mark ${reason}` });
throw new SessionPolicyError("corrupt", `session rules high-water mark for ${JSON.stringify(sessionId)} ${reason} — refusing rather than restart this key's rev from zero. Repair it: fix the mark file, or delete it (deleting it gives up the number it was holding). The file's location is reported to the store's corrupt-read disclosure (onCorruptRead), not in this message.`);
}
refuseUnreadable(sessionId, path, what, err) {
const reason = `${what} could not be read (${err.code ?? err.name})`;
this.disclose({ sessionId, path, reason });
throw new SessionPolicyError("corrupt", `session rules for ${JSON.stringify(sessionId)}: ${reason} — refusing rather than guess whose rules it holds. Retry; if it persists, repair access to the file. The file's location is reported to the store's corrupt-read disclosure (onCorruptRead), not in this message.`);
}
ownerOf(sessionId, file, row) {
try {
return attributeSessionPolicyRow(this.dir, file, row);
}
catch (err) {
return this.refuseUnreadable(sessionId, join(this.dir, `${file.slice(0, -".json".length)}${MARK_SUFFIX}`), "a rules row's high-water mark", err);
}
}
sweepMark(sessionId, markPath) {
try {
return readSessionPolicyMark(markPath);
}
catch (err) {
return this.refuseUnreadable(sessionId, markPath, "a rules row's high-water mark", err);
}
}
sweepEntries(sessionId) {
try {
return readdirSync(this.dir);
}
catch (err) {
if (err.code === "ENOENT")
return [];
return this.refuseUnreadable(sessionId, this.dir, "the session rules directory", err);
}
}
readMark(sessionId, principal) {
let judged;
try {
judged = readSessionPolicyMark(this.markPathFor(sessionId, principal));
}
catch (err) {
return this.refuseDamagedMark(sessionId, principal, `could not be read (${err.code ?? err.name})`);
}
if ("corrupt" in judged)
return this.refuseDamagedMark(sessionId, principal, judged.corrupt.reason);
return judged.mark;
}
pathFor(sessionId, principal) {
return join(this.dir, `${this.basenameFor(sessionId, principal)}.json`);
}
basenameFor(sessionId, principal) {
const effective = principal ?? this.anonymousAlias;
const composite = JSON.stringify([sessionId, effective ?? null]);
return sanitizeScope(composite);
}
markPathFor(sessionId, principal) {
return join(this.dir, `${this.basenameFor(sessionId, principal)}${MARK_SUFFIX}`);
}
writeMarkFile(path, rev, sessionId, principal, gen) {
this.writeMark(path, { __hwm: rev, __gen: gen, __sid: sessionId, __principal: principal });
}
writeMark(path, mark) {
atomicWriteFile(join(this.dir, "tmp"), path, JSON.stringify(mark));
}
read(sessionId, principal) {
const path = this.pathFor(sessionId, principal);
const judged = this.readRowFile(path);
if (judged === null)
return null;
if ("corrupt" in judged)
return this.refuseDamaged(sessionId, principal, path, judged.corrupt.grade, judged.corrupt.reason);
const shaped = judgeSessionPolicyRowShape(judged.row);
if ("corrupt" in shaped)
return this.refuseDamaged(sessionId, principal, path, shaped.corrupt.grade, shaped.corrupt.reason);
return shaped.rules;
}
readRowFile(path) {
let raw;
try {
raw = readFileSync(path, "utf8");
}
catch (err) {
if (err.code === "ENOENT")
return null;
return { corrupt: { grade: "unreadable", reason: `read failed (${err.code ?? err.name})` } };
}
return judgeSessionPolicyRowObject(raw);
}
async getRules(sessionId, principal) {
return this.read(sessionId, principal);
}
async putRules(sessionId, principal, rules, opts) {
const prior = this.read(sessionId, principal);
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.readMark(sessionId, principal);
if (mark?.__lineage === "lost") {
throw new SessionPolicyError("corrupt", `session rules for ${JSON.stringify(sessionId)} have no lineage evidence (the erase left a lost-lineage mark) — refusing to restart the key's lineage by guessing; delete the mark to restart it`);
}
const floor = Math.max(prior?.rev ?? 0, mark?.__hwm ?? 0);
const gen = prior !== null ? (prior.gen ?? 0) : (mark?.__gen ?? 0);
const next = { ...clean, rev: floor + 1, gen };
const attributedPrincipal = (principal ?? this.anonymousAlias) ?? null;
this.writeMarkFile(this.markPathFor(sessionId, principal), next.rev, sessionId, attributedPrincipal, gen);
atomicWriteFile(join(this.dir, "tmp"), this.pathFor(sessionId, principal), JSON.stringify({ ...next, __sid: sessionId, __principal: attributedPrincipal }));
return next;
}
async listBySession(sessionId) {
const files = this.sweepEntries(sessionId);
const out = [];
for (const file of files) {
if (file.endsWith(MARK_SUFFIX))
continue;
if (!file.endsWith(".json"))
continue;
const full = join(this.dir, file);
const judged = this.readRowFile(full);
if (judged === null)
continue;
const { owner } = this.ownerOf(sessionId, file, "row" in judged ? judged.row : undefined);
if (owner === undefined) {
this.disclose({ sessionId, path: full, reason: "corrupt" in judged ? judged.corrupt.reason : "session metadata is not a string" });
continue;
}
if (owner.sessionId !== sessionId)
continue;
if ("corrupt" in judged)
this.refuseDamaged(sessionId, undefined, full, judged.corrupt.grade, judged.corrupt.reason);
if (typeof judged.row.__sid !== "string")
this.refuseDamaged(sessionId, undefined, full, "malformed", "session metadata is not a string");
const shaped = judgeSessionPolicyRowShape(judged.row);
if ("corrupt" in shaped)
this.refuseDamaged(sessionId, undefined, full, shaped.corrupt.grade, shaped.corrupt.reason);
const attributed = judged.row.__principal;
if (attributed !== undefined && attributed !== null && typeof attributed !== "string") {
this.refuseDamaged(sessionId, undefined, full, "malformed", "principal metadata is not a string");
}
out.push({ principal: attributed ?? undefined, rules: shaped.rules });
}
return out;
}
async deleteBySession(sessionId) {
for (const file of this.sweepEntries(sessionId)) {
if (file.endsWith(MARK_SUFFIX))
continue;
if (!file.endsWith(".json"))
continue;
const full = join(this.dir, file);
const judged = this.readRowFile(full);
if (judged === null)
continue;
const row = "row" in judged ? judged.row : undefined;
const markPath = join(this.dir, `${file.slice(0, -".json".length)}${MARK_SUFFIX}`);
const { owner, markRead } = this.ownerOf(sessionId, file, row);
if (owner !== undefined) {
if (owner.sessionId === sessionId)
this.eraseRow(full, markPath, sessionId, owner.principal ?? null, row, markRead);
continue;
}
if (!("corrupt" in judged) || judged.corrupt.grade !== "unparseable")
continue;
const tornRead = markRead ?? this.sweepMark(sessionId, markPath);
if ("corrupt" in tornRead) {
this.disclose({ sessionId, path: markPath, reason: `erase purged a torn row whose high-water mark is corrupt (it ${tornRead.corrupt.reason}); the mark is left for the operator` });
}
else if (tornRead.mark === null) {
this.writeMark(markPath, { __hwm: 0, __gen: 0, __lineage: "lost" });
this.disclose({ sessionId, path: full, reason: "erase purged a torn row that had no high-water mark: the key's lineage is lost, writes are refused until the mark is deleted" });
}
else {
this.writeMark(markPath, { ...tornRead.mark, __gen: (tornRead.mark.__gen ?? 0) + 1 });
}
unlinkIfPresent(full);
}
}
eraseRow(full, markPath, sessionId, principal, row, observed) {
const markRead = observed ?? this.sweepMark(sessionId, markPath);
if (!("corrupt" in markRead)) {
const rowRev = typeof row?.rev === "number" && Number.isFinite(row.rev) ? row.rev : 0;
this.writeMarkFile(markPath, Math.max(markRead.mark?.__hwm ?? 0, rowRev), sessionId, principal, (markRead.mark?.__gen ?? 0) + 1);
}
unlinkIfPresent(full);
}
}
function unlinkIfPresent(path) {
try {
unlinkSync(path);
}
catch (err) {
if (err.code !== "ENOENT")
throw err;
}
}