@sema-agent/core
Version:
Stateless, task-oriented AI agent core
138 lines (137 loc) • 10.2 kB
TypeScript
/**
* The advertised-writable-directory seat — ONE rule for every directory the prompt tells the model
* it may write with the file tools:
*
* a directory the prompt advertises as writable is a directory the root fence ADMITS; the same
* admission decides the hole and the teaching, so a directory the fence cannot admit is neither
* opened nor taught, and the skip is announced.
*
* Two advertisers share it: the deployment's scratchpad (`TaskSpec.envFacts.scratchpadDir`, spoken
* by the `# Environment` block) and the memory engine's write root (spoken by the `# Memory`
* instruction, "write to it directly with the Write tool"). Before this seat each advertiser judged
* on its own: the scratchpad opened its hole in the hands phase, the memory instruction checked only
* that a `Write` tool was mounted — and the fence refused the very path the instruction named
* (`path_not_in_root`), a contradictory gate that taught the model to route around the tools.
*
* Admission = fail-closed canonicalization through the SAME ExecutionEnv the fence judges with (a
* raw or dangling entry is never admitted — the ancestor-canonicalize fallback would let a symlinked
* prefix carry writes outside every root). An advertiser that OWNS its directory's creation may ask
* for materialization on a DEFINITIVE `not_found` (the exclusive-create marker, so a concurrently
* planted path is never written through); an advertiser whose directory is created elsewhere passes
* no marker and an absent directory narrows.
*
* A directory the fence already holds is never admitted as a HOLE in it: a candidate that resolves onto
* a root — or above one — would turn that whole root into the advertised, write-gate-exempt domain, so
* an ordinary source write inside the workspace would stop asking. One predicate
* ({@link swallowedFenceRoot}) states that, and the write gate's configured domains apply the same one.
*
* Reverse check (the seat's own invariant): nothing enters `roots` except through `admit`, and every
* caller of `admit` is an advertiser deciding its advertisement off the returned verdict — so no
* directory reaches the ROOTS FENCE through this seat without the prompt advertising it, and none
* the prompt advertises is refused by that fence.
*
* The invariant is about this seat, not about the whole write surface: under an open write face
* ({@link import("../../tools/fs/containment-face.js").ContainmentFace}) the roots fence judges
* nothing, so what the prompt advertises is a floor rather than the whole list — the `# Environment`
* block says so in its own line, and the advertisement stays true (these directories ARE writable).
*/
import type { ExecutionEnv } from "../../internal/harness.js";
export interface AdvertisedWritableDir {
/** The directory as the advertisement spells it. */
path: string;
/** Who advertises it — names the skip line and the trace. */
advertiser: "scratchpad" | "memory";
/**
* How the directory comes to exist when the env reports it absent. The advertiser that owns the
* directory's creation supplies the marker file name for the atomic exclusive-create
* materialization (parent dirs created like writeFile; a marker that already exists — planted
* concurrently, symlink or not — is never written through; an env without an exclusive primitive
* narrows instead of emulating one with an overwriting write). An advertiser whose directory is
* created elsewhere (the memory engine materializes its own root) passes nothing — absent narrows.
*/
materializeMarker?: string;
/**
* The canonical spelling the advertiser's OWN gates key on, when it has one (the memory engine keys its
* write gate and its harvest boundary on `MemorySessionHandle.writableRoot`). A fence that resolves the
* directory to a different canonical path would admit writes those gates never see — such a mismatch is
* a skip, announced with both spellings, never an admission.
*/
expectCanonical?: string;
}
/** The skip announcement — the one loud outlet for a directory that was advertised but could not be admitted. */
export type AdvertisedWritableDirSkipped = (dir: AdvertisedWritableDir, reason: string) => void;
/**
* ONE rule for both halves of the auto-approved write surface: a directory offered as a WRITABLE DOMAIN
* (an advertised writable directory here; a configured `exemptDirs`/`acceptDirs` entry at the write
* gate) must not SWALLOW a root the fence already holds. Returns the first root the candidate equals or
* is an ancestor of — the one to name in the refusal — else `undefined`.
*
* Why a domain that contains a root is not a domain: the point of the domain is to exempt a small area
* the prompt (or the deployment) named. A candidate resolving onto the task root — a symlinked
* scratchpad or memory home is all it takes, and the resolution is what makes it invisible to the
* spelling — exempts the ENTIRE workspace instead, turning every ordinary source write from `ask` into
* `allow`. Refusing the admission is the fix; a special case inside the gate ("ignore this domain when
* it contains the root") would be the same rule written twice, in the place where it is easiest to
* forget. Both arguments must already be CANONICAL — this compares spellings, it does not resolve them.
*/
export declare function swallowedFenceRoot(domainCanonical: string, fenceRootsCanonical: readonly string[]): string | undefined;
export declare class AdvertisedWritableDirs {
private readonly env;
private readonly onSkipped;
/** The extra fence roots the SPEC declares (`additionalDirectories` / `additionalReadDirectories`), as
* spelled. Resolved by this seat rather than awaited from the hands phase — the memory advertiser is
* admitted BEFORE that phase, so a list that only grew there made the rule depend on phase order. An
* entry that does not resolve is not a fence root at all (the hands phase reads the SAME memoized
* answer and skips it too, announcing it there), so there is nothing here for it to guard. */
private readonly declaredRootSpellings;
private readonly admitted;
private readonly verdicts;
/** The CANONICAL roots the fence holds: the task root at construction, plus every root the spec
* DECLARES, resolved by this seat before it judges anything (see {@link resolveDeclaredRoots}) —
* so which advertiser runs first cannot decide what the rule sees. */
private readonly fenceRoots;
/** Latched after the declared spellings have been resolved once (the resolution is per seat, not per call). */
private declaredRootsResolved;
/** ONE answer per declared spelling, for this seat AND for the hands phase that folds the same entries
* into the allowlist ({@link canonicalizeDeclaredRoot}): two independent lookups of one path can
* DISAGREE, and a root outside this rule's list while inside the fence is exactly the gap. */
private readonly declaredRootAnswers;
constructor(env: ExecutionEnv, onSkipped: AdvertisedWritableDirSkipped,
/** The run's task root, CANONICAL (the driver's one resolution — see `task-root-canonical.ts`). */
taskRootCanonical: string,
/** The extra fence roots the SPEC declares (`additionalDirectories` / `additionalReadDirectories`), as
* spelled. Resolved by this seat rather than awaited from the hands phase — the memory advertiser is
* admitted BEFORE that phase, so a list that only grew there made the rule depend on phase order. An
* entry that does not resolve is not a fence root at all (the hands phase reads the SAME memoized
* answer and skips it too, announcing it there), so there is nothing here for it to guard. */
declaredRootSpellings?: readonly string[]);
/** Declare one more CANONICAL root the fence holds (an `additionalDirectories` /
* `additionalReadDirectories` entry, once the hands phase has resolved it). Declaring is
* idempotent and order-free; a root declared after an advertiser was judged does not re-open it. */
declareFenceRoot(canonical: string): void;
/**
* The canonical form of a DECLARED fence-root spelling — resolved at most once per spelling and
* served from the memo thereafter. The hands phase calls this instead of canonicalizing the same
* entries again, so the list this rule judges against and the allowlist the fence ends up holding
* are built from ONE answer per path: a lookup that fails for this seat fails for that phase too,
* and a root cannot be outside the rule's view while being inside the fence.
*/
canonicalizeDeclaredRoot(spelled: string): Promise<Awaited<ReturnType<ExecutionEnv["canonicalPath"]>>>;
/** Resolve the spec-declared root spellings into {@link fenceRoots}, exactly once per seat. */
private resolveDeclaredRoots;
/** The admitted roots, CANONICAL, in admission order, deduplicated. The hands phase folds this list into the
* write-capable containment allowlist before the toolkit compiles; it is not a live view after that. */
get roots(): readonly string[];
/** What became of an advertiser's directory: admitted, skipped (announced), or never asked (the advertiser
* had nothing to advertise, or decided not to). The prompt side reads this to withhold an advertisement. */
verdictOf(advertiser: AdvertisedWritableDir["advertiser"]): "admitted" | "skipped" | "not-asked";
/**
* Admit one advertised directory. TRUE ⇔ the fence will admit writes under it (its canonical form is on
* {@link roots}); FALSE ⇔ it was skipped (announced through the seat's outlet) and the advertiser must not
* advertise it. The env's answers are Result-shaped, so the judgment itself never throws; the skip outlet is
* the caller's own sink (`deps.onError` in the driver) and is deliberately not guarded here — every phase
* lets that sink's own exceptions propagate, and a seat that swallowed them would hide the deployment's bug.
*/
admit(dir: AdvertisedWritableDir): Promise<boolean>;
private judge;
}