@sema-agent/core
Version:
Stateless, task-oriented AI agent core
303 lines (302 loc) • 21.7 kB
TypeScript
/**
* The shell read JUDGE and its DECISION TABLE — the one place a shell command's read candidates become a
* verdict, for every shell face (the full `Bash`, the read-only `Bash`, `Monitor`) and both moments (the
* gate station before approval, the rewrite re-judge after it).
*
* · `judgeRead` judges ONE established candidate: the deny half first, over every view the candidate has
* (its canonical target, its folded spelling, the kernel spelling of a `..` operand, the folded spelling
* of a candidate that could not be established), then containment over the canonical target alone. The
* deny judge is the boundary's own `denyMatch` closure (`readDenySeat`, read-deny.ts) — the same function
* object every read face mounts; nothing here compiles a pattern or re-derives a view set. The containment
* judge is `withinAnyRoot` over the roots the boundary declares.
* · `foldShellReadFindings` folds a command's findings — every segment, every nested plan — to ONE word by
* the priority `identity_changed > deny_hit > outside_roots > unestablishable > declined > none`.
* · `SHELL_READ_DECISION` is the total table (finding × seat → disposition) that is the ONLY source of
* "refuse / mandated ask / ordinary ask / vouch" for a shell read. A seat is a FACE word, not a tool name
* and not a permission mode: which shell face the call runs on (the full shell, the read-only shell) or
* which moment judges it (the re-judge after approval). The permission mode is not read here — what a
* mode does with the ask this table mints is decided once, downstream, where every ask is arbitrated.
*
* The floor this module is (`shell.read_boundary.friction_floor`) is stated on {@link judgeRead}.
*/
import type { BashReadonlyRootBoundary } from "./bash-readonly-classifier.js";
import type { ReadCandidate, ShellReadPlan, ShellReadSegment } from "./shell-read-plan.js";
import type { WriteSeat } from "./shell-read-write-targets.js";
import type { AssertAllKeysHandled } from "../../core/ask-origin.js";
/** WHAT the judge found about a command — a closed set, registered in `docs/CLOSED-SETS.md`. */
export declare const SHELL_READ_FINDINGS: readonly ["deny_hit", "outside_roots", "unestablishable", "declined", "identity_changed", "none"];
export type ShellReadFinding = (typeof SHELL_READ_FINDINGS)[number];
/** WHERE a read is judged — the seats the decision table is keyed on, one per FACE: `full_shell` the
* writable shell (`Bash`, `Monitor`) under every permission mode and every shell-gate floor; `readonly`
* the read-only shell face (`handsReadOnly`); `rejudge` the post-approval re-judgment of rewritten bytes
* and the pre-spawn identity re-check. */
export declare const SHELL_READ_SEATS: readonly ["full_shell", "readonly", "rejudge"];
export type ShellReadSeat = (typeof SHELL_READ_SEATS)[number];
/** The fold priority: a command's finding is the highest-ranked finding of any candidate in it. */
export declare const SHELL_READ_FINDING_RANK: {
readonly identity_changed: 6;
readonly deny_hit: 5;
readonly outside_roots: 4;
readonly unestablishable: 3;
readonly declined: 2;
readonly none: 0;
};
/** Compile-time fence: `never` while every finding has a rank. */
export type RankTableCoversEveryFinding = AssertAllKeysHandled<Exclude<ShellReadFinding, keyof typeof SHELL_READ_FINDING_RANK>>;
/** What a seat does with a finding. `refuse` = a deny at the station (or a veto after a person's yes);
* `mandated` = an ask no automatic lane may clear; `ordinary` = an ask any answerer may clear (a person,
* the auto-mode classifier, a stored allow rule) but no answerless engine table may; `vouch` = the station
* has no question. */
export type ShellReadDisposition = "refuse" | "mandated" | "ordinary" | "vouch";
/**
* THE decision table — total over findings × seats; no cell is n/a. Row by row:
* · `deny_hit` — refused on every seat, for a spelling the station can establish: a deny row is a floor
* no root grant and no approval moves. A spelling the station cannot establish is not a deny hit (it is
* the `unestablishable` row) — the floor holds what it can place, and says so rather than guessing.
* · `outside_roots` — asked where a person can be asked (the full shell), refused where none can (the
* read-only face has no approval channel; a rewrite that leaves the roots after approval was seen by
* nobody).
* · `unestablishable` — the same shape: the floor could not say where the command reads, so it is not
* vouched anywhere; a person may still answer it on the full shell.
* · `declined` — the one deliberate fail-open of this table: the scan could not read the program bash
* will run, or the operands arrive on a stream. On the full shell it is an ORDINARY ask — an answerer
* may settle it, and an out-of-root read hidden inside such a command is then not asked again. The
* read-only face refuses: its allowlist is a deployment knob, so this cell is reachable and must not be
* n/a.
* · `identity_changed` — a candidate established at approval time resolves elsewhere at spawn time:
* refused everywhere (it is only ever produced on the `rejudge` seat).
* · `none` — vouched: the station has no question (the full shell's shape tier may still ask).
*/
export declare const SHELL_READ_DECISION: {
readonly deny_hit: {
readonly full_shell: "refuse";
readonly readonly: "refuse";
readonly rejudge: "refuse";
};
readonly outside_roots: {
readonly full_shell: "mandated";
readonly readonly: "refuse";
readonly rejudge: "refuse";
};
readonly unestablishable: {
readonly full_shell: "mandated";
readonly readonly: "refuse";
readonly rejudge: "refuse";
};
readonly declined: {
readonly full_shell: "ordinary";
readonly readonly: "refuse";
readonly rejudge: "refuse";
};
readonly identity_changed: {
readonly full_shell: "refuse";
readonly readonly: "refuse";
readonly rejudge: "refuse";
};
readonly none: {
readonly full_shell: "vouch";
readonly readonly: "vouch";
readonly rejudge: "vouch";
};
};
/** Compile-time fence: `never` while every finding has a row. */
export type DecisionTableCoversEveryFinding = AssertAllKeysHandled<Exclude<ShellReadFinding, keyof typeof SHELL_READ_DECISION>>;
/** Compile-time fence: `never` while every seat has a cell on the `none` row (the `satisfies` above holds
* every other row to the same shape). */
export type DecisionTableCoversEverySeat = AssertAllKeysHandled<Exclude<ShellReadSeat, keyof (typeof SHELL_READ_DECISION)["none"]>>;
/** The disposition of one command-level finding on one seat — the table, read. A seat word outside
* {@link SHELL_READ_SEATS} (an untyped caller, or one still passing a retired word) is refused loudly: an
* absent disposition would read as "no verdict", which no caller can act on safely. */
export declare function decideShellRead(finding: ShellReadFinding, seat: ShellReadSeat): ShellReadDisposition;
/**
* The boundary a shell read is judged against: the declared roots (canonical), the working directory and
* home the plan's bases start from, the deny seat (the SAME `denyMatch` closure every read face mounts),
* the containment face, and — for the read-only face — the engine's own overflow-spool fence as one more
* member of "inside" (`alsoInside`), asked once per target that landed outside the roots.
*/
export interface ShellReadBoundary extends BashReadonlyRootBoundary {
readonly alsoInside?: (canonicalTarget: string) => Promise<boolean>;
/** The deny table's WRITE face (`compileWriteDeny`, read-deny.ts): judges a command's write targets (candidates marked
* `face: "write"`) and returns the matched row's rule, or null. PRESENT-IFF there is something to judge. */
readonly writeDenyMatch?: (resolvedPath: string) => string | null;
/** The read table's rows AT or BELOW a path (`readDenySeat`'s `denyAtOrBelow`, read-deny.ts): read by the station's
* `writesPlaced` fact only, through {@link denyRowAtOrBelow}. */
readonly denyAtOrBelow?: (resolvedPath: string) => string | null;
/** The WRITE roots (canonical) — where a write may land without a question under a mode that accepts file commands
* (the task root and every additional working directory; never a read-only grant). Read for the station's
* `writesPlaced` fact only; the read judgment never reads it. Absent ⇒ no write is placed. */
readonly writeRoots?: readonly string[];
/** The write-protection TABLE's judge — the one a write tool's target meets (its spelling, its real target and every hop
* of its symlink chain): `true` ⇔ a row covers this ABSOLUTE write target; `canonical` ⇔ the path is a key the station
* established (judged as a key, never as a spelling). Read by the station's write facts only: a covered target is not
* placed, and the verdict says it was found ({@link import("./shell-read-check.js").ShellReadVerdict.writeProtected}). */
readonly writeProtectionJudge?: (target: string, opts?: {
readonly signal?: AbortSignal;
readonly canonical?: true;
}) => Promise<boolean>;
/** The deployment's write-target guard (`ToolPolicy.writeTargetGuard`, every policy of the leg's chain): `true` ⇔ a
* policy guards this ABSOLUTE write target. Read by the station's `writesPlaced` fact only: a guarded target is not placed. */
readonly writeTargetGuard?: (target: string, signal?: AbortSignal) => Promise<boolean>;
}
/** How far one candidate got before it was judged. `lexical` = the folded spelling is the judged target
* (an isolated sandbox, or a resolver that declares no authority and did not answer); `canonical` = the
* environment resolved it; `expansion` = one path a pattern expanded to or a traversal enumerated;
* `unestablishable` = the floor could not say where it lands (`cause` says why). */
export type ReadEstablishment = "lexical" | "canonical" | "expansion" | "unestablishable";
export interface EstablishedRead {
readonly candidate: ReadCandidate;
readonly established: ReadEstablishment;
/** The judged target: an absolute path (canonical where established so, the folded spelling otherwise).
* Absent exactly when `established === "unestablishable"`. */
readonly target?: string;
/** Every spelling the deny judge sees BESIDE the target: the folded spelling, the kernel spelling of a
* `..` operand, the pattern an expansion came from, the placeholder-carrying fold of a substitution. */
readonly spellings: readonly string[];
/** The pattern or traversal root an `expansion` came from, as folded. */
readonly origin?: string;
/** What the environment said the target IS, where a read asked — only a traversal asks (it lists the root and
* every member it walks; a link's answer is its target's). Absent = nothing asked; a plain read never lists. */
readonly node?: "directory" | "file";
/** A MEMBER a traversal enumerated below its root — never a spelling of the command. The deny judge reads it
* ({@link import("./read-deny.js").ReadDenyMatcher.matchPath} `walked`): the segment rows still judge it, a
* rule-DSL row does not. Absent on the root itself, on a pattern's own matches and on every spelled operand. */
readonly walked?: true;
/** Why the candidate could not be established. */
readonly cause?: string;
/** WHERE the command's program writes this target — present only on a write target the program itself writes, at the
* seat this read was established at (`landing`: through the path's links; `entry`: the directory entry, a link there
* not followed). Absent on a read, and on a write-face target the program only names (a copy's source). `child`: the
* candidate's own mark ({@link import("./shell-read-write-targets.js").JudgedCandidate.child}), carried to the read. */
readonly written?: {
readonly seat: WriteSeat;
readonly child?: true;
};
}
/** What the judge found about ONE candidate. `kind` is one of the four candidate-level findings
* (`declined` is a segment fact, `identity_changed` a re-establishment fact — neither is a candidate's). */
export interface ReadFinding {
readonly read: EstablishedRead;
readonly kind: Extract<ShellReadFinding, "deny_hit" | "outside_roots" | "unestablishable" | "none">;
/** The deny row that matched, the view it matched on, and — for a write target — the face (`write`) that refused. */
readonly deny?: {
readonly pattern: string;
readonly matchedView: string;
readonly face?: "write";
};
readonly containment: "inside" | "outside" | "open" | "unknown";
}
/**
* Judge ONE established candidate. Deny first, over every view (a hit on any view decides — a symlink whose
* SPELLING names a guarded path is refused even when its target is elsewhere, and a substitution's folded
* spelling is judged although the candidate could not be established); containment over the canonical
* target alone, and only where the boundary's face closes it.
*
* @contract shell.read_boundary.friction_floor — what this judge answers is a question about TEXT and the
* FILESYSTEM the command will run in: which files the words of a command may name, resolved against the
* declared roots, the declared home and the observed working directory, and — where the environment can
* answer — where those spellings really land (symlinks resolved, patterns expanded, traversal roots
* enumerated, each within a bound). It is not a question about what the process will open. A deployment's
* containment for what a shell may reach is the `ExecutionEnv` the command runs in (a sandbox, a
* container, a remote adapter); everything here is the friction floor under that containment. Under an
* isolated sandbox the floor is lexical (the sandbox is the boundary and its filesystem is not this
* process's to resolve). A deny-listed read is REFUSED on every seat; an out-of-root read asks where a
* person can be asked and is refused where none can; a read the floor cannot establish (an expansion it
* cannot finish, a spelling the shell rewrites, a working directory it cannot place) is asked or refused
* the same way — never vouched. Containment judges what a READ program opens: a program path is executed,
* not disclosed, and a leading environment word's value reaches the program through its environment, so
* neither is a containment candidate — both still meet the deny judge, on every spelling.
*/
export declare function judgeRead(read: EstablishedRead, boundary: ShellReadBoundary): ReadFinding;
/**
* Does a deny row lie AT or BELOW this write target, on any view the judge reads? Then a command that moves, copies or
* removes the target changes what the row covers, and a mode must not answer it unasked. WHICH tables answer is what the
* command does to the content below the target: a move or a copy carries it somewhere else (`mv .docker d` leaves
* `d/config.json` outside `.docker/config.json`; `mv <home> x` takes every home-anchored row's file along), so the READ
* table's rows answer too (`carriesContent`); a removal carries nothing anywhere — it is a write and nothing else, and
* only the write table answers it (the reference implementation's path check reads the read rows for a copy's or a
* move's sources and for sed, never for `rm` / `rmdir`). The read table answers per row (`denyAtOrBelow`); the write
* table's rows are rule rows, whose reach below a path is not enumerable, so any of them answers.
*/
export declare function denyRowAtOrBelow(read: EstablishedRead, boundary: ShellReadBoundary, carriesContent: boolean): boolean;
/**
* The deny floor of every home word the plan does not place: each candidate carrying a `floorSpelled` (the word
* spelled with the DECLARED home) gets a SHADOW candidate at that spelling, established exactly as a read is — the
* canonical target, a pattern's matches (a glob the word carries unquoted), a traversal root's tree — and judged for
* deny rows only (`denyOnly`). A deny row is a floor the text's rebinding may never reach past; the shadow adds no
* other finding, so a read that is not a deny row still asks through its own unplaced candidate.
*/
export declare function denyFloorShadowsOf(candidates: readonly ReadCandidate[]): ReadCandidate[];
/** One segment's judged reads and declines, with its nested plans judged the same way. */
export interface JudgedSegment {
readonly segment: ShellReadSegment;
readonly findings: readonly ReadFinding[];
readonly nested: readonly JudgedPlan[];
}
export interface JudgedPlan {
readonly plan: ShellReadPlan;
readonly segments: readonly JudgedSegment[];
}
/** The command-level fold and the finding that produced it. `witness` is absent for `none`; for `declined`
* the segment that declined stands in for a candidate. */
export interface ShellReadFold {
readonly finding: ShellReadFinding;
readonly witness?: ReadFinding;
readonly declined?: ShellReadSegment;
/** Some read of the text could not be established (an `unestablishable` or `declined` finding anywhere, winning or not). */
readonly unestablished?: true;
}
/**
* Fold a judged plan to ONE finding by {@link SHELL_READ_FINDING_RANK}, over every segment and every nested
* plan. Two structural rules ride the fold:
* · a plan the lexer refused (`unreadable`) is `unestablishable`, unconditionally — a quote-blind re-read
* yields more words, never zero, so an unreadable text is never "no candidates";
* · a SHELL re-entry segment (`sh -c S`, `eval S`, `env -S 'S'`, a launcher handing text to a shell) is at
* least `unestablishable` — what runs is text the shell parses, not this plan — and its nested plan may
* only RAISE that to `deny_hit`: the nested text's deny hits count (a refusal is a refusal whatever runs
* it), its containment and its silence do not (an inner `cat inside.txt` vouches nothing about the
* outer text). A substitution body (`$(…)`, a backtick, `<(…)`) is nested WITHOUT re-entry and folds
* in full.
*/
export declare function foldShellReadFindings(judged: JudgedPlan): ShellReadFold;
/** Judge every established read of a plan, in place. `establish` runs per segment so a caller can bound it. */
export declare function judgeEstablishedPlan(plan: ShellReadPlan, established: ReadonlyMap<ShellReadSegment, readonly EstablishedRead[]>, boundary: ShellReadBoundary, nestedOf: (plan: ShellReadPlan) => JudgedPlan): JudgedPlan;
/** Every finding of a judged plan, flattened (segments and nested plans alike). */
export declare function allFindings(judged: JudgedPlan): ReadFinding[];
/** One lexical step up, family-aware. A family ROOT is its own parent — the fixpoint the climb below ends
* on, and the one spelling this file can recognise as "the whole filesystem" without I/O. */
export declare function parentDirLexically(p: string): string;
/**
* The deepest directory containing both, or `undefined` when they share none (two path families).
*
* LINEAR, and that is the point: both paths are already folded, so the answer is their common prefix cut
* at a separator. Climbing one directory at a time re-folds the whole remaining path each step — the same
* answer at quadratic cost, measured at 4.9 s of synchronous work for one 32 KB command naming a
* 16 000-segment operand. The family-aware comparator keeps the last word: a prefix it cannot confirm
* contains both paths yields no candidate.
*/
export declare function deepestCommonDir(a: string, b: string): string | undefined;
/**
* The ONE directory whose addition to the declared roots would answer every `outside_roots` finding of a
* command — offered only when re-judging the same established reads with that root added leaves no
* finding at all (MEASURED, not argued: a deny row or an unestablishable candidate beside the out-of-root
* one leaves the ask standing whatever root is granted, and then nothing is offered). `exact` ⇒ the value
* is the one path the command NAMED, offered verbatim, because the containing directory would hand out the
* home or a root. A `..` spelling gets no offer: the folded and the kernel reading of it can disagree about
* whether the target is back inside, which no replay of the text settles.
*/
export declare function readRootGrantCandidateOf(judged: JudgedPlan, boundary: ShellReadBoundary): {
readonly dir: string;
readonly covers?: "exact";
} | undefined;
/**
* The grant a read-root card offers for the directory `candidate` it would add. A directory that would hand out more
* than was read is not offered: a family root grants the whole filesystem, and one CONTAINING the declared home or a
* root grants the workspace. ONE fallback for both rather than no offer at all — the single path that WAS read
* (`single`), named VERBATIM (`covers: "exact"`); with several paths read, no offer.
*/
export declare function grantOfferFor(candidate: string, single: string | undefined, bounds: Pick<ShellReadBoundary, "roots" | "homeDir">): {
dir: string;
covers?: "exact";
} | undefined;
/** The same judged plan under another boundary — the established reads are kept, only the judgment reruns. */
export declare function rejudge(judged: JudgedPlan, boundary: ShellReadBoundary): JudgedPlan;