/** * TypeBox schema and validation for the sandbox_run tool. * * Pure logic, no Docker dependency, unit-testable. */ import { Static, Type } from "typebox"; import { DEFAULT_TIMEOUT_SECONDS, MAX_ARTIFACTS, MAX_TIMEOUT_SECONDS } from "./types.ts"; /** Description shown to the model so it knows when to use the sandbox. */ export const SANDBOX_TOOL_DESCRIPTION = "Execute code in a fresh, disposable, offline Debian sandbox container. " + "Use this for building, testing, compiling, or running code that should not touch the host. " + "The host project is mounted read-only at /workspace; write exported results under /output. " + "Networking is OFF by default; if you set network:true the human must approve that single run. " + "Use exactly one of command (shell) or executable+args (no shell)."; /** TypeBox schema for sandbox_run parameters. */ export const sandboxRunParamsSchema = Type.Object( { command: Type.Optional( Type.String({ description: "Shell command to run (pipelines, redirects, &&, etc.). Exactly one of command or executable must be provided.", }), ), executable: Type.Optional( Type.String({ description: "Executable to run without a shell (no globbing, redirection, or pipes). Exactly one of command or executable must be provided.", }), ), args: Type.Optional( Type.Array(Type.String({ description: "Arguments passed to the executable (argv mode only)." })), ), setup: Type.Optional( Type.Array( Type.String({ description: "Privileged setup shell commands run as root before the main command (e.g. package installation). Default: none.", }), ), ), network: Type.Optional( Type.Boolean({ description: "Enable network access for THIS run only. Requires human approval. Default: false (no network).", }), ), timeout_seconds: Type.Optional( Type.Number({ description: `Timeout in seconds (default ${DEFAULT_TIMEOUT_SECONDS}, max ${MAX_TIMEOUT_SECONDS}).`, }), ), artifacts: Type.Optional( Type.Array( Type.String({ description: "Relative paths under /output to export to the host after the run (e.g. 'report.json', 'coverage/index.html'). No absolute or .. paths.", }), ), ), }, { additionalProperties: false }, ); export type SandboxRunParams = Static; /** Errors thrown during parameter validation. */ export class ParamsError extends Error {} /** True when the run requests networking. */ export function wantsNetwork(params: SandboxRunParams): boolean { return params.network === true; } /** * Validate that exactly one execution mode is selected. * Throws ParamsError on ambiguity (both) or absence (neither). */ export function validateExecutionMode(params: SandboxRunParams): "shell" | "argv" { const hasCommand = typeof params.command === "string" && params.command.trim() !== ""; const hasExecutable = typeof params.executable === "string" && params.executable.trim() !== ""; if (hasCommand && hasExecutable) { throw new ParamsError("Provide exactly one of command or executable, not both."); } if (!hasCommand && !hasExecutable) { throw new ParamsError("Provide exactly one of command or executable."); } return hasCommand ? "shell" : "argv"; } /** * Normalize and bound the timeout. * Returns the effective timeout in seconds, or throws ParamsError if invalid. */ export function resolveTimeoutSeconds(timeout_seconds: number | undefined): number { if (timeout_seconds === undefined) return DEFAULT_TIMEOUT_SECONDS; if (!Number.isFinite(timeout_seconds) || timeout_seconds <= 0) { throw new ParamsError("timeout_seconds must be a positive finite number."); } if (timeout_seconds > MAX_TIMEOUT_SECONDS) { throw new ParamsError(`timeout_seconds cannot exceed ${MAX_TIMEOUT_SECONDS}.`); } return timeout_seconds; } /** Normalize the artifact list, validating count bounds. */ export function normalizeArtifactList(artifacts: string[] | undefined): string[] { const list = artifacts ?? []; if (list.length > MAX_ARTIFACTS) { throw new ParamsError(`At most ${MAX_ARTIFACTS} artifacts may be requested per run.`); } return list; }