How to Create a Custom Sandbox Provider in Sandcastle Using `createBindMountSandboxProvider`
Use createBindMountSandboxProvider from src/SandboxProvider.ts to define a factory that returns a BindMountSandboxHandle implementing exec, copyFileIn, copyFileOut, and close methods, then pass the provider to run() or createSandbox().
Sandcastle is a flexible code execution framework that abstracts containerized runtimes through the SandboxProvider interface. If you need to integrate a custom execution environment—whether a chroot jail, LXC container, or VM—createBindMountSandboxProvider offers the standard factory to register your implementation with the framework. This guide walks through the contract requirements and implementation details found in the mattpocock/sandcastle repository.
Understanding the Sandbox Abstraction
Sandcastle distinguishes between sandbox types, with bind-mount being the simplest: the host file system mounts directly into the sandbox runtime, allowing agents to read and write files without overlay filesystems. The core factory for these providers resides in src/SandboxProvider.ts (lines 303-311), exporting createBindMountSandboxProvider which accepts a configuration object defining how to spawn and manage your runtime.
Configuring the Provider Factory
The factory expects an object with four key properties: name, optional env, optional sandboxHomedir, and the mandatory create function.
name: A human-readable identifier like"docker"or"chroot-alpine".env: Extra environment variables merged with user-supplied values.sandboxHomedir: Path to$HOMEinside the sandbox, required for containers with fixed home directories.create: An async function receivingBindMountCreateOptions(defined at lines 66-78 insrc/SandboxProvider.ts) and returning aBindMountSandboxHandle.
The BindMountCreateOptions interface supplies:
worktreePath: Host path to the project directory.repoPath: Original repository root.mounts: Pre-calculated bind-mounts prepared by Sandcastle.env: Process environment variables.
Implementing the BindMountSandboxHandle Contract
Your create function must return an object satisfying the BindMountSandboxHandle interface (lines 23-64 in src/SandboxProvider.ts). The handle acts as the runtime controller with these required methods:
exec(command, opts?): Execute a command inside the sandbox, returning stdout, stderr, and exit code. Output should stream line-by-line for real-time processing.copyFileIn(hostPath, sandboxPath): Copy files from the host into the sandbox.copyFileOut(sandboxPath, hostPath): Copy files from the sandbox back to the host.close(): Asynchronously clean up the runtime (stop containers, remove temp directories, etc.).
Optionally implement interactiveExec(args, opts) to support TTY sessions, forwarding stdin/stdout/stderr between the user and sandbox process.
Custom Provider Implementation Example
Below is a minimal chroot-based provider that satisfies the contract. It creates an isolated directory, bind-mounts the worktree, and implements all required handle methods:
// src/sandboxes/chrootProvider.ts
import {
createBindMountSandboxProvider,
type BindMountCreateOptions,
type BindMountSandboxHandle,
} from "../SandboxProvider.js";
export const chrootProvider = createBindMountSandboxProvider({
name: "chroot-alpine",
create: async (opts: BindMountCreateOptions): Promise<BindMountSandboxHandle> => {
const chrootBase = "/tmp/sandcastle-chroot";
// Setup: Ensure chroot directory exists
const fs = await import("fs/promises");
await fs.mkdir(chrootBase, { recursive: true });
// Calculate sandbox-side worktree path
const sandboxWorktree = `${chrootBase}${opts.worktreePath}`;
return {
worktreePath: sandboxWorktree,
exec: async (command) => {
const { execFile } = await import("node:child_process");
return new Promise((resolve, reject) => {
execFile(
"chroot",
[chrootBase, "sh", "-c", command],
{ env: { ...opts.env, HOME: "/root" } },
(error, stdout, stderr) => {
if (error) reject(error);
else resolve({
stdout: stdout?.toString() ?? "",
stderr: stderr?.toString() ?? "",
exitCode: 0,
});
}
);
});
},
interactiveExec: async (args, { stdin, stdout, stderr }) => {
const { spawn } = await import("node:child_process");
const proc = spawn("chroot", [chrootBase, ...args], {
stdio: [stdin, stdout, stderr],
});
return new Promise((resolve) => {
proc.on("close", (code) => resolve({ exitCode: code ?? 0 }));
});
},
copyFileIn: async (hostPath, sandboxPath) => {
const { execFile } = await import("node:child_process");
await new Promise<void>((res, rej) => {
execFile("cp", [hostPath, `${chrootBase}${sandboxPath}`], (err) =>
err ? rej(err) : res()
);
});
},
copyFileOut: async (sandboxPath, hostPath) => {
const { execFile } = await import("node:child_process");
await new Promise<void>((res, rej) => {
execFile("cp", [`${chrootBase}${sandboxPath}`, hostPath], (err) =>
err ? rej(err) : res()
);
});
},
close: async () => {
const { rm } = await import("fs/promises");
await rm(chrootBase, { recursive: true, force: true });
},
};
},
});
Integrating Your Provider
Once exported, use your provider exactly like built-in implementations by invoking it and passing the handle to Sandcastle's execution API:
import { run } from "sandcastle";
import { chrootProvider } from "./sandboxes/chrootProvider.js";
await run({
agent: myAgent,
sandbox: chrootProvider(),
});
The provider also works with interactive() for TTY sessions and createSandbox() for direct handle access.
Reference Implementations
Study the official implementations for complex runtime patterns:
- Docker:
src/sandboxes/docker.ts(lines 85-138) demonstrates container lifecycle management, volume mapping, and the Docker CLI integration. - Podman:
src/sandboxes/podman.tsmirrors the Docker pattern targeting thepodmanbinary.
Both follow the identical BindMountSandboxHandle contract, proving the abstraction works across container engines.
Summary
createBindMountSandboxProviderinsrc/SandboxProvider.tsis the factory for bind-mount sandbox integrations.- The
createfunction receivesBindMountCreateOptionsand must return aBindMountSandboxHandle. - Required handle methods include
exec,copyFileIn,copyFileOut, andclose. - Reference implementations in
src/sandboxes/docker.tsandsrc/sandboxes/podman.tsdemonstrate production patterns. - Custom providers integrate seamlessly with
run(),interactive(), andcreateSandbox().
Frequently Asked Questions
What is the difference between createBindMountSandboxProvider and other sandbox types?
Bind-mount providers map the host worktree directly into the sandbox runtime, allowing native file access. Isolated sandboxes (not covered by this factory) use overlay filesystems or ephemeral storage, while "none" disables sandboxing entirely. According to src/startSandbox.ts, the dispatcher selects bind-mount when the provider follows this specific contract.
Do I need to implement interactiveExec for my custom provider?
No. interactiveExec is optional per the BindMountSandboxHandle interface (lines 23-64 in src/SandboxProvider.ts). However, implementing it enables TTY support for interactive() sessions. If omitted, Sandcastle falls back to non-interactive execution.
How do I handle environment variables in my custom sandbox?
Merge opts.env (provided in BindMountCreateOptions) with any runtime-specific defaults in your exec implementation. The factory's optional env property adds static variables to all executions, while opts.env contains dynamic values from the Sandcastle runtime.
Can I use createBindMountSandboxProvider for cloud-based sandboxes?
Yes, provided the remote environment supports bind-mount semantics or you simulate them via synchronization. The contract only requires file copy methods and command execution; the underlying runtime can be local Docker, remote Kubernetes, or even a VM managed via SSH, as implemented in the handle's methods.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →