UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

138 lines (137 loc) • 10.2 kB
/** * 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; }