UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

296 lines (295 loc) • 15 kB
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; } }