/** * Guarded Pi `ExecutionEnv` (T11761 · S1 · T11897). * * Pi's agent loop performs ALL filesystem + shell work through a single * pluggable seam: `ExecutionEnv extends FileSystem, Shell`. This module supplies * Cleo's implementation of that seam — {@link GuardedExecutionEnv} — which routes * every operation through the deny-first {@link ToolGuard} chokepoint and an * injected **workspace boundary**, so Pi can touch nothing outside the * allowlisted roots and runs no command on the denylist. * * Two layers of confinement (deny-first): * 1. **Workspace boundary** — every fs path is canonicalized with SYMLINK * RESOLUTION (`fs.realpath` on the nearest existing ancestor) and the REAL * target MUST fall under the injected `workspaceRoot`; a `../` escape, an * out-of-root absolute path, OR a symlink (file or parent dir) whose real * target leaves the root is rejected as a Pi `Result.err(FileError)` BEFORE * the guard is consulted. A purely lexical check is symlink-blind, so the * boundary resolves symlinks first. (The guard's own `allowedRoots` is a * second, ALSO-symlink-resolving net.) * 2. **ToolGuard** — the allowed operations delegate to the `ToolGuard` surface * (`readFileText`/`writeFileAtomic`/`pathExists`/`executeShell`/`runGit`), * which applies the project's symlink-resolved path allowlist + shell * denylist + subprocess-env scrub. A `GuardDeniedError` from enforce-mode is * CAUGHT and converted to a Pi `Result.err` — Pi's `FileSystem`/`Shell` ops * must NEVER throw. The guard MUST be constructed in `enforce` mode (the * factory asserts it) — in `warn` mode its allowlist/denylist are advisory * and confinement would collapse to the workspace boundary alone. * * Every other capability (binary reads, raw temp/dir mutation, listing, …) for * which there is no atomic primitive in v0 is DENIED with a typed error rather * than reaching the real filesystem. The structural Pi interfaces are declared * locally here (the `@earendil-works/pi-agent-core` package is not yet a * dependency); S2 replaces these with the real type-only imports — the shapes * match `@earendil-works/pi-agent-core@0.78.1` `harness/types.ts:268-332`. * * Scope discipline (S1): NO `pi-ai`/`pi-agent-core` imports, NO DB access, NO * LLM calls. Import-time side-effect free. * * @epic T10403 * @task T11761 * @task T11897 */ import { type GuardMode, type ToolGuard } from '../../tools/guard.js'; /** * Pi's `Result` discriminated union. Pi's `FileSystem`/`Shell` ops return * this and MUST never throw — failures are encoded as `{ ok: false, error }`. * * Matches `@earendil-works/pi-agent-core@0.78.1` result shape. */ export type PiResult = { readonly ok: true; readonly value: T; } | { readonly ok: false; readonly error: E; }; /** A filesystem failure surfaced to Pi (never thrown). */ export interface PiFileError { /** Stable failure kind. */ readonly code: string; /** Human-readable description. */ readonly message: string; /** The path the operation targeted, when applicable. */ readonly path?: string; } /** A shell-execution failure surfaced to Pi (never thrown). */ export interface PiExecutionError { /** Stable failure kind. */ readonly code: string; /** Human-readable description. */ readonly message: string; } /** Metadata returned by `fileInfo`. */ export interface PiFileInfo { /** Whether the entry is a regular file. */ readonly isFile: boolean; /** Whether the entry is a directory. */ readonly isDirectory: boolean; } /** Result of a `Shell.exec`. */ export interface PiExecResult { /** Captured standard output. */ readonly stdout: string; /** Captured standard error. */ readonly stderr: string; /** Process exit code (`null` when killed by signal/timeout). */ readonly exitCode: number | null; } /** Options for a `Shell.exec`. */ export interface PiExecOptions { /** Working directory. */ readonly cwd?: string; /** Hard timeout in milliseconds. */ readonly timeout?: number; /** Extra environment variables. */ readonly env?: Readonly>; } /** * Pi's `FileSystem` interface (the fs half of `ExecutionEnv`). Every op returns * a {@link PiResult} and must never throw. Mirrors * `@earendil-works/pi-agent-core@0.78.1` `harness/types.ts:268-318`. */ export interface PiFileSystem { cwd(): string; absolutePath(path: string): string; joinPath(...segments: string[]): string; readTextFile(path: string): Promise>; readTextLines(path: string): Promise>; readBinaryFile(path: string): Promise>; writeFile(path: string, content: string): Promise>; appendFile(path: string, content: string): Promise>; fileInfo(path: string): Promise>; listDir(path: string): Promise>; canonicalPath(path: string): Promise>; exists(path: string): Promise>; createDir(path: string): Promise>; remove(path: string): Promise>; createTempDir(prefix?: string): Promise>; createTempFile(prefix?: string): Promise>; cleanup(): Promise; } /** * Pi's `Shell` interface (the shell half of `ExecutionEnv`). Mirrors * `@earendil-works/pi-agent-core@0.78.1` `harness/types.ts:321-328`. */ export interface PiShell { exec(command: string, options?: PiExecOptions): Promise>; cleanup(): Promise; } /** * Pi's `ExecutionEnv` — the single pluggable surface for all fs + process work. * Mirrors `@earendil-works/pi-agent-core@0.78.1` `harness/types.ts:332`. */ export interface PiExecutionEnv extends PiFileSystem, PiShell { } /** Construction dependencies for {@link GuardedExecutionEnv}. */ export interface GuardedExecutionEnvDeps { /** * The guarded primitive surface (path allowlist + shell denylist) the env * delegates allowed operations to. Injected by the dispatcher — the env never * constructs it (atomic primitives are Gate-11-bound to `core/src/tools`). */ readonly guard: ToolGuard; /** * The absolute workspace root every fs path is confined under. A path that * resolves outside this root is denied BEFORE the guard is consulted. */ readonly workspaceRoot: string; } /** * Deny-first {@link PiExecutionEnv} backed by the Cleo {@link ToolGuard} surface * and a workspace boundary. * * - File reads/writes/existence checks route through the guard's atomic * primitives, confined to `workspaceRoot`. * - `exec` routes through the guard's `executeShell` (shell denylist applies). * - Capabilities with no atomic primitive in v0 (binary read, `listDir`, * `createDir`, `remove`, temp dir/file) are DENIED with a typed * `Result.err` — they never reach the real filesystem. * - Pure path arithmetic (`cwd`/`absolutePath`/`joinPath`) is computed locally * (no fs access), anchored to `workspaceRoot`. * * Every method honours the Pi contract: it returns a {@link PiResult} and never * throws — a {@link GuardDeniedError} from the guard is caught and converted. */ export declare class GuardedExecutionEnv implements PiExecutionEnv { #private; /** * @param deps - The guard surface + the workspace root to confine to. */ constructor(deps: GuardedExecutionEnvDeps); /** The workspace root (the in-process Pi run's working directory). */ cwd(): string; /** Resolve `path` to an absolute path anchored at the workspace root. */ absolutePath(path: string): string; /** Join path segments (pure). */ joinPath(...segments: string[]): string; /** Read a file's text, confined to the workspace + guarded. */ readTextFile(path: string): Promise>; /** Read a file's text as an array of lines. */ readTextLines(path: string): Promise>; /** * Binary read — DENIED in v0. The atomic `readFileText` primitive is * text-only (`ReadFileInput.encoding` has no binary variant); a binary * primitive must be added under `core/src/tools` (Gate 11) before this is * supported. Deny-first: never reaches the filesystem. */ readBinaryFile(path: string): Promise>; /** Atomically write a file, confined to the workspace + guarded. */ writeFile(path: string, content: string): Promise>; /** * Append to a file. The atomic surface offers only whole-file atomic writes, * so this reads-then-writes the concatenation (still confined + guarded). A * missing file is treated as empty. * * Confinement happens EXACTLY ONCE here, then the read + write delegate * straight to the guard with the already-resolved `abs`. Re-entering the * public `readTextFile`/`writeFile` would re-`#confine` the value `#confine` * just returned — and because `#confine` yields the SYMLINK-RESOLVED real path * (e.g. macOS `/tmp`→`/private/tmp`, or a symlinked workspace root), that real * path is `../`-outside the lexical `workspaceRoot`, so the second lexical * gate would spuriously reject it (`E_PI_FS_DENIED`). Reading once through the * guard — whose own allowlist canonicalizes both sides — avoids the * double-confine while keeping the symlink-escape denial intact. */ appendFile(path: string, content: string): Promise>; /** Report whether a path is a file or directory, confined + guarded. */ fileInfo(path: string): Promise>; /** Whether a path exists, confined + guarded. */ exists(path: string): Promise>; /** * Canonicalize a path inside the workspace. SYMLINK-RESOLVING: `#confine` * follows every symlink component via `fs.realpath`, so the returned value is * the REAL on-disk location (matching Pi's `NodeExecutionEnv.canonicalPath` * contract), and a path whose real target escapes the workspace is denied. */ canonicalPath(path: string): Promise>; /** DENIED in v0 — no `listDir` atomic primitive. */ listDir(path: string): Promise>; /** DENIED in v0 — no `createDir` atomic primitive. */ createDir(path: string): Promise>; /** DENIED in v0 — no `remove` atomic primitive. */ remove(path: string): Promise>; /** DENIED in v0 — no temp-dir atomic primitive. */ createTempDir(): Promise>; /** DENIED in v0 — no temp-file atomic primitive. */ createTempFile(): Promise>; /** * Execute a command through the guard's shell surface (denylist applies). The * Pi `command` string is a bare executable name (args are passed by Pi via the * loop, but the v0 seam takes only `command`); we forward it as the guard's * `command` with empty args. `cwd` is symlink-confined to the workspace. * * ## Env is SCRUBBED before it leaves this seam (T11897 · security) * * Pi's `options.env` is UNTRUSTED model-driven input. Forwarding it verbatim * would let Pi set `LD_PRELOAD`/`NODE_OPTIONS`/`GIT_SSH_COMMAND` (arbitrary * native code execution outside any allowlist) or hijack `PATH` to make a * workspace-resident impostor satisfy an allowed command basename. So the env * is run through {@link scrubSubprocessEnv} HERE — forbidden keys (loader * hooks, `PATH`, secrets) are dropped and `PATH` is pinned to a trusted value * — before it reaches the guard (which scrubs again as a redundant net, as * does `defaultShellExecutor`). This also prevents the daemon's own secrets * from leaking into the child (the scrubbed env never inherits `process.env`). */ exec(command: string, options?: PiExecOptions): Promise>; /** Best-effort cleanup; the guarded surface owns no resources, so a no-op. */ cleanup(): Promise; } /** * Thrown by {@link createGuardedExecutionEnv} when the injected guard is NOT in * `enforce` mode. In `warn` mode the guard's path allowlist + command denylist * are advisory (log-and-proceed), so the second confinement net does not exist * and the boundary would collapse to the workspace check alone. The Pi adapter * therefore REFUSES a non-enforce guard rather than running untrusted Pi work * behind an advisory boundary. */ export declare class PiGuardModeError extends Error { /** Machine-readable code. */ readonly code = "E_PI_GUARD_NOT_ENFORCING"; /** * @param mode - The (non-enforce) mode the guard was constructed with. */ constructor(mode: GuardMode); } /** * Construct a deny-first {@link GuardedExecutionEnv}. * * REQUIRES an `enforce`-mode guard: a `warn`-mode guard's allowlist/denylist are * advisory, so handing untrusted Pi work behind one would leave confinement to * the workspace boundary alone. The factory ASSERTS the mode and throws * {@link PiGuardModeError} otherwise — the Pi adapter never silently degrades to * an advisory boundary. * * @param deps - The guard surface + the workspace root to confine to. * @returns A {@link PiExecutionEnv} safe to hand to Pi's agent loop. * @throws {PiGuardModeError} When `deps.guard.mode !== 'enforce'`. * * @example * ```ts * const guard = createToolGuard({ allowedRoots: [projectRoot], mode: 'enforce' }); * const env = createGuardedExecutionEnv({ guard, workspaceRoot: projectRoot }); * ``` */ export declare function createGuardedExecutionEnv(deps: GuardedExecutionEnvDeps): PiExecutionEnv; //# sourceMappingURL=pi-execution-env.d.ts.map