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 (209 loc) • 10.7 kB
TypeScript
/**
* 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 {};