@sema-agent/core
Version:
Stateless, task-oriented AI agent core
271 lines (270 loc) • 20.7 kB
TypeScript
import type { ExecutionEnv } from "../internal/harness-types.js";
import type { PermissionResult } from "./tool-policy.js";
import { type ToolCallLike } from "./tool-registry.js";
/**
* How one table row matches the judged path:
* · `"basename"` — the target's last segment equals the row name (CC `DANGEROUS_FILES` form);
* · `"segment"` — ANY path segment equals the row name (CC `DANGEROUS_DIRECTORIES` form);
* · `"segment-run"` — a CONSECUTIVE run of segments equals the row's `/`-separated segments
* (CC `DANGEROUS_DIRECTORY_PATHS` form, e.g. `.config/git`).
*/
export type WriteProtectedKind = "basename" | "segment" | "segment-run";
/** One row of the write-protection table: a LITERAL name and how it matches. The `name` is also the
* row's stable identity — the string a refusal/ask message cites. */
export interface WriteProtectedRow {
readonly name: string;
readonly kind: WriteProtectedKind;
}
/**
* One deployment-authored entry for the replacement seat (`RunnerDeps.writeProtectedPaths`).
* String shorthand: a bare name ≡ `{ name, kind: "segment" }` (the WIDER single-segment kind —
* over-matching is the fail-safe direction for a tighten); a name containing `/` ≡
* `{ name, kind: "segment-run" }`. Spell `kind: "basename"` explicitly when last-segment-only
* matching is the intent. A name opening with the home word (`~/…`) refuses: the table places no row at a home.
*/
export type WriteProtectedEntry = string | WriteProtectedRow;
/** A table hit: which row matched (the row's canonical name + kind). */
export interface WriteProtectedHit {
readonly name: string;
readonly kind: WriteProtectedKind;
}
export declare const WRITE_PROTECTED_DEFAULT_TABLE: readonly WriteProtectedRow[];
/**
* The ONE SEGMENT fold of this module, applied to BOTH sides of every comparison (table names at
* compile, path segments at match), in the reference implementation's own order:
* · `toLowerCase` plus the two confusable letters its fold maps (U+0131 dotless ı → i, U+017F long ſ → s);
* · the INVISIBLE FORMAT characters it strips — the zero-width joiners/non-joiners, the bidirectional
* overrides and the byte-order mark (U+200C–U+200F, U+202A–U+202E, U+206A–U+206F, U+FEFF), verbatim.
* A segment carrying one of these LOOKS like the row it is not, on both sides of every comparison, so
* the strip is a deception axis rather than a platform one: it applies to every reading below, not to
* the win32 one alone. It can only ADD matches, which on a TIGHTEN costs one extra question — and the
* data-root passthrough, where an added match would be a guard BYPASSED, deliberately does not use this
* fold at all (its comparison is byte-exact; see {@link relativeUnderDataRoot}).
* · …and a strip that would EMPTY the segment keeps the segment instead (the reference fold's own `|| n`):
* an all-invisible component names a real file, and folding it to "" would make it collide with nothing
* or with everything depending on the caller.
* Deliberately NOT full Unicode confusable folding: that would be a different, wider claim than the one made
* (the read deny set's ASCII-contract doc states the same boundary for its own fold). The NTFS data-stream
* suffix the reference fold also strips is a win32 NAMESPACE fact, not a comparison one, and lives with the
* other component normalization of those views ({@link ComponentNormalization}) so a POSIX file legally
* named `a:b` keeps its own identity on the POSIX reading.
*/
export declare function foldWriteProtectSegment(s: string): string;
/**
* Resolve the ACTIVE table under the deployment seat (validated, loud — #123's bad-value states all
* throw and name the knob). `undefined` = the default table; `[]` = NO table (an explicit, legal
* posture — the whole-table escape hatch's empty end); a non-empty list REPLACES the default table
* whole (compose additions as `[...WRITE_PROTECTED_DEFAULT_TABLE, …]`, drop rows by filtering the
* exported table — the visible/deletable admin face). Exact duplicates fold (idempotent, not one of
* #123's bad-value states). Exported so an admin face can preview the effective table under a
* candidate configuration with the engine's own rules.
*/
export declare function resolveWriteProtectedTable(entries?: readonly WriteProtectedEntry[]): readonly WriteProtectedRow[];
/**
* WHY the ENVIRONMENT half of one judgment could not contribute — the canonicalizer refused the spelling,
* a link the tree could not follow, a namespace spelling it declines, an environment call that threw.
* Reported through {@link WriteProtectionCompileOptions.onViewAbsent}; it is a DISCLOSURE, never a verdict
* (see the absence law on {@link WriteProtectionMatcher.judge}).
*/
export interface WriteProtectionViewAbsence {
/** The environment's own sentence, verbatim — what it said when asked to resolve this target. */
readonly cause: string;
/** The failure was specifically a symlink whose real target is unknowable, as opposed to a spelling the
* tree refuses or a probe that errored. */
readonly unresolvedSymlink: boolean;
}
/**
* The judge's answer for ONE covered write ({@link WriteProtectionMatcher.judge}):
* · `clear` — no row on any view that could answer: the write is not this layer's business;
* · `hit` — a row matched; `via` says which view named it: `"spelling"` (the anchored spelling, no
* filesystem consulted), `"chain"` (a spelling the leaf symlink chain passes through, judged as a
* spelling) or `"target"` (the resolved real target, the spelling being innocent);
* · `unresolvable` — NO half of the judgment answered at all. The judge itself no longer mints this: an
* environment that cannot resolve the target has ANSWERED, and an answer of "I cannot resolve this"
* leaves the lexical half's judgment standing (the absence law below). What is left for this verdict is
* the judgment that never happened — the gate's bounded wait elapsing or the task ending before
* anything came back (`awaitWriteProtectionJudgment`, gate-bounded-await.ts) — where there is no
* lexical answer to stand on either, and the consumer REFUSES. `unresolvedSymlink` is that minter's
* `false`: a wait that produced nothing knows nothing about what it was waiting for.
*/
export type WriteProtectionVerdict = {
readonly kind: "clear";
} | {
readonly kind: "hit";
readonly hit: WriteProtectedHit;
readonly via: "spelling" | "chain" | "target";
} | {
readonly kind: "unresolvable";
readonly cause: string;
readonly unresolvedSymlink: boolean;
};
/** What one judgment knows about the call, all optional — every member has an absence reading. */
export interface WriteProtectionJudgeContext {
/** The call's LIVE tracked working directory (see {@link WriteProtectionMatcher.matchPath}). */
readonly cwd?: string;
/** The base a RELATIVE spelling resolves against on the TARGET view — the tool's own declared base
* ({@link pathTargetBaseOf}). Absent ⇒ `cwd`, then the compile-time `rootPath`, then the environment's
* own working directory: the same fallback chain the write tool itself walks. */
readonly base?: string;
/** Bounds the filesystem probes of the target view. */
readonly signal?: AbortSignal;
/** The path is a CANONICAL key the caller already resolved at the seat its program writes it (the shell read station's
* established write target: where the path lands, or — for a removal or a rename — the entry itself, its canonical
* parent joined with the last name, a link there not followed). It is judged where a key is judged — the readings and
* the canonical data-root exemption — never as a spelling (the lexical passthrough reads a spelling, and a key reached
* through a linked data root does not spell itself inside it) and never followed: no link chain is walked from it, as
* the program walks none from where it writes. Without an environment the key is judged as a spelling (the absence law). */
readonly canonical?: true;
}
/**
* WHICH reading of a write's TARGET a compiled judge has — its own word, and the vocabulary the wiring
* manifest discloses it with. `"target"`: the judge holds an execution environment, so a spelling that
* misses the table is judged a SECOND time on its resolved real target (an alias, a symlink, a `..` walk
* that lands on a protected row is seen). `"spelling-only"`: no environment, so the spelling IS the whole
* judgment and a path reaching a row only through an alias is cleared — the module header's absence law,
* unchanged by this word, which only makes it READABLE. Never derived by a reader from the judge's seats.
*/
export declare const WRITE_PROTECTION_TARGET_VIEWS: readonly ["spelling-only", "target"];
/** One of {@link WRITE_PROTECTION_TARGET_VIEWS}. */
export type WriteProtectionTargetView = (typeof WRITE_PROTECTION_TARGET_VIEWS)[number];
/** The compiled judge over one resolved table. `matchPath` is the SPELLING view alone (literal folded
* comparisons, no filesystem access — the admin preview face); `judge` is the whole judgment (see the
* module header for the two views). */
export interface WriteProtectionMatcher {
/** WHICH reading of a target this judge HAS — minted at compile and read verbatim by everyone who needs
* the fact; a reader re-deriving it from the seats is a second opinion, free to drift. */
readonly targetView: WriteProtectionTargetView;
/** The resolved rows this judge was compiled from (canonical names — the disclosure face). */
readonly rows: readonly WriteProtectedRow[];
/** Judge ONE path SPELLING. Returns the first matching row (basename rows first, then segment,
* then segment-run — deterministic), or null.
*
* `cwd` = the call's LIVE tracked working directory, read at judgment time off the ONE ref the fs
* hand tools resolve with — the law every path guard here follows: judge the file the tool will write.
* Absent ⇒ judged exactly as given — the historical reading, and the honest answer for a host
* that tracks no cwd. */
matchPath(path: string, cwd?: string): WriteProtectedHit | null;
/**
* Judge ONE covered write by the file the tool will write: the spelling view first (`matchPath`; a hit
* there is the answer and costs no I/O), then — on a miss, and only when the judge was compiled with an
* execution environment — the resolved real target through the shared `canonicalizeTarget`.
*
* @contract write_protection.judges_the_real_target — the always-on write-protection table and the
* opt-in sensitive-path deny set differ in VOCABULARY (literal rows / segment globs) and SEVERITY
* (ask / deny), never in REACH: both judge the file the tool will write — the spelling anchored at the
* live cwd AND its resolved real target, through one canonicalizer. Where they DO part: a target the write-protection
* judge cannot resolve leaves its resolved view ABSENT (the spelling still judges); the deny set still REFUSES whole. An environment-less judge has no target view and answers by the spelling alone —
* the same absence law the cwd follows (absent ⇒ the spelling is judged exactly as given). REACH is what
* this sentence promises, not a LOCK: the identity is resolved at judgment time and re-resolved by the
* tool at execute time (the module header states that window and the seat that would close it).
*/
judge(path: string, ctx?: WriteProtectionJudgeContext): Promise<WriteProtectionVerdict>;
}
/** Compile-time seats of {@link compileWriteProtection}. Every member is optional and has an absence reading. */
export interface WriteProtectionCompileOptions {
/** The engine data root for the passthrough in the module header (same seat and same meaning as
* `createSensitivePathPolicy`'s): omitted ⇒ the knob its consumers read (`$AGENT_DATA_DIR ?? ~/.ai-agent`). */
readonly dataRoot?: string;
/** The execution environment the hands write against — the TARGET view resolves in ITS filesystem
* (a remote/sandboxed task resolves in its container). Omitted ⇒ no target view: the spelling is the
* judgment (a host with no hands has no filesystem to resolve against). */
readonly env?: ExecutionEnv;
/** The task root — the base a `base: "root"` write tool's relative spelling resolves against, and the
* path-family source when the call carries no cwd. Omitted ⇒ relative spellings resolve against the
* cwd, then the environment's own working directory. */
readonly rootPath?: string;
/**
* Called at most ONCE per judgment, at the moment the ENVIRONMENT half is found unable to contribute
* ({@link WriteProtectionViewAbsence}) — before the judgment returns, and on the hit arm as well as the
* clear one, because the fact is about the ENVIRONMENT rather than about the answer it did not change.
* Omitted ⇒ the absence is silent, which is the honest default for a judge compiled outside a leg.
* A throwing sink is the caller's bug and is not caught here; the leg that wires one wraps it.
*
* Deliberately carries no call identity: what it reports is a deployment-level fact (this environment
* cannot answer path questions), and its consumer announces once per leg rather than once per write.
*/
readonly onViewAbsent?: (absence: WriteProtectionViewAbsence) => void;
}
/**
* Compile the write-protection judge for a deployment configuration. Returns `undefined` when the
* resolved table is EMPTY (`[]` replacement) — the caller then mounts no tighten at all, keeping an
* opted-out deployment's decision path byte-identical to a build without this layer.
*
* The data-root spellings are read ONCE here at compile (a judge that re-read `process.env` per call
* would answer two calls of one run differently); their CANONICAL keys are resolved per judgment, on the
* target view only, and only once a row has matched there (see {@link canonicalDataRootKeys} for why they
* are never cached). The passthrough is a property of the JUDGE, not of the default table — a deployment
* that replaces the table wholesale gets the same law over its own rows.
*/
export declare function compileWriteProtection(entries?: readonly WriteProtectedEntry[], opts?: WriteProtectionCompileOptions): WriteProtectionMatcher | undefined;
/**
* The gate tighten's DECISION for one judgment — the words the fold writes on the pass, minted beside the
* verdict they read so the two cannot drift:
* · `hit` ⇒ `ask` (`safety`; no `requiresRealApproval` — one explicit look, clearable by an approver, a
* blanket `onAsk:"allow"`, the classifier or a matching persisted rule). The matched row NAME goes on the
* message (table names are deployment-validated vocabulary); the model-authored target path deliberately
* does not — `message` is the classifier's `askMessage`, and the approval card already shows the args. The
* spelling form's sentence is the historical one byte for byte; the target form says the target RESOLVED.
* · `unresolvable` ⇒ `deny` naming the cause: an unjudged target is never vouched, and an ask would be a
* question about a file nobody can name — the rule the canonicalizing deny set follows for the same reason.
* · `clear` ⇒ `undefined`: the surviving decision stands.
*/
export declare function writeProtectionTightenDecision(toolName: string, verdict: WriteProtectionVerdict): PermissionResult | undefined;
/**
* @contract write_protection.verdict_is_the_mandate — the judge's two non-clear verdicts are the two facts
* a standing allow rule may not retire, and `clear` is neither: a MATCHED row is the mandate a live decider
* may still clear, a target it could not RESOLVE is the mandate no automatic lane may. These two predicates
* are the ONE reading of a verdict as either fact, beside the decision mint, so the fold records what the
* judge said and never re-derives "was this write protection?" from the decision it got back.
*/
export declare function writeProtectionMandated(verdict: WriteProtectionVerdict): boolean;
/** The second half of {@link writeProtectionMandated}'s contract: the judge could not name this target
* (the arm that owns the ACTION denies on the same verdict). */
export declare function writeProtectionUnresolved(verdict: WriteProtectionVerdict): boolean;
/**
* The ENGINE-FILLED gate input (`ToolGateInput.writeProtectionCheck`). Returns `null` SYNCHRONOUSLY when the
* call is not this layer's business — not a covered write, or no resolvable target string — and otherwise
* the judgment of {@link WriteProtectionMatcher.judge}, a promise. The split is load-bearing for the gate:
* a call the judge does not cover must not cost the fold a suspension point.
*/
export type WriteProtectionCheck = (toolName: string, args: unknown, face?: ToolCallLike["face"], cwd?: string, signal?: AbortSignal) => Promise<WriteProtectionVerdict> | null;
/**
* ONE leg's write-protection mount: the gate input and the judge's own {@link WriteProtectionTargetView},
* handed back together so the two readers of this compile — the gate station that mounts the check and the
* disclosure face that reports which reading it got — read ONE compile. `undefined` in place of the whole
* record is "no judge" (an emptied table), a different fact from a judge whose target view is absent.
*/
export interface WriteProtectionMount {
/** The gate input (`ToolGateInput.writeProtectionCheck`). */
readonly check: WriteProtectionCheck;
/** The compiled judge's own word for the reading it has of a write's target. */
readonly targetView: WriteProtectionTargetView;
}
/**
* Build the ENGINE-FILLED gate input (`ToolGateInput.writeProtectionCheck`): covered tools are the
* path-confinable write set (Write/Edit/NotebookEdit — the shared spelling), the judged target is
* the SAME string the tool itself will resolve (`writeTargetPath`, the shared single source with
* the sensitive-path guard and the fs-write gate), and the verdict is the table judge's. A covered
* write with NO resolvable target string returns null here — this layer is ADDITIVE friction on a named
* set of targets, not a containment boundary (a target-less call cannot land on a named row, and the
* tool's own schema validation refuses it before any write); the fail-closed treatment of that case
* belongs to the containment gates (fs-write-gate-policy documents that split). A target string the
* environment cannot RESOLVE is the judge's own `unresolvable` verdict, not this null.
* Returns `undefined` when the resolved table is empty — nothing to judge, mount nothing.
* `opts` are the judge's own seats, passed straight through ({@link compileWriteProtection}).
*
* @contract write_protection.membership_is_the_call — WHICH calls the always-on table judges is a
* PREDICATE OVER THE CALL and nothing else: the call's own face declares a path target whose access is not
* a read, so a custom writer joins this judge by DECLARING what it writes and a tool that declares nothing
* is not this layer's business under any name. There is deliberately NO name-list seat beside it, and the
* asymmetry with the opt-in deny policy — which does take one (`createSensitivePathPolicy({ tools })`, its
* own pre-declaration hatch) — is the intended shape rather than a gap: a name list on a default-ON floor
* is a way to narrow that floor silently, one tool name at a time, while the seat this layer gives a
* deployment for narrowing is the whole-table replacement, which says what it removes. A deployment that
* wants name-list membership composes the deny policy alongside; the two then answer under the shared
* `deny > ask > allow` fold, which is what makes the missing seat cost nothing.
*/
export declare function createWriteProtectionCheck(entries?: readonly WriteProtectedEntry[], opts?: WriteProtectionCompileOptions): WriteProtectionMount | undefined;
/** {@link createWriteProtectionCheck} over a judge already compiled — the engine's leg compiles once and hands the same
* judge to the call check and to the shell station (engine-internal; not re-exported from the package root). */
export declare function writeProtectionMountOver(matcher: WriteProtectionMatcher, rootPath: string | undefined): WriteProtectionMount;