UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

210 lines • 10.7 kB
/** * localCodeRunner — run code in a child process, on this machine. * * Pattern: Adapter (GoF) over `node:child_process`. * Role: the dev default behind the {@link CodeRunner} port. `agentCoreCodeRunner` * is the production swap; the tool code does not change. * Emits: nothing. The tool that owns the session emits. * * ── ISOLATION, NOT A SANDBOX. Read this before deploying it. ──────────────── * The name is `localCodeRunner`, never `sandboxedCodeRunner`, and the * difference is not modesty — it is the whole security posture. * * What a child process DOES give you: * • a separate process and heap — a crash or an OOM takes the child, not you; * • kill on timeout, so runaway code has a ceiling — on POSIX the child * runs in a process group of its own, and at the timeout, on an abort or * at the output ceiling the runner stops that OWNED group (SIGKILL). That * is cleanup, not containment: a descendant that starts its own session * or group escapes it, and a host killed before its exit handlers run * stops nothing. On Windows only the child itself can be stopped, and the * timeout error says so; * • no inherited stdin, so nothing can block waiting for a terminal; * • an environment ALLOWLIST — `process.env` is not inherited (only `PATH`, * so the interpreter can be found), so an `AWS_SECRET_ACCESS_KEY` in your * shell is not in the model's reach; * • a working directory, by convention. * * What it does NOT give you, at all: * • a filesystem jail — the code can read and write anywhere this process can; * • a network jail — it can call out; * • CPU or memory limits beyond the timeout; * • any protection from code that deletes something outside `cwd`. * * ── It stages inputs; it does NOT report outputs (a decision, not a gap) ──── * `stageInputs` puts declared artifact payloads INTO the session, and the code * reads them from the manifest. Nothing comes back the same way: this runner * leaves `CodeResult.artifacts` ABSENT — never `[]`, which would claim the code * produced nothing — because it has no output location to collect from. The * child's working directory is the CALLER'S OWN cwd, so "files the code wrote" * is not a set this adapter can identify without guessing which of a developer's * files were meant, and a dev-loop runner that quietly uploaded whatever * appeared beside your source would be the worse failure. Producing outputs * needs a declared output directory the model is told about and a size policy * for what is too big to carry in-band; `readFile` below is the bounded * read-back, for a caller that names the file. A runner that has all three * fills the field, and `codeRunnerTool` mints every data-carrying entry with * no further wiring. * * ── Files and commands ───────────────────────────────────────────────────── * `readFile(path, { maxBytes })` reads ONE file back from the working * directory — the path checked by the port's one rule (`sandboxPath`), then * by its REAL location, so a symbolic link cannot lead it outside; a file over * `maxBytes` is refused, never cut; a file on disk is bytes, so bytes come * back and no encoding is guessed. `runCommand(command)` runs ONE `sh -c` * command with the same environment allowlist as the code, and exists only on * a runner built with `commands: true`. Neither is a network or filesystem * jail: a command reaches whatever this machine reaches. * * So: a development loop, a trusted-input pipeline, a machine you would be * relaxed about a shell script running on. NOT arbitrary model-written code * from an untrusted user, on a host with anything on it. For that, run a real * sandbox behind the same port — `agentCoreCodeRunner`, a gVisor container, a * Firecracker VM — and keep the tool identical. * * **In-process `eval` / `node:vm` is refused outright.** Node's own * documentation says `vm` is not a security mechanism ("do not use it to run * untrusted code"), so shipping it as one would be theater: the same code * reaching the same globals, wearing a word that makes a reader stop checking. * A subprocess is genuinely a boundary; it is just a smaller one than the word * "sandbox" implies. The library teaches refusals for things that are WRONG and * honest names for things that are merely LIMITED — running local code in a dev * loop is not wrong, and calling it a sandbox is. * * @example * const agent = Agent.create({ provider }) * .tool(codeRunnerTool({ runner: localCodeRunner() })) * .build(); */ import type { CodeRunner } from '../../provider-contract/types.js'; export interface LocalCodeRunnerOptions { /** * How to run a snippet, as `[command, ...args]` — the code is appended as the * final argument. Defaults by language: `['node', '-e']` for javascript, * `['python3', '-c']` for python. */ readonly command?: readonly string[]; /** Working directory for the child. Defaults to `process.cwd()`. */ readonly cwd?: string; /** * The child's environment. An ALLOWLIST, never a merge: `process.env` is NOT * inherited, so a credential sitting in this process's environment is not * handed to model-written code by default. Pass exactly what the code needs. * * ONE exception, stated because an unstated one is a hole: `PATH` is passed * through, because without it the operating system cannot find the * interpreter and nothing runs at all. `PATH` names directories, not secrets. * Set `env: { PATH: '/usr/bin' }` to narrow it, or `env: { PATH: '' }` to * refuse even that — anything you supply here WINS over the inherited value. */ readonly env?: Readonly<Record<string, string>>; /** Per-execution ceiling; the child is killed past it. Default 30s. */ readonly timeoutMs?: number; /** * Per-stream output ceiling, in characters. Default 8000. * * Cutting is allowed; cutting SILENTLY is not — anything cut is reported on * `CodeResult.truncated`, and `codeRunnerTool` renders that as a visible * marker in the tool result. The model has to know the table it is about to * reason over is a fragment. */ readonly maxOutputChars?: number; /** * Give each session `runCommand` — one `sh -c` command in the working * directory, with the same environment allowlist as the code. Default * `false`, and then the member is ABSENT (`canRunCodeCommands` says no): * it runs arbitrary shell, so a runner offers it only when built to. Build * one runner per agent that should have it. * * Not a jail: the command reaches this machine's filesystem and network, as * the code already does. */ readonly commands?: boolean; /** Stable id (default `'local-code-runner'`). Rides the session events. */ readonly id?: string; /** @internal Test seam — the `node:child_process` module. */ readonly _childProcess?: ChildProcessModuleLike; /** @internal Test seam — the `node:fs` slice staging and reading use. */ readonly _fs?: FsModuleLike; /** @internal Test seam — where staging directories are created. Defaults to * the OS temp directory. */ readonly _stagingRoot?: string; } /** The slice of `node:fs` staging and reading touch. Loaded lazily, and ONLY * when a caller actually stages or reads something — a session that does * neither never asks this runtime for a filesystem. */ export interface FsModuleLike { readonly mkdtempSync?: (prefix: string) => string; readonly writeFileSync?: (path: string, data: string | Uint8Array) => void; readonly rmSync?: (path: string, options?: { recursive?: boolean; force?: boolean; }) => void; readonly realpathSync?: (path: string) => string; readonly openSync?: (path: string, flags: string | number) => number; /** The open flags a read uses where the platform has them. */ readonly constants?: { readonly O_RDONLY?: number; readonly O_NONBLOCK?: number; readonly O_NOFOLLOW?: number; }; readonly fstatSync?: (fd: number) => { isFile(): boolean; readonly size: number; }; readonly readSync?: (fd: number, buffer: Uint8Array, offset: number, length: number, position: number | null) => number; readonly closeSync?: (fd: number) => void; } /** The slice of `node:child_process` this adapter touches. */ export interface ChildProcessModuleLike { /** Used when present (the real module always has it): only `spawn` can give * a child a process group of its own. */ readonly spawn?: SpawnLike; readonly execFile?: (file: string, args: readonly string[], options: { cwd?: string; env?: Record<string, string>; maxBuffer?: number; killSignal?: string; detached?: boolean; }, callback: (error: (Error & { code?: number | string; killed?: boolean; signal?: string | null; }) | null, stdout: string, stderr: string) => void) => unknown; } /** The part of the child `execFile` / `spawn` hands back that ending it needs. */ interface ChildLike { readonly pid?: number; kill?(signal?: string): boolean; readonly stdout?: { destroy?(): void; } | null; readonly stderr?: { destroy?(): void; } | null; } /** A readable end of a spawned child's pipe, as far as collecting it goes. */ interface PipeLike { on(event: 'data', listener: (chunk: Uint8Array) => void): unknown; destroy?(): void; } /** The child `spawn` hands back, as far as this adapter uses it. */ interface SpawnedLike extends ChildLike { readonly stdout?: PipeLike | null; readonly stderr?: PipeLike | null; on(event: 'error', listener: (error: Error & { code?: string; }) => void): unknown; on(event: 'close', listener: (code: number | null, signal: string | null) => void): unknown; } /** The slice of `node:child_process` · `spawn` the runner uses — preferred * over `execFile`, which does not pass `detached` on to the child. */ type SpawnLike = (file: string, args: readonly string[], options: { cwd?: string; env?: Record<string, string>; detached?: boolean; stdio?: readonly ['ignore', 'pipe', 'pipe']; }) => SpawnedLike; export declare function localCodeRunner(options?: LocalCodeRunnerOptions): CodeRunner; export {}; //# sourceMappingURL=local.d.ts.map