/** * Docker-based tool sandbox for enterprise deployments. * Isolates bash and file write operations inside a Docker container * to prevent accidental or malicious damage to the host system. */ import { execFile } from "child_process" import { promisify } from "util" import * as path from "path" import * as fs from "fs" import * as os from "os" import type { ToolResult } from "../protocol" const execFileAsync = promisify(execFile) const SANDBOX_IMAGE = "llmtune-sandbox:latest" const SANDBOX_WORKDIR = "/workspace" const EXECUTION_TIMEOUT_MS = 60_000 const MAX_OUTPUT_BYTES = 100_000 export interface SandboxConfig { enabled: boolean image: string timeoutMs: number readOnlyPaths: string[] maxMemoryMB: number } export const DEFAULT_SANDBOX_CONFIG: SandboxConfig = { enabled: false, image: SANDBOX_IMAGE, timeoutMs: EXECUTION_TIMEOUT_MS, readOnlyPaths: [], maxMemoryMB: 512, } let sandboxConfig: SandboxConfig = { ...DEFAULT_SANDBOX_CONFIG } export function configureSandbox(config: Partial): void { sandboxConfig = { ...sandboxConfig, ...config } } export function isSandboxEnabled(): boolean { return sandboxConfig.enabled } export async function isDockerAvailable(): Promise { try { const { stdout } = await execFileAsync("docker", ["--version"], { timeout: 5000, }) return /docker/i.test(stdout) } catch { return false } } export async function ensureSandboxImage(): Promise { try { const { stdout } = await execFileAsync("docker", ["images", "-q", sandboxConfig.image], { timeout: 10000, }) if (stdout.trim()) return true // Pull the image if not found console.log(`Pulling sandbox image: ${sandboxConfig.image}...`) await execFileAsync("docker", ["pull", sandboxConfig.image], { timeout: 120_000, }) return true } catch { return false } } export async function runInSandbox(command: string, cwd: string): Promise { if (!sandboxConfig.enabled) { return { name: "sandbox", output: { error: "Sandbox is not enabled" }, isError: true, } } const dockerAvailable = await isDockerAvailable() if (!dockerAvailable) { return { name: "sandbox", output: { error: "Docker is not available. Install Docker to use sandbox mode." }, isError: true, } } const workspaceMount = `${path.resolve(cwd)}:${SANDBOX_WORKDIR}` const memoryLimit = `${sandboxConfig.maxMemoryMB}m` const timeout = Math.min(sandboxConfig.timeoutMs, 120_000) const args = [ "run", "--rm", "--network", "none", "--memory", memoryLimit, "--cpus", "1", "--timeout", String(Math.ceil(timeout / 1000)), "-v", `${workspaceMount}:ro`, "--workdir", SANDBOX_WORKDIR, sandboxConfig.image, "sh", "-c", command, ] try { const { stdout, stderr } = await execFileAsync("docker", args, { timeout: timeout + 10_000, maxBuffer: MAX_OUTPUT_BYTES, }) let output = "" if (stdout) output += truncateOutput(stdout) if (stderr) { output += "\n--- stderr ---\n" output += truncateOutput(stderr) } return { name: "sandbox", output: { stdout: output.trim(), exit_code: 0, sandboxed: true, }, isError: false, } } catch (err: unknown) { const execErr = err as { stdout?: string; stderr?: string; killed?: boolean; code?: number } let output = "" if (execErr.stdout) output += truncateOutput(execErr.stdout) if (execErr.stderr) { output += "\n--- stderr ---\n" output += truncateOutput(execErr.stderr) } if (execErr.killed) { output += `\n--- Process killed (timeout or memory limit) ---` } return { name: "sandbox", output: { stdout: output.trim(), exit_code: typeof execErr.code === "number" ? execErr.code : 1, sandboxed: true, timed_out: execErr.killed ?? false, }, isError: true, } } } export async function writeFileInSandbox( filePath: string, content: string, cwd: string, ): Promise { if (!sandboxConfig.enabled) { return { name: "sandbox", output: { error: "Sandbox is not enabled" }, isError: true, } } // Write to a temp file on host, then copy into container const tmpDir = path.join(os.tmpdir(), "llmtune-sandbox") fs.mkdirSync(tmpDir, { recursive: true }) const tmpFile = path.join(tmpDir, `write-${Date.now()}.tmp`) fs.writeFileSync(tmpFile, content, "utf-8") try { const absTarget = path.resolve(cwd, filePath) const containerPath = `${SANDBOX_WORKDIR}/${path.relative(cwd, absTarget).replace(/\\/g, "/")}` const workspaceMount = `${path.resolve(cwd)}:${SANDBOX_WORKDIR}` const dockerArgs = [ "run", "--rm", "--network", "none", "--memory", `${sandboxConfig.maxMemoryMB}m`, "-v", `${workspaceMount}`, "-v", `${tmpFile}:/tmp/write-content.tmp:ro`, "--workdir", SANDBOX_WORKDIR, sandboxConfig.image, "sh", "-c", `mkdir -p "$(dirname '${containerPath}')" && cp /tmp/write-content.tmp '${containerPath}'`, ] await execFileAsync("docker", dockerArgs, { timeout: 30_000 }) return { name: "sandbox", output: { type: "text", filePath, bytesWritten: Buffer.byteLength(content, "utf-8"), sandboxed: true, }, isError: false, } } catch (err: unknown) { return { name: "sandbox", output: { error: `Sandbox write failed: ${(err as Error).message}` }, isError: true, } } finally { try { fs.unlinkSync(tmpFile) } catch { // cleanup failure is non-critical } } } function truncateOutput(output: string): string { if (output.length > 50_000) { return output.slice(0, 50_000) + "\n... (truncated)" } return output }