@sema-agent/core
Version:
Stateless, task-oriented AI agent core
131 lines (130 loc) • 9.94 kB
TypeScript
import type { ExecutionEnv } from "../../internal/harness-types.js";
import type { ToolEffect } from "../types.js";
import { type NamedToolPolicy } from "../tool-policy.js";
import { type SessionPermissionRules, type SessionPolicyStore, type StoredSessionRules } from "../session-policy-store.js";
/** The FULL path-confinable write-tool set: every catalog tool declaring a write path target — `Write` / `Edit`
* (target `file_path`) and `NotebookEdit`, whose write target is `notebook_path` and is extracted by the shared
* tool-aware {@link writeTargetPath} (notebook_path outranks a decoy `file_path` for that tool).
*
* REF-B3 — the ONE spelling of "tools this codebase can confine by path". Consumers:
* {@link createSessionRulePolicy} (below, `allowDirs`) and `createFsWriteGatePolicy`
* (core/fs-write-gate-policy.ts), which used to rebuild `["Write", "Edit", "NotebookEdit"]` inline.
* A fourth spelling survives as a LITERAL in core/tool-policy.ts (the transcript-dir gate) because THIS
* module imports tool-policy.ts — that one is a documented coordinate held in sync by a source-scan pin
* (SW-10), not a silent copy.
*
* SW-10 relax (ruled 2026-08-04): `NotebookEdit` was previously absent from the session-rule face's
* covered set, so an `allowDirs` confinement denied it OUTRIGHT via the "mutating but not path-confinable"
* arm — a capability GAP relative to the fs-write gate, which has always proven the tool confinable.
* Both faces now share this set and the same target extractor, so the session face confines notebooks by
* path instead of refusing them. The fail-closed deny for every OTHER non-confinable mutating tool
* (bash, deployment-authored write tools, unknown effects) is unchanged. */
export declare const PATH_CONFINABLE_WRITE_TOOLS: ReadonlySet<string>;
/** Path-segment-aware containment, FAMILY-AWARE ([K-PLATFORM-SWEEP] MAJOR#2: canonical keys are
* backslash-form on a win32 env — the "/"-hardcoded suffix rejected every legitimate write there).
* Compare in "/" form with drive-letter case folded; never keyed on process.platform (the env may be
* remote and of the OTHER family than the host).
*
* REF-B3 — exported as the single source for the policy modules that fence WRITES by an allowlist of
* directories: fs-write-gate-policy.ts (`acceptDirs`
* /`exemptDirs`), plus the K-PLATFORM sweep tests. The line here used to read "exported ONLY for the
* K-PLATFORM sweep tests", which was already false when written (fs-write-gate-policy.ts imports it in
* production), and a since-retired skill fence carried a byte-identical private twin — comment included — so
* a platform-family fix had to be remembered at two coordinates.
*
* The sibling implementation is `within()` in tools/fs/safety.ts (the fs-tool root fence). Those stay
* separate functions on purpose — that one is private to the fence and keyed on canonical roots — but
* they must never disagree on a verdict; the empty-root arm below is that alignment. */
export declare function isWithin(root: string, p: string): boolean;
/**
* Where a session-rule layer takes the rules it enforces.
*
* @contract session_rules.every_gate_re_reads — a session-rule layer re-reads its rule source at EVERY
* tool gate, not once at prepare: an operator's mid-run write (a new deny, a narrowed allowlist) binds the
* very next call of the SAME run, on the same beat as the permission-rule store's per-call read. `seed` is
* the prepare-time snapshot and stays the FLOOR — a read that fails, a row that was deleted, or a lagging
* replica whose `rev` is below the snapshot's enforces the seed rather than loosening toward it — and it is
* also what the prepare-time policy-name audit sees, so a rule written after prepare is enforced but not
* audited. A source with no `read` is a constant one: the seed at every gate, which is what a caller that
* hands a plain rules object gets.
*/
export interface SessionRuleSource {
/** The prepare-time snapshot: the audit's view, and the floor no live read may loosen. */
seed: SessionPermissionRules;
/** The per-call read, keyed on the call being adjudicated, and bounded by the gate's own signal. Absent
* ⇒ the seed is the whole source. */
read?: (toolCallId: string, signal?: AbortSignal) => Promise<SessionPermissionRules>;
}
/**
* The LIVE half of {@link SessionRuleSource}: re-read `(sessionId, principal)` from the store on each
* adjudicated call, floored by `seed`.
*
* ONE law, two consumers (this task's own session rules and each inherited ancestor's), where the policy
* chain used to carry a hand-written live-preferring read for ancestors only and a frozen snapshot for the
* task's own rules. Direction, unchanged from that read and stated once here: a whole-row replacement
* includes RELAXATION — an operator's authorized post-prepare rule change propagates in BOTH directions;
* what the floor forbids is an UNAUTHORIZED loosening (a lagging replica, a deleted row, an unreachable
* store), and nothing task-side can edit the rules row itself.
*
* The floor RATCHETS to the highest-rev row this layer has actually served, and a live row replaces it only
* on a STRICTLY GREATER `rev`. Two reasons, and the second is why "greater or equal" is not good enough:
* · pinning the floor to the prepare snapshot would make "a failed read never loosens" false in the one
* sequence that matters — a row tightened mid-run, enforced at one gate, then unreadable at the next
* would fall back PAST the tightening to the snapshot the run started with;
* · `rev` is per-ROW, and both bundled backends compute it as `(prior?.rev ?? 0) + 1`, so a row that is
* DELETED and written again starts over at 1. At `>=`, a recreated row whose rev merely TIES the
* enforced one would replace it — an unauthorized loosening dressed as an update. At `>`, a tie enforces
* the floor, which is a no-op for the honest case (equal rev is the same row) and fail-closed for the
* recycled one. Closed since 7.25.0 by the STORE's lineage identity (`StoredSessionRules.gen`): a
* recreated row is another generation and is never compared to the enforced one, whatever its rev, and
* a backend without lineages (gen 0 throughout) keeps the `>` protection above.
*
* The per-call handoff mirrors the permission-rule lanes': the first reader of a `toolCallId` takes the read
* and hands it on, a second reader of the SAME call claims that one snapshot, and an unclaimed entry is
* evicted first-in-first-out past a bound. It is a cost device, never a semantic one — a reader that cannot
* claim simply reads again. Its firing path here is the fold itself: `policyLayers` re-appends the narrowing
* layers AFTER a rewrite-capable caller layer, so one adjudication asks this layer twice under one id, and
* the leading (original args) and trailing (rewritten args) passes must judge ONE set of rules.
*
* Two bounds keep a handed entry from ever being a STALE one, because `toolCallId` is not always unique —
* the turn-boundary read-visibility query reuses one synthetic id for every path it screens and for every
* turn of the run. The entry is taken ONCE, and a claimed snapshot is served only while its `rev` still
* stands at the enforced floor; a claim from a later adjudication, whose floor has moved on, falls through
* to a fresh read. A real tool call's id is minted per call and never repeats.
*/
export declare function createLiveSessionRules(store: SessionPolicyStore, floor: {
sessionId: string;
principal?: string;
seed: StoredSessionRules;
}, hooks?: {
/** A rule source this layer could not read, once per distinct reason. */
onUnreadable?: (reason: string) => void;
/** The store row this layer now enforces, each time the floor ADVANCES. Never called for the synthetic
* empty seed: it reports rows the store actually served, which is what a child may inherit. */
onEnforced?: (row: StoredSessionRules) => void;
}): (toolCallId: string, signal?: AbortSignal) => Promise<SessionPermissionRules>;
/**
* Build the per-session deny-narrowing policy. For each tool call:
* - `toolDeny` → deny the listed tools.
* - `toolAllow` (if set) → deny any tool NOT listed (a narrowing allowlist).
* - `commandAllow`/`commandDeny` → delegate to {@link createCoarseCommandNamePolicy} (argv[0] names; only
* speaks about shell tools) with `defaultAction:"deny"` in BOTH modes — so this policy structurally
* emits no ask. Allowlist mode denies the un-listed and the un-parseable; blocklist mode denies the
* listed and the un-parseable (a parseable command absent from the deny list stays allowed).
* - `allowDirs` (if set) → a WRITE tool must land inside one of the dirs (resolved via `canonicalizeTarget`,
* so a symlink can't carry across a write out); a write-capable tool that cannot be path-confined (e.g. `bash`)
* is DENIED while `allowDirs` is set (fail-closed).
* Returns `allow` when nothing constrains the call. `env`/`rootPath` are the task's real exec env + root, so
* matching sees the actual filesystem (a remote/E2B task resolves in its container). A relative
* TARGET prefers the call's live tracked cwd ({@link ToolCallRequest.cwd}) when the engine stamps one;
* `allowDirs` entries always resolve against `rootPath`.
*
* The rules are taken from a {@link SessionRuleSource} at EVERY call
* ({@link SessionRuleSource} carries the law); a plain rules object is the constant source, which is what
* every caller outside the policy chain hands, and what a layer whose rules cannot change under it needs.
*/
export declare function createSessionRulePolicy(rules: SessionPermissionRules | SessionRuleSource, opts: {
env: ExecutionEnv;
rootPath?: string;
toolEffects?: ReadonlyMap<string, ToolEffect>;
}): NamedToolPolicy;