@sema-agent/core
Version:
Stateless, task-oriented AI agent core
233 lines (232 loc) • 16.9 kB
TypeScript
/**
* design/99 §E6 — per-session permission rules: a durable, session-scoped policy narrowing folded into the
* task-time {@link import("./tool-policy.js").ToolPolicy} (see {@link import("./runner/session-rule-policy.js").createSessionRulePolicy}).
*
* Why core-owned (not a parallel service store): the rules must be ENFORCED by the same policy fold that runs
* every tool call, so storage and enforcement can't drift (the capability-token invariant). core defines
* the seam + an InMemory default; a deployment supplies a DURABLE cross-replica backend (its CAS-rev semantics
* must match this InMemory one byte-for-byte — a cross-backend equivalence test is the contract).
*
* Safety model:
* - **tighten-only by default**: a NORMAL write may only ADD denies / NARROW allowlists / NARROW allowDirs. A
* write that LOOSENS (removes a deny, widens an allowlist/allowDirs) is REFUSED unless it is an OPERATOR
* write. core has NO role system — `operator` is a boolean the caller FREEZES from a verified principal
* (mirrors `steer.trusted`); core never adjudicates operator-ness, it only honors the flag.
* - **CAS rev** (optimistic concurrency): a `putRules` with a stale `expectedRev` is rejected, so two replicas
* can't lose an update (mirrors the checkpoint store's `resolve` OCC).
* - **structured fields only**: core interprets `toolAllow/Deny`, `allowDirs`, `commandAllow/Deny` (argv[0]
* names). A richer rule DSL is the service's/profile's job — core grows no rule interpreter.
* - **NOT A SANDBOX**: command rules inherit the coarse argv[0] matching (wrappers like `sh -c`/`sudo` circumvention);
* the real isolation boundary is the executionEnv, never these rules.
*/
import { type RuleToolNameVerdict } from "./tool-name-aliases.js";
/** The structured per-session rules. All optional; an absent field imposes no constraint of that kind. */
export interface SessionPermissionRules {
/** If set, ONLY these tool names are allowed (others denied). A narrowing allowlist. */
toolAllow?: string[];
/** Tool names always denied (deny wins). */
toolDeny?: string[];
/** If set, WRITE tools (`Write`/`Edit`) may only target paths within these dirs; a write-capable
* tool that can't be path-confined (e.g. `Bash`) is denied while this is set. Stored RAW; resolved at run time. */
allowDirs?: string[];
/** If set, ONLY these `Bash` command names (argv[0]) are allowed. */
commandAllow?: string[];
/** `Bash` command names (argv[0]) always denied. */
commandDeny?: string[];
}
/** {@link SessionPermissionRules} plus the monotonic `rev` the store stamps (the OCC key). */
export interface StoredSessionRules extends SessionPermissionRules {
rev: number;
/** The row's LINEAGE: which generation of this key the row belongs to. `rev` counts within one lineage; an
* erase (`deleteBySession`) ends it, and the next write on the key starts the next generation, so a reader
* can tell a recreated row from a continuation of the one it enforces whatever `rev` the new row wears.
* Absent = generation 0 (a backend that predates the mark, or a row written before it). */
gen?: number;
}
/** Options for {@link SessionPolicyStore.putRules}. */
export interface PutRulesOptions {
/** CAS: reject the write if the stored `rev` is not this (optimistic concurrency). Omit to skip the check. */
expectedRev?: number;
/** When true, this write MAY loosen the rules (remove a deny / widen an allowlist or allowDirs). A NORMAL
* (non-operator) write is TIGHTEN-ONLY. core does NOT verify operator-ness — the caller (service) FREEZES
* this from a verified, role-checked principal (mirrors `steer.trusted`). */
operator?: boolean;
}
/** The {@link SessionPolicyError.detail} of a tool-name refusal: which bucket, which entry, the judgment's word, and the
* 1:1 successor when the retired name has one. */
export interface SessionRuleNameRefusal {
field: SessionRuleToolNameField;
name: string;
verdict: Exclude<RuleToolNameVerdict["kind"], "current">;
currentName?: string;
}
/** Typed error from {@link SessionPolicyStore.putRules}. `conflict` = stale `expectedRev`; `loosen_forbidden`
* = a non-operator write tried to relax the rules; `corrupt` = a backend could not read the erase-surviving
* rev mark ({@link SessionPolicyStore.deleteBySession}) and refuses rather than stamp an unproven rev; the fourth
* code = a tool-name bucket names a retired or prefix-less name ({@link assertSessionRuleNamesCurrent}) — `detail` is
* present on that code and only on it. */
export declare class SessionPolicyError extends Error {
readonly code: "conflict" | "loosen_forbidden" | "corrupt" | "legacy_tool_name";
readonly detail?: SessionRuleNameRefusal | undefined;
constructor(code: "conflict" | "loosen_forbidden" | "corrupt" | "legacy_tool_name", message: string, detail?: SessionRuleNameRefusal | undefined);
}
/** Which buckets hold TOOL names (the ones a run's policy audit reads as names); a new bucket is given a role here. */
declare const HOLDS_TOOL_NAMES: {
readonly toolAllow: true;
readonly toolDeny: true;
readonly allowDirs: false;
readonly commandAllow: false;
readonly commandDeny: false;
};
type SessionRuleToolNameField = {
[K in keyof typeof HOLDS_TOOL_NAMES]: (typeof HOLDS_TOOL_NAMES)[K] extends true ? K : never;
}[keyof typeof HOLDS_TOOL_NAMES];
/** Construction options both bundled stores take. */
export interface SessionPolicyStoreOptions {
/** The names this deployment mounts its OWN tools under — the roster the store judges a rule's tool names against
* ({@link judgeRuleToolName}): a retired name declared here is an ordinary name to the write and to the loosening
* check alike. Absent = none. A name carrying `__` is refused, as it is for a deployment tool. */
mountedToolNames?: Iterable<string>;
}
/** A store's declared roster from {@link SessionPolicyStoreOptions.mountedToolNames} — the one reader both bundled stores
* (and so `FileStorageBackendOptions.sessionPolicy`) construct through, refusing loudly when it is not a list of plain
* tool names (a bare string would iterate by character). */
export declare function readMountedToolNames(value: unknown): ReadonlySet<string>;
/**
* The write judge for session rules, against `mountedToolNames` (the store's declaration; empty when it has none): every
* entry of a tool-name bucket must be `current` ({@link judgeRuleToolName}), or the write is refused (the fourth
* {@link SessionPolicyError} code) — such a name matches no tool, and a run's policy audit refuses to start with it.
* Operator writes included. Both bundled stores call it after normalizing and before {@link loosenReasons}; a durable
* backend calls it at the same point. The message names the entry and the way out, never a storage location.
*/
export declare function assertSessionRuleNamesCurrent(rules: SessionPermissionRules, mountedToolNames?: ReadonlySet<string>): void;
/**
* The seam a deployment implements for DURABLE, cross-replica per-session rules. Unset on `RunnerDeps` ⇒ the
* feature is OFF (no rules read, zero behavior change). Keyed on `(sessionId, principal-owner)`: core passes
* the running task's `principal` as the owner key; ownership/operator AUTHORIZATION is the implementation's job
* (core has no identity system). `getRules` returns the rules to ENFORCE for a task; `putRules` writes them.
*/
/** A (principal, rules) pair for one session — the unit {@link SessionPolicyStore.listBySession} returns. */
export interface SessionRulesRecord {
/** The principal these rules apply to (undefined = the session-wide default). */
principal: string | undefined;
rules: StoredSessionRules;
}
export interface SessionPolicyStore {
getRules(sessionId: string, principal?: string): Promise<StoredSessionRules | null>;
/** Write the rules for a key: CAS on `expectedRev`, then the name judge ({@link assertSessionRuleNamesCurrent} —
* operator writes included), then, for a non-operator write over an existing row, the
* tighten-only check ({@link loosenReasons}) — both against the names the deployment declares it mounts its own
* tools under. */
putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
/**
* 2c session-sync: every (principal, rules) record for `sessionId` across ALL principals — `getRules`
* is per-(sessionId, principal), but a cross-backend session EXPORT must bundle the WHOLE session's policy.
* The importer replays each via `putRules(sessionId, record.principal, record.rules)`. Optional — a backend
* that supports cross-backend policy export implements it (local backend uses the in-memory/file impl).
*/
listBySession?(sessionId: string): Promise<SessionRulesRecord[]>;
/**
* 2c session-sync, the ERASE half (server C1b, 2026-07-26): remove EVERY (principal, rules) record for
* `sessionId`. An importer that OVERWRITES a session must be able to erase the abandoned branch's policy
* rows — without this seam they survive the overwrite and keep enforcing the old branch's rules against
* the new content. Optional like {@link listBySession} (a sync-capable backend implements both); the two
* bundled impls do. Idempotent: deleting a session with no records is a no-op.
*
* @contract session_policy.rev_survives_delete — a key's `rev` (one key = `(sessionId, principal)`, the unit
* a row is stored under) is a HIGH-WATER MARK that survives this erase: the first `putRules` on that key
* after a `deleteBySession` stamps a `rev` strictly greater than every rev the key ever carried, so no later
* row of that key reads a smaller `rev` than an earlier one (another principal's row on the same session
* counts on its own), and a
* tighten-only (non-operator) write landing after an erase cannot slip under a floor a reader already
* enforced. A backend that reads "no row" as rev 0 must therefore keep the mark past the row (the bundled
* in-memory store and a deployment's SQL twin both do). The erase also ends the row's LINEAGE: the next
* write on the key carries the next `gen`, so a reader that enforces a row treats a later row of another
* generation as not comparable whatever its rev — a recreated row cannot pass for a continuation.
*/
deleteBySession?(sessionId: string): Promise<void>;
}
/**
* LEXICAL path containment for the tighten gate. **Must agree with the runtime's view of what a dir IS** (the
* runtime resolves `..`/`.`/`//` via `canonicalizeTarget`): both args are `posix.normalize`d FIRST so a
* non-operator can't carry across a write-confinement OUTSIDE the prior dir by writing `<prior>/../etc` — which is
* lexically "within `<prior>`" by a naive `startsWith` but canonicalizes to `/etc` at run time (the tighten
* gate must never UNDER-report a loosening).
*
* The `..`-resolving `posix.normalize` pass is THIS function's own job (the tighten gate's `..`-carrying across
* defense above) and is NOT shared with the runtime's `isWithin` (session-rule-policy.ts), which compares
* already-canonicalized keys and never resolves `..` itself — the two functions agree on every OTHER input
* (same win-family fold, same prefix semantics) but diverge on inputs that still carry an unresolved `..`,
* which is exactly the tighten-gate-only case this function exists to catch.
*
* RESIDUAL (documented, bounded): the gate is LEXICAL and cannot follow symlinks at write time, while the
* runtime IS symlink-aware. A stored allowDir whose LAST component is a pre-existing symlink to outside the
* prior set would be seen as "within" by this gate yet resolve elsewhere at run time. This is bounded: while an
* `allowDirs` confinement is active the runtime DENIES `bash` (non-path-confinable), so a confined non-operator
* cannot CREATE such a symlink — it must pre-exist on the deployment's filesystem. Operators / the durable
* backend should keep `allowDirs` to real directories; a future symlink-aware write-time canonicalize closes it.
*/
declare function lexWithin(root: string, p: string): boolean;
/** Normalize a rules object's `allowDirs` (lexical `posix.normalize`) so stored rules are `..`-free and the
* tighten diff compares the same canonical-ish form the runtime resolves. */
export declare function normalizeRules(rules: SessionPermissionRules): SessionPermissionRules;
/**
* Pure tighten-only diff: the reasons `next` LOOSENS `prior` (empty ⇒ `next` is a pure tightening or no-op).
* Direction per field — deny-lists: removing an entry loosens, except a `toolDeny` entry that is not `current` against
* `mountedToolNames` ({@link judgeRuleToolName}: it matches no tool, so removing it relaxes nothing — the way a session's owner
* takes out a stale entry that stops every run). allowlists: removing the list (→ everything allowed) or adding an
* entry outside the prior set loosens; prior-undefined → any defined next only narrows. allowDirs: removing
* confinement, or adding a dir not within some prior dir, loosens (coarse raw containment). `mountedToolNames` = the
* store's declaration ({@link SessionPolicyStoreOptions.mountedToolNames}; empty when it has none) — the write judge's too.
*/
export declare function loosenReasons(prior: SessionPermissionRules, next: SessionPermissionRules, mountedToolNames?: ReadonlySet<string>): string[];
/**
* Does row `a` belong to the SAME lineage as row `b` — the one question every reader asks before it compares
* two rows' `rev`, asked in ONE place so a `gen` that is ABSENT cannot mean three different things at three
* stations. It means what {@link StoredSessionRules.gen} says it means: generation ZERO. A row that spells
* `gen: 0` and a row that omits it are therefore the same row's generation, and a row of generation 1 does not
* continue either of them — which is the case the readings used to disagree on, each treating absence as
* "unknown, so do not judge" and letting a recreated row pass for a continuation of the one it enforced.
*
* ABSENCE STILL CARRIES ONE OTHER FACT, and it is a different question: a backend that stamps no lineage at
* ALL gives its reader no recycle evidence, so a reader that has one of its own (it watched the row vanish)
* keeps using it. That test asks whether the FIELD is there, never what it compares to, and it is the caller's
* to make — this predicate answers only "same lineage or not".
*/
export declare function continuesLineage(a: {
gen?: number;
}, b: {
gen?: number;
}): boolean;
/** strip any caller-passed `rev` from a rules object so the store is the sole authority on it. */
export declare function stripRev(rules: SessionPermissionRules): SessionPermissionRules;
/**
* In-process default {@link SessionPolicyStore} (single-replica). A deployment that needs cross-replica
* durability supplies its own backend with the SAME CAS-rev + tighten-only semantics. Single-threaded JS makes
* the read-check-write trivially atomic here; a durable backend folds it into a CAS WHERE clause.
*/
export declare class InMemorySessionPolicyStore implements SessionPolicyStore {
private readonly map;
/** {@link SessionPolicyStoreOptions.mountedToolNames}, read once at construction. */
private readonly mountedToolNames;
constructor(opts?: SessionPolicyStoreOptions);
/**
* The per-key mark {@link SessionPolicyStore.deleteBySession} may not erase: the largest `rev` this key has
* ever been stamped with, kept outside the row so removing the row cannot roll the counter back. Written by
* every stamp, read by the next one, never read by anything else, and never collected — an erase frees the
* row and keeps this, so the cost is one integer per distinct key the process has ever written.
*/
private readonly highWater;
private key;
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>;
}
/** Test-only escape hatch for the module-private tighten-gate containment predicate (mirrors the
* `__testables` convention in file-snapshot-store.ts) — lets a regression pin the fold's win-family
* conditionality directly rather than only through `loosenReasons`'s allowDirs branch. */
export declare const __testables: {
lexWithin: typeof lexWithin;
};
export {};