/** * Gondolin micro-VM Pi `ExecutionEnv` — the SECOND backend of the * {@link PiExecutionEnv} seam (T11909 · T11888-B · P5 Gondolin · epic T11599). * * Where the in-process {@link import('./pi-execution-env.js').GuardedExecutionEnv} * (S1 · T11761 · T11897) confines Pi with a deny-first `ToolGuard` + workspace * boundary INSIDE the host process, this backend's confinement is the * **micro-VM boundary itself**. Guest code runs with: * * - **ZERO host authority** — no `cleo.db` handle, no writer-lease IPC socket, no * host `process.env` secrets, no host filesystem outside the single RW * `/workspace` mount. The guest is a different kernel; it cannot reach the * daemon, the lease arbiter, or BRAIN even if Pi-driven code tries. * - **Egress only via the credential-injection proxy** — `createHttpHooks` is * constructed with `allowedHosts: []` (present-and-empty = DENY ALL per the * gondolin docs; OMITTING it = allow-all — the egress footgun this guards) and * host-side secret PLACEHOLDERS, so the guest only ever sees the `placeholder` * bytes, never the real token. The loop appends specific hosts per run. * - **Live DBs NEVER mounted** — `.cleo/tasks.db` / `.cleo/brain.db` are NOT in * the VFS mount set. A replay operates on a DISPOSABLE seeded copy * (`VACUUM INTO` snapshot), so the T5158 data-loss vector (git overwrites the * live WAL-backed DB) is structurally impossible: there is no live handle * inside the VM to corrupt. * * This is strictly stronger than S1's env-scrub: S1 protects against an in-process * Pi escape via the guard allowlist; Gondolin protects via kernel + VFS + network * namespace separation. A thin in-guest deny layer (lexical `/workspace` * confinement + a command denylist for real egress verbs) is kept as DEFENSE IN * DEPTH — the boundary is the VM, but a Pi escape that runs a binary still cannot * do damage that survives the run. * * Optional-dep discipline (D11142): `@earendil-works/gondolin` is loaded ONLY via * the {@link import('./gondolin-loader.js').loadGondolin} dynamic import — it is * in NEITHER `dependencies` NOR `optionalDependencies`, and this module declares * NO `import type` from the package (the consumed structural shapes come from the * loader's LOCAL types). `core` builds + all non-gondolin tests pass with gondolin * ABSENT, and this module is import-time side-effect-free (no top-level VM boot). * * Never-throw discipline: every `vm.fs.*` / `vm.exec` call is wrapped exactly as * S1's `#toFsErr` / `execErr` — a thrown gondolin/guest error becomes a * {@link PiResult} `err` with `E_PI_FS_*` / `E_PI_EXEC_*` codes. The * `PiResult` contract is byte-for-byte identical to S1, so the Pi loop * cannot tell the two backends apart. * * @epic T11599 * @task T11909 * @see ./pi-execution-env.js — the S1 `GuardedExecutionEnv` this mirrors over a VM * @see ./gondolin-loader.js — the optional-dep loader supplying the structural types */ import { type GondolinModule, type SecretDefinition, type VM } from './gondolin-loader.js'; import type { PiExecOptions, PiExecResult, PiExecutionEnv, PiExecutionError, PiFileError, PiFileInfo, PiResult } from './pi-execution-env.js'; /** The single RW mount point inside the guest — the ONLY writable location. */ export declare const GONDOLIN_WORKSPACE_ROOT: "/workspace"; /** * The real egress verbs the guest is NEVER allowed to run. Sandbox output is a * DRAFT PR opened HOST-side by the loop's egress step (never from inside the * guest). The guest may run `git diff`/`git apply`/`git status` to PRODUCE a * patch, but pushing/publishing is a host-only, budget-gated action. `cleo` is * denied for completeness (no daemon is reachable from the guest anyway). * * Each entry is matched against the command's argv-0 basename, plus the * two-token forms `git push` / `git remote` so a `git`-rooted egress is caught * even though `git`'s OWN basename is allowed (for `git diff` etc.). */ export declare const DENIED_EXEC_PREFIXES: readonly string[]; /** * Whether `command` (string or argv) is a denied real-egress verb. Deny-first: * checked BEFORE the command ever reaches `vm.exec`. Comparison is on the * normalized leading tokens (argv-0 basename + a possible second subcommand * token), so `git push origin main`, `/usr/bin/gh pr create`, and * `npm publish --tag x` are all rejected, while `git diff` / `git status` pass. * * @param command - The string command or argv array the env received. * @returns The matched denied prefix, or `null` when the command is allowed. */ export declare function deniedEgressVerb(command: string | readonly string[]): string | null; /** * The injectable factory the {@link createGondolinExecutionEnv} uses to obtain * the gondolin module. Defaults to the real lazy {@link loadGondolin}; unit * tests inject a fake that returns a MOCK module so NO real QEMU VM is launched. */ export type GondolinLoader = () => Promise; /** Construction options for {@link createGondolinExecutionEnv}. */ export interface CreateGondolinExecutionEnvOptions { /** * The host directory mounted RW at `/workspace` inside the guest. This MUST be * a DISPOSABLE seeded copy (e.g. a `VACUUM INTO` snapshot dir) — NEVER a path * containing the live `.cleo/tasks.db` / `.cleo/brain.db`. The factory mounts * ONLY this directory; live DBs are structurally absent from the guest VFS. */ readonly seededCopyDir: string; /** * Outbound host allowlist. Defaults to `[]` (DENY ALL) when omitted — the * egress footgun guard. The loop appends ONLY the specific hosts it needs * (e.g. the model endpoint via the Vault proxy) per run. */ readonly allowedHosts?: readonly string[]; /** * Host-side secret injections. The guest only ever sees each entry's * `placeholder`; the real `value` bytes never enter the VM. */ readonly vaultSecrets?: Readonly>; /** Guest memory bound (e.g. `"1G"`). Defaults to {@link DEFAULT_GUEST_MEMORY}. */ readonly memory?: string; /** * Guest environment — the ONLY env the guest sees (host `process.env` is NEVER * inherited; that isolation is structural). Defaults to an empty map. */ readonly env?: Readonly>; /** * Optional loader override (tests inject a fake → a MOCK VM, no QEMU). Defaults * to the real {@link loadGondolin}. */ readonly load?: GondolinLoader; } /** * A {@link PiExecutionEnv} whose confinement is a gondolin micro-VM. Owns exactly * ONE {@link VM} and releases it in {@link cleanup} via `vm.close()`. * * Every fs op maps to `vm.fs.*`; `exec` maps to `vm.exec`. Each call is wrapped * try/catch → {@link PiResult} `err`, so no method ever throws. A thin in-guest * deny layer (lexical `/workspace` confinement + the {@link DENIED_EXEC_PREFIXES} * command denylist) is the defense-in-depth net BEHIND the VM boundary. * * Unlike the stateless {@link import('./pi-execution-env.js').GuardedExecutionEnv} * (whose `cleanup()` is a no-op), this env OWNS the VM and MUST release it. * `cleanup()` is idempotent (guarded by a `#closed` flag). */ export declare class GondolinExecutionEnv implements PiExecutionEnv { #private; /** * @param vm - The booted gondolin VM this env owns. Construct via * {@link createGondolinExecutionEnv}, not directly — the factory wires the * deny-by-default egress hooks + seeded-copy-only mount set. */ constructor(vm: VM); /** The guest workspace root — the in-VM Pi run's working directory. */ cwd(): string; /** Resolve `path` to a guest-absolute path anchored at `/workspace`. */ absolutePath(path: string): string; /** Join path segments (pure POSIX) anchored at the guest workspace root. */ joinPath(...segments: string[]): string; /** Read a file's text from the guest workspace. */ readTextFile(path: string): Promise>; /** Read a file's text as an array of lines. */ readTextLines(path: string): Promise>; /** * Binary read — FILLS S1's v0 denial. The guest VFS supports binary reads * directly via `vm.fs.readFile(p)` (returns a `Uint8Array`). */ readBinaryFile(path: string): Promise>; /** Write a file to the guest workspace. */ writeFile(path: string, content: string): Promise>; /** * Append to a file. The guest fs surface offers only whole-file writes, so this * reads-then-writes the concatenation (still confined). A missing file is * treated as empty (append-creates). */ appendFile(path: string, content: string): Promise>; /** Report whether a guest path is a file or directory. */ fileInfo(path: string): Promise>; /** Whether a guest path exists. */ exists(path: string): Promise>; /** * Canonicalize a guest path via `realpath` inside the guest. Runs the binary in * ARRAY form (no `$PATH` / shell expansion) so the path argument cannot be * reinterpreted as shell. */ canonicalPath(path: string): Promise>; /** List a guest directory — FILLS S1's v0 denial. */ listDir(path: string): Promise>; /** Create a guest directory (recursive) — FILLS S1's v0 denial. */ createDir(path: string): Promise>; /** Remove a guest path (recursive, force) — FILLS S1's v0 denial. */ remove(path: string): Promise>; /** * Create a temp directory under `/workspace/.tmp` — FILLS S1's v0 denial. Runs * `mktemp -d` in ARRAY form (no `$PATH` / shell expansion). */ createTempDir(prefix?: string): Promise>; /** * Create a temp file under `/workspace/.tmp` — FILLS S1's v0 denial. Runs * `mktemp` in ARRAY form (no `$PATH` / shell expansion). */ createTempFile(prefix?: string): Promise>; /** * Execute a command in the guest via `vm.exec`. Deny-first: a real-egress verb * ({@link DENIED_EXEC_PREFIXES} — `gh` / `git push` / `npm publish` / `cleo` / * `git remote`) is rejected BEFORE reaching the VM, even though the VM itself * has no egress (the network namespace is deny-by-default). `cwd` is confined * to the workspace; the host `AbortSignal` is forwarded only when supplied. * * ## exitCode mapping * * gondolin's `ExecResult.exitCode` is `number` (NON-nullable) with a SEPARATE * optional `signal` field; Pi's `PiExecResult.exitCode` is `number | null` * ("null when killed by signal"). So a signal-kill is mapped to `null`, NOT * the raw `exitCode`. */ exec(command: string, options?: PiExecOptions): Promise>; /** * Tear down the owned VM (`vm.close()`). Idempotent: a second call is a no-op. * Best-effort and never throws (a close failure is logged, not propagated) so * the Pi contract — `cleanup()` must not throw — holds. */ cleanup(): Promise; } /** * Thrown by {@link createGondolinExecutionEnv} when the optional * `@earendil-works/gondolin` package (or its QEMU/KVM host infra) is not * available, so a VM cannot be booted. Callers that want graceful degradation * should check `isGondolinAvailable()` first (the selector * {@link import('./resolve-execution-env.js')} does this) and fall back to the * in-process {@link import('./pi-execution-env.js').GuardedExecutionEnv}. */ export declare class GondolinUnavailableError extends Error { /** Machine-readable code. */ readonly code = "E_GONDOLIN_UNAVAILABLE"; constructor(message: string); } /** * Boot a gondolin micro-VM and wrap it in a {@link GondolinExecutionEnv}. * * The VM is configured for ZERO host authority: * - **mount set** = ONLY `{ '/workspace': new RealFSProvider(seededCopyDir) }`. * The disposable seeded copy is the single RW mount; live `tasks.db`/`brain.db` * are structurally absent from the guest VFS (T5158 impossible in-VM). * - **egress** = `createHttpHooks({ allowedHosts, secrets })` with `allowedHosts` * defaulting to `[]` (DENY ALL — the footgun guard) and host-side secret * PLACEHOLDERS (the guest never sees real token bytes). * - **env** = ONLY `opts.env` (host `process.env` is NEVER inherited). * - **memory** = `opts.memory` (default `"1G"`), set EXPLICITLY. * * Import-time side-effect-free: the gondolin module is loaded lazily HERE (not at * module top-level) via the injectable {@link GondolinLoader} (tests pass a fake * → a MOCK VM, so NO real QEMU boots). * * @param opts - The seeded-copy mount dir + egress allowlist/secrets + guest env. * @returns A {@link PiExecutionEnv} backed by the booted VM. * @throws {GondolinUnavailableError} When the optional package cannot be loaded. * * @example * ```ts * const env = await createGondolinExecutionEnv({ seededCopyDir: snapshotDir }); * // ... drive Pi's loop over `env` ... * await env.cleanup(); // releases the VM * ``` */ export declare function createGondolinExecutionEnv(opts: CreateGondolinExecutionEnvOptions): Promise; //# sourceMappingURL=pi-gondolin-env.d.ts.map