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 $HOME inside the sandbox, required for containers with fixed home directories.
  • create: An async function receiving BindMountCreateOptions (defined at lines 66-78 in src/SandboxProvider.ts) and returning a BindMountSandboxHandle.

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:

Both follow the identical BindMountSandboxHandle contract, proving the abstraction works across container engines.

Summary

  • createBindMountSandboxProvider in src/SandboxProvider.ts is the factory for bind-mount sandbox integrations.
  • The create function receives BindMountCreateOptions and must return a BindMountSandboxHandle.
  • Required handle methods include exec, copyFileIn, copyFileOut, and close.
  • Reference implementations in src/sandboxes/docker.ts and src/sandboxes/podman.ts demonstrate production patterns.
  • Custom providers integrate seamlessly with run(), interactive(), and createSandbox().

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →