@sema-agent/core
Version:
Stateless, task-oriented AI agent core
210 lines (209 loc) • 15.7 kB
TypeScript
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;
}