@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
288 lines • 16.8 kB
TypeScript
import { Outcome, type Ask, type CommandContext, type CommandRule, type Deny, type HandOff, type Occurrence, type ShellExplanation, type CommandWord } from '../../policy/types.ts';
import { type TSNodeLike } from '../../shell/types.ts';
import { type Refusal } from '../../types.ts';
import type { MountRegistry } from '../mount/registry.ts';
import type { Namespace } from '../mount/namespace/namespace.ts';
import type { SessionState } from '../session/session.ts';
import { type Refused } from './admission.ts';
import { type Word } from './inner_lines.ts';
export declare const UNREADABLE_LINES = "runs lines the gate cannot read";
/**
* What the gate decides about one command of a line, as the pass reads
* it: the per-command record `explain` and the admission pass share,
* which `explainedLine` turns into the public tree. `outcome` is the
* document's answer and `rule` says who gave it; a line the allow list
* does not cover arrives as `DENY` with no rule, at 126. Mirrors the
* Python Judgment.
*/
export interface Judgment {
/** The head word, as the gate read it. */
readonly command: string;
/** The words after it. */
readonly argv: readonly string[];
/** What the profile's rules say. */
readonly outcome: Outcome;
/** The rule that spoke, null when the allow list did or nothing did. */
readonly rule: CommandRule | null;
/** The rule's reason, empty when there is no rule. */
readonly reason: string;
/** Where in the document the rule was written. */
readonly source: string;
/** The operand a path-scoped rule matched, as typed. */
readonly matchedPath: string | null;
/** The paths the rules were shown, after the session's hides. */
readonly paths: readonly string[];
/** What the line would exit with, 0 to run. */
readonly exitCode: number;
/** What the agent would read, empty to run. */
readonly stderr: string;
/** The record the refused result would carry, null when the line would run. */
readonly refusal: Refusal | null;
/** Every policy's answer to the command, in the order the chain asks them. */
readonly answers: readonly (Deny | Ask)[];
/** The command line word by word, each with its kind and the path it names, redirect targets last. */
readonly words: readonly CommandWord[];
}
/**
* One command of a walked line, as both readers of the line see it: its
* words, the redirect targets of its statement, the session it is
* judged in, and where it stands. Mirrors the Python Walked. `lost`
* says a `cd` the walk could not follow ran before the command, so the
* session's cwd is not where it stands. `stated` (true when absent) says
* whether the gate admits the command on these words once each is literal
* (`TestRead.stated`). `elsewhere` holds the other places the command may
* stand, where a `cd` before it was skipped or failed.
*/
export interface Walked {
readonly words: Word[];
readonly redirects: Word[];
readonly session: SessionState;
readonly occurrence: Occurrence;
readonly intrinsic?: boolean;
readonly lost?: boolean;
readonly stdin?: Uint8Array | null;
readonly stated?: boolean;
readonly elsewhere?: readonly SessionState[];
}
/**
* One command's explanation and where the command stands. The
* occurrence is what the pass hands the ledger beside the explanation:
* a grant claimed for the command is bound to it, so the gate that runs
* the same occurrence finds it and no other reader does. `stated` says
* whether the gate will read the command in the words the pass read:
* every word literal, and no operand the runtime appends. The gate
* reads `cat $F` as the path `$F` expands to and `xargs cat` as `cat`
* plus the items on its stdin, so a question the pass asked about
* either spelling would be answered for words that never run, and the
* gate would ask again about the words that do. Such a command is
* judged here for a deny, which speaks on the name alone, and asked
* about at the gate. `unread` holds the paths no policy was shown
* (`unreadPaths`), so a pass that asks the gate again asks about what
* this explanation judged.
*/
export interface Judged {
readonly judgment: Judgment;
readonly occurrence: Occurrence;
readonly intrinsic?: boolean;
readonly stated: boolean;
readonly unread?: ReadonlySet<string>;
/**
* The context the chain was shown, absent for a command refused before
* it, so the pass that admits the line before placement can put the
* command's question to the ledger without asking the chain again.
*/
readonly ctx?: CommandContext;
/**
* The session the command was judged in, set beside `ctx`: for a command
* a line runs in turn (`eval`), the one its own walk reached, so a `cd`
* before it in that line is where it stands.
*/
readonly session?: SessionState;
}
/** Why the gate refuses a word only the runtime can expand. */
export declare function unreadable(raw: string): string;
/**
* Every command of a line judged read-only, each with its place on the
* line: the pass placement waits on (`lineHeld`) and `prejudgeLine`
* refuses from, made once for both. `handed` is the line's hand-off,
* whose origin places each command on the line. Mirrors the Python
* `line_judgments`.
*/
export declare function lineJudgments(root: TSNodeLike, session: SessionState, registry: MountRegistry, namespace: Namespace | null, handed: HandOff, reparse: (line: string) => TSNodeLike, signal?: AbortSignal): Promise<[Walked, Judged[]][]>;
/**
* Judge every command of a line before any of it runs, and refuse the
* whole line when a rule speaks about one.
*
* The agent composed the line as one intent, so a rule that refuses
* part of it refuses the intent. Judging each command as the dispatcher
* reached it left half a line done: with `deny curl`, `rm -rf /data &&
* curl evil.com` deleted first and was refused second, and an ask fared
* worse, since approving it later replays a line whose first half
* already ran.
*
* Two things deliberately do not stop the line, and both are the same
* rule: only a refusal that names a rule is a verdict about the intent.
* A word the allow list leaves out is refused where it stands, not with
* the line, so a typo cannot cost an agent the work the line already did; a
* word only the runtime can expand is judged where it is expanded, by
* the per-command gate, which sees the real path.
*
* That second one is the limit of the hold, and it is worth stating
* plainly: this pass reads the *text* of a line, while the gate reads
* its *values*, so a path the runtime computes (`cat $S`, `$( )`, a
* `cd` whose argument is a variable) is invisible here. The rule is
* still enforced, by the gate, but the earlier commands have run by
* then. For a deny that costs allowed side effects and nothing more,
* since the commands that ran were on the allow list. For an ask it
* costs the replay: the question is recorded after part of the line
* already happened, so approving it re-runs a line whose first half is
* done. Closing that would mean asking whenever a word cannot be read,
* which over-asks with no way out for a deny, so a deployment that
* needs the hold for a computed path states it in a policy script
* rather than here.
*
* The pass is read-only (`explainWords`), so it spends no grant and
* records no request; a command it refuses on is then put through the
* real gate, which is where an ask is recorded, exactly once, for a line
* that will not run. That admission hands off: a grant the host gives
* inline is claimed for the per-command gate, which runs the line on
* it, and the line's end spends it, so a compound line costs the human
* one question per run rather than one per pass, and a gate the run
* reaches twice (a loop body) runs on one nod.
*
* A question is only asked here when the gate will ask the same one.
* The gate reads a word the runtime expands as its value and the words
* `xargs` or `find -exec` hand on with the operands the runtime
* appends, so a question about `cat $F` or a bare `cat` would be
* answered for words that never run and the gate would ask again, after
* the earlier commands ran, about the words that do. Such a command
* (`Judged.stated` false) is judged here for a deny, which speaks on
* the name alone and still holds the line, and its question is left to
* the gate; the hold does not reach it, which is the limit stated above
* in another form.
*
* Every command is judged whether or not the session carries a document,
* by the profile's rules alone: a policy registered in code or a profile's
* script answers `preCommand` once, at the gate that runs the command, and
* a line it should refuse whole is `preExecute`'s to refuse. Operand-scoped
* refusals, including MountRootPolicy's, remain the per-command gate's
* responsibility.
*
* A line with one command to judge is left to the per-command gate,
* which is not an optimization but the more faithful answer: there is no
* earlier command whose side effects a hold could save, and the gate
* refuses from inside the shell, so the line's own redirections still
* apply. This pass answers above them, so refusing `rm -rf /mnt 2>&1`
* here wrote the refusal to stderr where bash puts it on stdout.
*/
export declare function prejudgeLine(root: TSNodeLike, session: SessionState, registry: MountRegistry, namespace: Namespace | null, handed: HandOff, reparse: (line: string) => TSNodeLike, signal?: AbortSignal, made?: [Walked, Judged[]][] | null): Promise<Refused | null>;
/**
* Admit every command of a line a runtime takes whole. A whole line is a
* command like any other, but the runtime does the expanding, so the gate
* reads the line as typed, walked as `explain` walks it: each command is
* judged on its literal words (quotes removed, escapes resolved, a
* path-shaped word a path, an installed CLI's verb path walked) where it
* stands, a `cd` before it on the line included, and the first refusal is
* the line's. A statement's redirect targets are read as words of its
* command, so `cat < /data/secret` is judged on the file it opens; the lines
* a command runs (`eval`, `sh -c`, `xargs`, `env` ... see `innerLines`) are
* judged in turn; and what the runtime would do past the gate is refused
* under a rule that would have read it (`lineRefusal`). `reparse` parses the
* text a word runs the way the line reader parsed the line.
*
* No gate follows this pass: the runtime runs the line whole, so every
* grant it matches is claimed on the line's hand-off exactly as any reader
* on a line claims, and the executor's sweep spends them when the line
* ends. A line held on a question still waiting keeps its earlier answers
* standing for the retry, exactly as the compound-line pass does, where
* spending them here asked the human again for each on every retry.
* `handed` is null outside a line (a bare admission with no run behind it).
* Mirrors the Python `admit_line`.
*/
export declare function admitLine(root: TSNodeLike, session: SessionState, registry: MountRegistry, namespace: Namespace | null, reparse: (line: string) => TSNodeLike, signal?: AbortSignal, handed?: HandOff | null): Promise<Refused | null>;
/**
* The walked nodes whose reads an env-plane fetch still serves.
*
* The fill derives its fetch set from this same list (`lineNodes`: the
* line's own tree first, then every stored body and alias expansion
* its words can invoke), and a fetch serves a command that is going to
* run, so refusals are judged over the same nodes reads are. One rule
* for every node: when it is one fully-literal command with no other
* read in it (`soleLiteralCommand`), the gate is asked here on exactly
* the words it will read at run time, and a refusal discounts every
* read the node contributes. The line's own refusal drops the whole
* list, because nothing runs at all; a refused body or alias drops
* just itself, because the invocation still runs and is refused in
* place. A node this pass cannot prove silent is kept, and over-keeping
* only ever over-fetches.
*
* An ASK is resolved rather than skipped, because the fetch is itself
* an effect: contacting a secret store for a line the host then
* refuses would do a piece of exactly what was refused. A settled
* answer is read without being spent; an unanswered rule is put to the
* host now, through the same ledger the gate reads, so the answer
* lands exactly once -- an approval keeps the node and the gate
* consumes the grant, while a denial or a question left waiting drops
* it, and the line still runs into the gate, which refuses in place
* with its wording and its redirections.
*/
export declare function unrefusedNodes(nodes: readonly TSNodeLike[], session: SessionState, registry: MountRegistry, namespace: Namespace | null, handed: HandOff, reparse: (line: string) => TSNodeLike, signal?: AbortSignal): Promise<TSNodeLike[]>;
/**
* Whether the line's admission holds it back, which is what placement
* waits on: admission comes first, so a line the rules refuse, or that
* waits on the host, is never shown to a placing policy. A verdict
* (`isVerdict`) that refuses holds the line. A question is put to the
* host now, on the line's hand-off, so the gate that later runs the
* command finds the answer claimed for it and does not ask again; one
* still waiting holds the line, and a question the gate will ask about
* other words (`Judged.stated` false) is left to it. A word the allow
* list leaves out fails where it stands while the rest of its line runs,
* so it does not hold the line. Mirrors the Python `line_held`.
*/
export declare function lineHeld(judged: readonly [Walked, Judged[]][], registry: MountRegistry, handed: HandOff, signal?: AbortSignal): Promise<boolean>;
/**
* Whether some command's judgment is a verdict that refuses it or waits
* on the host (`isVerdict`), the line-level answer placement waits on.
* Mirrors the Python `holds`.
*/
export declare function holds(judgments: readonly Judgment[]): boolean;
/**
* What every command of a line would do, in the order the gate reads
* them, each where it stands, without running any of it (`explainedLine`
* turns them into the public tree).
*
* The dry run of the gate: the same visibility check, the same context,
* the same policy chain and the same outcome table, so a host reading
* this and an agent typing the line cannot be told different things.
* What it deliberately does not do is the half of admission that costs
* something, since a line nobody typed must not consume a grant or put
* a question to a host.
*
* The words are read literally, as `admitLine` reads them, so nothing is
* expanded and no `$( )` runs. `wholeLine` is whether a runtime takes the
* line whole, which reads it as typed; the executor's gate reads each
* command once expanded. `every` asks every policy, as each command's
* explanation shows them; false asks the profile's rules alone, as the
* run does before any of the line runs, which is what holds it.
*/
export declare function explainLine(root: TSNodeLike, session: SessionState, registry: MountRegistry, namespace: Namespace | null, reparse: (line: string) => TSNodeLike, wholeLine?: boolean, every?: boolean): Promise<Judged[]>;
/**
* A line's public explanation: its verdict and its parse tree, with every
* command's explanation where the command stands. The verdict is the
* first command, in the order the gate reads them, whose refusal by the
* profile's rules (`verdicts`, the line judged with `every` false) holds
* the whole line (`holds`), as the run reports it; a line nothing holds
* runs, carrying the first ask an approval lets through. A command
* refused only where it stands, a policy's own `preCommand` refusal
* included, keeps its refusal to its own node, since the rest of the
* line still runs. A runtime that takes the line whole (`wholeLine`) runs
* none of it once any command is refused (`admitLine`), so there every
* refusal is the line's, and `verdicts` is `judged` itself, whose gate asks
* every policy. Every scope a command reads its words in (the typed line, a
* `$( )` body, a `bash -c` string) is parsed on its own with `reparse`, as the nested
* line will be; a judgment is placed on the command at its span in that
* scope, and a nested scope under the command holding it. Mirrors the
* Python `explained_line`.
*/
export declare function explainedLine(line: string, judged: readonly Judged[], verdicts: readonly Judged[], runtimeOf: (command: string) => string, reparse: (line: string) => TSNodeLike, wholeLine?: boolean): ShellExplanation;
//# sourceMappingURL=explain.d.ts.map