@crow-agent/codemode
v0.2.0
Published
Sandboxed execution modes for running untrusted code safely.
Downloads
373
Readme
@crow-agent/codemode
Let a model write JavaScript that calls your tools — without ever letting that JavaScript touch your process. Each run executes inside a QuickJS virtual machine (compiled to WebAssembly) whose only way out is the tool functions you inject. Nested tool calls stay inside the sandbox; the LLM sees only the script's output and return value.
The coding agent's built-in codemode tool is one consumer. The package has no
Crow dependencies, so anything — remote APIs, MCP servers, application services —
can be exposed to model-written scripts through it.
A first script
import { CodemodeSandbox } from "@crow-agent/codemode";
const sandbox = new CodemodeSandbox({
timeoutMs: 60_000,
tools: [
{
name: "list-dir",
description: "List files in a directory",
execute: async (args, { signal }) => {
const { dir } = args as { dir: string };
return await readdir(dir);
},
},
],
});
const result = await sandbox.execute(`
const entries = await tools.list_dir({ dir: "src" });
text("entries " + entries.length);
return entries.filter((name) => name.endsWith(".ts"));
`);
console.log(result.output); // [{ type: "text", text: "entries 12" }]
if (result.ok) console.log(result.value);
else console.error(result.error.kind, result.error.message);
await sandbox.close();execute() takes the body of an async function: top-level await and return
work. It never rejects for script failures — inspect result.ok instead.
What a script can touch
Deliberately little:
| Name | Meaning |
| --- | --- |
| tools.<name>(args) | Call an injected tool; returns a promise |
| ALL_TOOLS | [{ name, description }] for every tool, names in identifier form |
| text(value) | Append a text item to the output (non-strings are JSON-stringified) |
| image(urlOrItem) | Append an image item |
| exit() | Finish successfully right now, keeping output and store writes |
| store(key, value) / load(key) | Synchronous JSON persistence across runs |
| console.log/info/warn/error/debug | Append text items, like text() |
Tool names are also exposed as identifiers: anything that isn't valid in an
identifier becomes _ (see toCodemodeIdentifier), so a tool named my-tool
is reachable as both tools.my_tool and tools["my-tool"].
Arguments and results cross the boundary as JSON. A tool that throws rejects the
script-side promise with an Error carrying the same message. image() accepts
a base64 data: URL, an { image_url } object, or an MCP ImageContent block —
remote URLs are refused.
There is nothing else: no timers, no fetch, no process, no require, no
modules, no WebAssembly. eval and Function still work, but they can only
manufacture more code inside the same VM.
Tools and globals
Tools are the async workhorses. Globals are a second injection channel for
synchronous host helpers that scripts call as plain top-level functions: a
global named models.classify materializes as a frozen models object with a
classify function. Globals behave like tools except they never appear in
result.calls. Two options tune them: spread: true passes every call argument
through as an array instead of just the first, and signature replaces the
schema-derived declaration.
Tools and globals may carry description, inputSchema, and outputSchema
(JSON Schema). renderDeclarations() turns them into TypeScript declarations you
can paste into a model-facing tool description:
renderDeclarations({
tools: sandbox.tools,
globals: sandbox.globals,
});
// declare const tools: {
// /** List files in a directory */
// list_dir(args: {
// dir: string;
// }): Promise<string[]>;
// };Schemas shape the declarations only — values are never validated against them.
Local references (#/$defs/…) are expanded inline; recursive or remote ones
render as unknown.
Carrying state between runs: the store
The sandbox persists nothing itself. Pass the current values in as
options.store; a successful run reports what changed as
result.storeWrites ({ set, delete }), and failed runs report no writes:
const result = await sandbox.execute(
`store("visits", (load("visits") ?? 0) + 1)`,
{ store: saved },
);
if (result.ok) {
for (const gone of result.storeWrites.delete) delete saved[gone];
Object.assign(saved, result.storeWrites.set);
}load() returns a copy, so mutating the result leaves the store alone. Storing
undefined deletes the key. Values are capped: one value may hold at most
MAX_STORE_VALUE_CHARS (256 KiB) of JSON and all values together at most
MAX_STORE_TOTAL_CHARS (1 MiB); larger writes throw a RangeError inside the
script.
Timeouts, memory, and aborts
timeoutMsbounds the run;timeoutMs: Infinityremoves the deadline, leaving the script to run until it settles orsignalaborts it.- A script parked on a promise nothing can ever settle — no tool call pending, and the VM has no timers or I/O — fails immediately instead of hanging.
memoryLimitBytescaps the VM heap; over-allocation surfaces inside the script asInternalError: out of memory.
The options line
parseCodemodeSource() accepts a script whose first line may carry options:
// @options: {"max_output_tokens": 2000, "timeout_ms": 30000}
const entries = await tools.list_dir({ dir: "src" });
text(entries.join(", "));max_output_tokens budgets the output, timeout_ms sets a hard deadline. The
sandbox ignores both — the caller decides what they mean. The line is replaced
with an empty line so stack-trace line numbers still match the input. Empty
input, bad JSON, unknown fields, or an options line with no code after it throw
CodemodeSourceError. CODEMODE_SOURCE_GRAMMAR is a Lark grammar for providers
that constrain tool input with a grammar. Both also live on the lightweight
@crow-agent/codemode/source entry point.
Reading results
execute() resolves for every outcome. When result.ok is false,
result.error.kind tells you what happened:
| kind | Meaning |
| --- | --- |
| script | The script threw or failed to parse; stack points at codemode.js:<line> |
| timeout | The deadline expired; the worker was terminated |
| aborted | options.signal fired or close() was called; the worker was terminated |
| sandbox | The worker or VM itself failed — a wasm trap, a missing worker file, … |
result.output holds text and image items in production order, including for
failed runs. result.calls lists every tool call with
status: "ok" | "error" | "cancelled". A call still in flight when the script
returns (never awaited) is aborted through the tool's signal and reported as
cancelled.
Bundling
By default the sandbox loads quickjs-wasi/quickjs.wasm from the installed
package and starts the worker file next to this package's module. Neither path
exists once you bundle the host, so pass both explicitly:
import { CodemodeSandbox, loadQuickJSWasm } from "@crow-agent/codemode";
const sandbox = new CodemodeSandbox({
tools,
wasm: loadQuickJSWasm(pathToQuickJSWasm), // compiled once per path, then cached
workerUrl: new URL("./codemode-worker.js", import.meta.url), // a file of your build containing `import "@crow-agent/codemode/worker";`
});Giving an agent a codemode tool
Expose the agent's other tools to a sandbox and wrap execute() as an
AgentTool:
import type { AgentTool } from "@crow-agent/agent-core";
import {
type CodemodeJsonSchema,
CodemodeSandbox,
type CodemodeTool,
renderDeclarations,
} from "@crow-agent/codemode";
import { Type } from "typebox";
const exposed: CodemodeTool[] = agentTools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.parameters as CodemodeJsonSchema,
outputSchema:
(tool.outputSchema as CodemodeJsonSchema | undefined) ?? { type: "string" },
execute: async (args, { signal }) => {
const outcome = await tool.execute("nested", args as never, signal);
if (tool.outputSchema && outcome.structuredContent !== undefined) {
return outcome.structuredContent;
}
return outcome.content
.map((block) => (block.type === "text" ? block.text : ""))
.join("\n");
},
}));
const codemodeTool: AgentTool = {
name: "codemode",
label: "Codemode",
description:
"Run JavaScript that calls tools as `await tools.<name>(args)`. " +
"Output with text() or return.\n\n" +
renderDeclarations({ tools: exposed }),
parameters: Type.Object({
code: Type.String(),
}),
execute: async (_toolCallId, { code }, signal) => {
const sandbox = new CodemodeSandbox({ tools: exposed });
try {
const outcome = await sandbox.execute(code, { signal });
const content = [...outcome.output];
if (outcome.ok && outcome.value !== undefined) {
content.push({ type: "text", text: JSON.stringify(outcome.value) });
}
if (!outcome.ok) {
content.push({
type: "text",
text: outcome.error.stack ?? outcome.error.message,
});
}
return { content, details: undefined, isError: !outcome.ok };
} finally {
await sandbox.close();
}
},
};result.output items already match @crow-agent/ai's TextContent/ImageContent
shapes. Calling tool.execute() directly skips the agent's beforeToolCall /
afterToolCall hooks; to apply them to nested calls as well, route each call
through runToolCall() from @crow-agent/agent-core — the
mcp-codemode example
shows how, including failing the script on failed nested calls and combining
codemode with MCP tools.
How it works
Each execute() starts a worker thread (roughly 20 ms including VM creation)
that instantiates a fresh QuickJS VM from the compiled wasm module — a separate
wasm instance with its own linear memory. The VM's only imports are a WASI shim
(clock, random, stdout/stderr writes the worker discards) and a single host-call
entry point, so a script cannot reach the host except through registered
functions. A prelude evaluated inside the VM keeps that one bridge in a closure
and builds tools, console, and globals on top of it; the script itself is
compiled as an async function body.
Tool calls travel to the host thread as messages; the host runs the tool and
posts the JSON result back. The host also owns the deadline and the abort signal:
when either fires it sets a shared interrupt flag the VM polls, then calls
worker.terminate(). The flag exists for Bun, where terminate() cannot stop a
thread spinning inside wasm (while (true) {} or while (true) await null).
Keeping execution on a worker matters because QuickJS runs synchronously — a spinning script on the host thread would freeze its event loop.
Runtime notes
- Node and Bun behave the same, including memory limits, interrupts, and the
catchable
RangeErrorfor deep recursion (QuickJS's stack guard is on; without it the wasm stack overflows and traps). - Stack traces use QuickJS frames (
at f (codemode.js:2:31)) prefixed withName: messagelike V8. The wrapper shares line 1 with the script, so line numbers are exact; column numbers on line 1 shift. - QuickJS is an interpreter. Glue code and filtering tool results are plenty fast; heavy number-crunching runs slower than in V8.
