UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

271 lines (270 loc) • 20.7 kB
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;