UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

137 lines (136 loc) • 10.9 kB
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 SessionRuleEntryField, type StoredSessionRules } from "../session-policy-store.js"; import type { PathRuleBases } from "../permission-rule-model.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 call's id is the provider's: unique within one assistant response only (`brain/tool-call-id.ts`). */ 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 calls its entries reach — the one list matcher's deny reading (`judgeAllowEntries`): a tool name * under any name the call answers to, a protocol namespace, a shell command rule over every program the command line runs * (a line it cannot read against the rule is refused too), a path rule over every view of the call's target. * - `toolAllow` (if set) → deny any call no entry admits (the matcher's allow reading in the `narrowing` posture: every * segment of a command line named by an entry; never the allow layer's auto-mode strip), naming the uncovered segment. * - an entry that reads as nothing → in no row (it guards and grants nothing; the prepare-time audit names it, or * `onEarlierGrammar(entries, bucket, grammar)`, once per distinct set, when its snapshot's own earlier grammar reads it so). * - a snapshot that cannot be read (its shape; a `toolDeny` entry no grammar reads) → deny every call, never quoting it. * - `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). * The policy structurally emits no ask. 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`, and a path rule's pattern against `anchors` (the leg's root and home — absent, `rootPath` * alone, so a home-relative pattern is unreadable and refuses in the deny direction). * * 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>; anchors?: PathRuleBases; onEarlierGrammar?: (entries: readonly string[], field: SessionRuleEntryField, grammar: number) => void; }): NamedToolPolicy;