UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

210 lines (209 loc) • 15.7 kB
import { type PutRulesOptions, type SessionPolicyStoreOptions, type SessionPermissionRules, type SessionPolicyStore, type SessionRulesRecord, type StoredSessionRules } from "../../core/session-policy-store.js"; /** * design/99 §E6 — file-backed {@link SessionPolicyStore} for the local (TOC) backend: one JSON row file per * `(sessionId, principal)`, plus — once that key has ever been stamped — a second, tiny file beside it holding * the number the erase contract on {@link SessionPolicyStore.deleteBySession} says must outlive the row. Both * names share a stem, and the mark's suffix is what every directory sweep here tests first * ({@link MARK_SUFFIX}). Semantics otherwise **byte-for-byte identical to `InMemorySessionPolicyStore`** (the * cross-backend equivalence contract). CAS-rev OCC + tighten-only reuse the SAME pure helpers * (`loosenReasons`/`normalizeRules`/`stripRev`) as core — no re-implemented rule logic that could drift. * * Atomicity: the file backend is single-process (the `FileStorageBackend` boot lock guarantees ONE writer per * data dir), and `getRules`/`putRules` read-check-write SYNCHRONOUSLY (no await between the rev read and the * atomic write), so the read-modify-write is atomic in the one event loop — exactly the InMemory store's premise. * Cross-process CORRECT concurrency is the Pg/TiDB backend's job (a CAS WHERE clause), by design. */ /** Coordinates of one damaged policy file the store observed (the {@link FileSessionPolicyStoreOptions.onCorruptRead} * payload): the keyed read REFUSES it, the enumeration skips it. Named rather than inline so the backend option * that forwards it names the SAME shape. */ export interface SessionPolicyCorruptReadInfo { /** Always a whole-file disclosure (the {@link import("./corrupt-read-sink.js").CorruptReadInfo} level). */ kind: "file"; /** The session whose read observed the corrupt row. On the enumeration face this is the session being * ENUMERATED: the row it refuses is that session's ({@link attributeSessionPolicyRow}), the row it omits is one * nothing on disk attributes to anybody. */ sessionId: string; /** Present only when the read was keyed by a principal (the enumeration face has none). */ principal?: string; /** Absolute path of the offending file — a rules row, or a key's high-water mark (`*.hwm.json`). The refusals' * messages never carry it: this is where a location is reported. */ path: string; /** What was observed — the same words the refusal's message carries after its grade (unparseable / malformed / unreadable); * for a high-water mark, `high-water mark ` + the reader's fault predicate (is unparseable / is not a JSON object / …). */ reason: string; } export interface FileSessionPolicyStoreOptions extends SessionPolicyStoreOptions { /** Disclosure sink for a damaged rules file or high-water mark (see {@link FileSessionPolicyStore.refuseDamaged} and * its mark sibling): it fires WITH the refusal on the keyed read / write and on the enumeration of the session that * owns the row, and on its own where the enumeration omits a row nothing attributes to anybody or the erase sweep * leaves a damaged mark. Never fires on plain ENOENT. * Swallow-guarded: a throwing sink cannot change the refusal. */ onCorruptRead?: (info: SessionPolicyCorruptReadInfo) => void; } /** * The suffix that tells a MARK file apart from a rules ROW in the shared directory — injective by * construction, not by convention: `sanitizeScope` ends every row's basename with a 64-hex digest, so the * character before a row's `.json` is always a hex digit and no row can be spelled `*.hwm.json`. Every sweep * of this directory tests it FIRST: a mark must never be listed as a row, nor judged as crash litter. */ export declare const SESSION_POLICY_MARK_SUFFIX = ".hwm.json"; /** What one mark file carries: the number, plus the row's attribution fields (the filename is a one-way hash, * so those are the only way a sweep can tell whose mark it is). Attribution is OPTIONAL on the read side — * this store always writes it, a hand-written file need not, and a mark with no owner is simply nobody's. */ export interface StoredHighWaterMark { __hwm: number; /** The lineage the key's NEXT row starts (bumped by every erase); absent = 0 (a mark written before lineages). */ __gen?: number; __sid?: string; __principal?: string | null; /** The erase's tombstone (a torn row purged with no mark): writes are refused until an operator deletes this file. */ __lineage?: "lost"; } /** The ONE reader's answer: the mark (`null` = no file), or the fault that makes the bytes not a mark. `reason` is a * predicate ("is unparseable"), so every wrapper — the keyed store's refusal, the adoption leg's, the disclosure — * says the same fault in the same words. */ export type SessionPolicyMarkRead = { mark: StoredHighWaterMark | null; } | { corrupt: { reason: string; }; }; /** * The ONE reader of a mark file — the number it holds (`{ mark }`, `null` for no file: the ordinary state of a key * nobody erased), or the fault that makes the bytes not a mark (`{ corrupt }`). Every outcome but those two is a * refusal at the caller: for a ROW "unreadable" degrades to "absent" and costs nothing, while for a mark absence * reads as ZERO, the rollback the erase contract forbids. It only JUDGES, carrying no sentence and no location, * because the wording depends on the audience: a store method answers an end user (session, fault, repair; the * location goes to the disclosure sink), the adoption leg answers an operator (it names the file). An IO failure * propagates raw. Exported because adoption moves these files too; a second, more tolerant reader there is how the * guarantee goes quietly missing. */ export declare function readSessionPolicyMark(path: string): SessionPolicyMarkRead; /** Why one row file's bytes are not a row. `unparseable` is the ONE grade saying they are not JSON at all — the * only evidence the erase sweep's crash-artifact ruling rests on. `reason` is the disclosure text, so two faces * judging the SAME file name the same fault in the same words. */ export interface SessionPolicyRowFault { grade: "unparseable" | "malformed"; reason: string; } /** One row file's bytes → the JSON object every face of this directory requires before reading a field off it. * Split from the rules-shape judgment below because the erase sweep and the ownership rebind treat a row as * bytes-with-attribution: a malformed-rules row is still erasable and still travels with its key. */ export declare function judgeSessionPolicyRowObject(raw: string): { row: Record<string, unknown>; } | { corrupt: SessionPolicyRowFault; }; /** * The SHAPE of a stored row, judged ONCE for every face that serves its rules to a reader — the per-key read * and the session-wide enumeration alike, so one store cannot answer two things about one file. * * Required: `rev` >= 1 (a rev-0 row would read as the live reader's SYNTHETIC empty seed); `gen`, when present, a * whole number; every rule field a string array (a STRING `rev` poisons the CAS arithmetic). The `__sid`/`__principal` * metadata is STRIPPED so the value is byte-identical to the in-memory twin's. */ export declare function judgeSessionPolicyRowShape(row: Record<string, unknown>): { rules: StoredSessionRules; } | { corrupt: SessionPolicyRowFault; }; /** Whose key one row file is, as the bytes on disk show it. */ export interface SessionPolicyRowOwner { sessionId: string; /** The principal the SAME evidence names: a string, `null` for the anonymous lane, `undefined` when it names none it can spell. */ principal: string | null | undefined; } /** * The ONE answer to "whose row is this file?" for every face that sweeps the directory — the enumeration, the erase * and the ownership rebind — so a damaged row is one session's fact on all three. The row's own `__sid` when its * bytes are a JSON object carrying one; otherwise the `__sid` of its same-stem high-water mark, which every stamp * writes BEFORE the row, so a torn, non-object or unreadable row still has it. `undefined` = nothing on disk names * an owner (no mark, or one that is not a mark or carries no session): such a row can be judged by nobody's key, and * each face keeps its own answer for it. A mark that cannot be READ is no evidence either way, so it THROWS (raw, * from the reader) and the caller refuses rather than guess: guessing "nobody" would let an erase sweep another * session's restriction, or a rebind strand it, on one transient read fault. When the answer rested on the mark, * the read is handed back (`markRead`) so a caller that then acts on the mark acts on the SAME observation. */ export declare function attributeSessionPolicyRow(dir: string, file: string, row: Record<string, unknown> | undefined): { owner: SessionPolicyRowOwner | undefined; markRead?: SessionPolicyMarkRead; }; export declare class FileSessionPolicyStore implements SessionPolicyStore { private readonly dir; private readonly onCorruptRead; /** {@link SessionPolicyStoreOptions.mountedToolNames}, read once at construction. */ private readonly mountedToolNames; /** #132 — the ADOPTED identity of a local-owner root's anonymous lane, or undefined on an * unadopted (or principal-adopted) root. A local-owner adoption rebinds the anonymous * `[sid,null]` estate rows to `toPrincipal`; until the deployment's principal wiring lands * (`REQUIRE_PRINCIPAL` et al. — recorded `migrated:false` in the config account, an operator * action), the runtime still queries anonymously, and ENOENT there would silently drop the very * tighten-only deny rules the rebind moved. Adoption is a transfer of the whole anonymous * identity, so on such a root the anonymous key IS the adopted principal — reads and writes * alike (a window-era anonymous write must not mint a fresh orphan row). */ private readonly anonymousAlias; constructor(root: string, opts?: FileSessionPolicyStoreOptions); /** The one delivery point for {@link onCorruptRead}. */ private disclose; /** The ONE refusal for a rules file that is PRESENT but cannot be served as rules — every damage grade lands * here (`unreadable` bytes, `unparseable` JSON, a `malformed` row), so the keyed read and the write that reads * before it answer with one voice. Session rules only TIGHTEN: a row is the restriction an operator put on the * session, so reading a damaged one as "no rules" would drop the restriction itself — the wrong direction for a * store whose every other face refuses a loosening. ABSENCE (ENOENT) is the only "no rules"; it never reaches * here. The disclosure sink fires with the refusal (swallow-guarded: a throwing sink cannot turn it back into an * answer) and is the ONLY carrier of the file's path: the thrown message reaches end users through a host's error * body, so it names no on-disk location. Two ways out, both the operator's: fix the file, or delete it — deleting is an explicit "no rules" and * gives up the rules the file held. */ private refuseDamaged; /** The sibling of {@link refuseDamaged} for the key's high-water MARK, and its only other voice: one disclosure * shape, one code, a message naming the session, the fault and the repair but never a location (the path rides * the sink, as the row's does). It refuses rather than read the mark as absent: that reads as ZERO and restarts * the key's rev (see {@link readSessionPolicyMark}). Deleting the file gives up the number it held. */ private refuseDamagedMark; /** The sweeps' (enumeration / erase) refusal when a file they must READ cannot be — the directory, or a mark that * decides whose a row is or holds the number an erase must keep. Same voice as the two above: session, fault, * repair, and the location only on the disclosure. Nothing is guessed and nothing is removed; a retry re-reads. */ private refuseUnreadable; /** {@link attributeSessionPolicyRow} for a sweep: an unreadable mark becomes {@link refuseUnreadable}. */ private ownerOf; /** A mark the erase must act on, read in the sweep's voice ({@link refuseUnreadable} when it cannot be read). */ private sweepMark; /** The directory a sweep enumerates; absent = empty, unreadable = {@link refuseUnreadable}. */ private sweepEntries; /** The keyed read of the mark for a write: the number, `null` for a key nobody stamped; a file that is there is a * mark or a refusal ({@link refuseDamagedMark}), never "no mark". One that cannot be READ refuses in the same * voice — the raw errno error names the file (EACCES does). */ private readMark; /** `(sessionId, principal)` → a safe, INJECTIVE filename (sanitizeScope appends the full sha256 of the raw * composite key, so distinct keys never collide on disk). On a local-owner-adopted root the * anonymous lane resolves to the adopted principal's key (#132 — see {@link anonymousAlias}). */ private pathFor; /** The shared, injective stem of a key's two files — the row (`<stem>.json`) and its mark * (`<stem>${MARK_SUFFIX}`), so the pair can never drift onto different keys. */ private basenameFor; private markPathFor; private writeMarkFile; /** The mark as given — the erase's lineage transition rewrites a mark keeping whatever attribution it had. */ private writeMark; /** The keyed read: `null` is ABSENCE only (no file); a file that is there is served as rules or refused * ({@link refuseDamaged}) — it is never read as "no rules". */ private read; /** One row file → its JSON object, the fault that makes it not one (the read failure included), or `null` for * ABSENCE (ENOENT — also a row unlinked under a sweep, an honest absence). Every face reads a row through here. */ private readRowFile; getRules(sessionId: string, principal?: string): Promise<StoredSessionRules | null>; putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>; listBySession(sessionId: string): Promise<SessionRulesRecord[]>; deleteBySession(sessionId: string): Promise<void>; /** Erase ONE row this session owns: the number must outlive it, so it is folded into the key's mark BEFORE the * unlink, and the erase ends the row's lineage (`__gen` + 1) in that same single mark write — a crash in between * leaves a mark one erase ahead of a row that still exists, which the next erase counts again (a skipped * generation, and nothing else). Normally the mark is already ahead (every stamp writes it first); the fold earns * its keep on rows that predate the mark. A row is released only once its number is provably safe, so exactly ONE * fault may be absorbed: bytes that are not a mark. Those already block every future write to this key (`putRules` * refuses on them), so they are left as they are rather than replaced by evidence this sweep cannot check. A mark * that cannot be READ proves nothing: {@link refuseUnreadable}, and the erase is retried. `observed` = the read the * owner judgment already took — acted on as observed, so a mark cannot vanish between the judgment and the erase. */ private eraseRow; }