openclaw/secure/sandbox.ts
Claude de7c462c95
feat: add AI tool calling for code execution + fix smart quotes
- AI can now execute code directly when users ask (uses execute_code tool)
- No need for users to use slash commands - AI runs code automatically
- Fixed smart quote issue: curly quotes from mobile keyboards are normalized
- Updated system prompts to instruct AI to use tools proactively

https://claude.ai/code/session_015VqJ7gN4vaxtYfYc92UjLs
2026-01-30 08:33:27 +00:00

503 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* AssureBot - Sandbox Execution
*
* Isolated code execution with multiple backends:
* 1. Docker (local) - if Docker socket available
* 2. Piston API (cloud) - free code execution API fallback
*
* Security-first: no network, read-only root, resource limits.
*/
import { spawn } from "node:child_process";
/**
* Normalize smart quotes to straight quotes
* Telegram and mobile keyboards often auto-convert quotes which breaks code
*/
export function normalizeQuotes(code: string): string {
return code
// Double quotes: " " „ ‟ → "
.replace(/[\u201C\u201D\u201E\u201F]/g, '"')
// Single quotes: ' ' → '
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
// Backticks: ` → `
.replace(/[\u0060\u2018]/g, "`");
}
import type { SecureConfig } from "./config.js";
import type { AuditLogger } from "./audit.js";
export type SandboxResult = {
exitCode: number;
stdout: string;
stderr: string;
timedOut: boolean;
durationMs: number;
};
export type SandboxRunner = {
run: (command: string, stdin?: string) => Promise<SandboxResult>;
runCode: (language: string, code: string) => Promise<SandboxResult>;
isAvailable: () => Promise<boolean>;
backend: "docker" | "piston" | "none";
};
// Piston API - free cloud-based code execution
const PISTON_API = "https://emkc.org/api/v2/piston";
// Supported languages for Piston
const PISTON_LANGUAGES: Record<string, { language: string; version: string }> = {
python: { language: "python", version: "3.10" },
python3: { language: "python", version: "3.10" },
py: { language: "python", version: "3.10" },
javascript: { language: "javascript", version: "18.15.0" },
js: { language: "javascript", version: "18.15.0" },
node: { language: "javascript", version: "18.15.0" },
typescript: { language: "typescript", version: "5.0.3" },
ts: { language: "typescript", version: "5.0.3" },
bash: { language: "bash", version: "5.2.0" },
sh: { language: "bash", version: "5.2.0" },
shell: { language: "bash", version: "5.2.0" },
rust: { language: "rust", version: "1.68.2" },
go: { language: "go", version: "1.16.2" },
c: { language: "c", version: "10.2.0" },
cpp: { language: "c++", version: "10.2.0" },
java: { language: "java", version: "15.0.2" },
ruby: { language: "ruby", version: "3.0.1" },
php: { language: "php", version: "8.2.3" },
};
/**
* Check if Docker is available
*/
async function checkDocker(): Promise<boolean> {
return new Promise((resolve) => {
const proc = spawn("docker", ["version"], {
stdio: ["ignore", "ignore", "ignore"],
});
proc.on("error", () => resolve(false));
proc.on("close", (code) => resolve(code === 0));
});
}
/**
* Check if Piston API is available
*/
async function checkPiston(): Promise<boolean> {
try {
const response = await fetch(`${PISTON_API}/runtimes`, {
method: "GET",
signal: AbortSignal.timeout(5000),
});
return response.ok;
} catch {
return false;
}
}
/**
* Execute code via Piston API
*/
async function runPiston(
language: string,
code: string,
timeoutMs: number
): Promise<SandboxResult> {
const startTime = Date.now();
// Normalize smart quotes from mobile keyboards
const normalizedCode = normalizeQuotes(code);
const langConfig = PISTON_LANGUAGES[language.toLowerCase()];
if (!langConfig) {
return {
exitCode: 1,
stdout: "",
stderr: `Unsupported language: ${language}\n\nSupported: ${Object.keys(PISTON_LANGUAGES).join(", ")}`,
timedOut: false,
durationMs: Date.now() - startTime,
};
}
try {
const response = await fetch(`${PISTON_API}/execute`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
language: langConfig.language,
version: langConfig.version,
files: [{ content: normalizedCode }],
}),
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
const text = await response.text();
return {
exitCode: 1,
stdout: "",
stderr: `Piston API error: ${response.status} ${text}`,
timedOut: false,
durationMs: Date.now() - startTime,
};
}
const result = await response.json() as {
run: { stdout: string; stderr: string; code: number; signal: string | null };
compile?: { stdout: string; stderr: string; code: number };
};
// Check for compilation errors
if (result.compile && result.compile.code !== 0) {
return {
exitCode: result.compile.code,
stdout: result.compile.stdout || "",
stderr: result.compile.stderr || "Compilation failed",
timedOut: false,
durationMs: Date.now() - startTime,
};
}
return {
exitCode: result.run.code,
stdout: (result.run.stdout || "").slice(0, 10000),
stderr: (result.run.stderr || "").slice(0, 10000),
timedOut: result.run.signal === "SIGKILL",
durationMs: Date.now() - startTime,
};
} catch (err) {
const isTimeout = err instanceof Error && err.name === "TimeoutError";
return {
exitCode: 1,
stdout: "",
stderr: isTimeout ? "Execution timed out" : `Error: ${err instanceof Error ? err.message : String(err)}`,
timedOut: isTimeout,
durationMs: Date.now() - startTime,
};
}
}
/**
* Build Docker run arguments for secure execution
*/
function buildDockerArgs(config: SecureConfig["sandbox"], command: string): string[] {
const args: string[] = [
"run",
"--rm", // Remove container after exit
"-i", // Interactive (for stdin)
// Security: No network by default
`--network=${config.network}`,
// Security: Read-only root filesystem
"--read-only",
// Security: tmpfs for writable areas
"--tmpfs=/tmp:rw,noexec,nosuid,size=64m",
"--tmpfs=/var/tmp:rw,noexec,nosuid,size=64m",
// Security: Drop all capabilities
"--cap-drop=ALL",
// Security: No new privileges
"--security-opt=no-new-privileges",
// Resource limits
`--memory=${config.memory}`,
`--cpus=${config.cpus}`,
"--pids-limit=100",
// Timeout handled externally, but set a ulimit too
"--ulimit=cpu=60:60",
// Working directory
"--workdir=/workspace",
// Image
config.image,
// Command (via shell for flexibility)
"sh",
"-c",
command,
];
return args;
}
/**
* Execute command via Docker
*/
async function runDocker(
config: SecureConfig["sandbox"],
command: string,
stdin?: string
): Promise<SandboxResult> {
const startTime = Date.now();
// Normalize smart quotes from mobile keyboards
const normalizedCommand = normalizeQuotes(command);
return new Promise((resolve) => {
const args = buildDockerArgs(config, normalizedCommand);
const proc = spawn("docker", args, {
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
let resolved = false;
const finish = (exitCode: number) => {
if (resolved) return;
resolved = true;
resolve({
exitCode,
stdout: stdout.slice(0, 10000), // Limit output size
stderr: stderr.slice(0, 10000),
timedOut,
durationMs: Date.now() - startTime,
});
};
// Timeout
const timeout = setTimeout(() => {
timedOut = true;
proc.kill("SIGKILL");
}, config.timeoutMs);
proc.stdout?.on("data", (data: Buffer) => {
stdout += data.toString();
// Prevent memory exhaustion
if (stdout.length > 100000) {
proc.kill("SIGKILL");
}
});
proc.stderr?.on("data", (data: Buffer) => {
stderr += data.toString();
if (stderr.length > 100000) {
proc.kill("SIGKILL");
}
});
proc.on("error", (err) => {
clearTimeout(timeout);
stderr += `\nProcess error: ${err.message}`;
finish(1);
});
proc.on("close", (code) => {
clearTimeout(timeout);
finish(code ?? 1);
});
// Write stdin if provided
if (stdin && proc.stdin) {
proc.stdin.write(stdin);
proc.stdin.end();
} else {
proc.stdin?.end();
}
});
}
export function createSandboxRunner(config: SecureConfig, audit: AuditLogger): SandboxRunner {
const sandboxConfig = config.sandbox;
// Detect available backend at creation time
let detectedBackend: "docker" | "piston" | "none" = "none";
let backendChecked = false;
async function detectBackend(): Promise<"docker" | "piston" | "none"> {
if (backendChecked) return detectedBackend;
if (!sandboxConfig.enabled) {
detectedBackend = "none";
backendChecked = true;
return detectedBackend;
}
// Try Docker first
if (await checkDocker()) {
detectedBackend = "docker";
console.log("[sandbox] Using Docker backend");
} else if (await checkPiston()) {
// Fall back to Piston API
detectedBackend = "piston";
console.log("[sandbox] Using Piston API backend (Docker not available)");
} else {
detectedBackend = "none";
console.log("[sandbox] No sandbox backend available");
}
backendChecked = true;
return detectedBackend;
}
// Start detection immediately
void detectBackend();
return {
get backend() {
return detectedBackend;
},
async isAvailable(): Promise<boolean> {
const backend = await detectBackend();
return backend !== "none";
},
async run(command: string, stdin?: string): Promise<SandboxResult> {
const backend = await detectBackend();
const startTime = Date.now();
if (backend === "none") {
return {
exitCode: 1,
stdout: "",
stderr: "Sandbox is disabled or no backend available",
timedOut: false,
durationMs: 0,
};
}
let result: SandboxResult;
if (backend === "docker") {
result = await runDocker(sandboxConfig, command, stdin);
} else {
// Piston: run as bash
result = await runPiston("bash", command, sandboxConfig.timeoutMs);
}
audit.sandbox({
command,
exitCode: result.exitCode,
durationMs: result.durationMs,
});
return result;
},
async runCode(language: string, code: string): Promise<SandboxResult> {
const backend = await detectBackend();
if (backend === "none") {
return {
exitCode: 1,
stdout: "",
stderr: "Sandbox is disabled or no backend available",
timedOut: false,
durationMs: 0,
};
}
let result: SandboxResult;
if (backend === "piston") {
// Use Piston directly for language support
result = await runPiston(language, code, sandboxConfig.timeoutMs);
} else {
// Docker: build command for the language
const command = buildCommand(language, code);
result = await runDocker(sandboxConfig, command);
}
audit.sandbox({
command: `[${language}] ${code.slice(0, 100)}...`,
exitCode: result.exitCode,
durationMs: result.durationMs,
});
return result;
},
};
}
/**
* Parse sandbox command from user message
* Returns null if message doesn't request code execution
*/
export function parseSandboxRequest(text: string): {
language: string;
code: string;
} | null {
// Match code blocks with language
const codeBlockMatch = text.match(/```(\w+)?\n([\s\S]*?)```/);
if (codeBlockMatch) {
const language = codeBlockMatch[1] || "sh";
const code = codeBlockMatch[2].trim();
return { language, code };
}
// Match /run command
const runMatch = text.match(/^\/run\s+(.+)$/s);
if (runMatch) {
return { language: "sh", code: runMatch[1].trim() };
}
// Match /python command
const pythonMatch = text.match(/^\/python\s+(.+)$/s);
if (pythonMatch) {
return { language: "python", code: pythonMatch[1].trim() };
}
return null;
}
/**
* Build execution command for language (Docker only)
*/
export function buildCommand(language: string, code: string): string {
switch (language.toLowerCase()) {
case "python":
case "py":
return `python3 -c ${JSON.stringify(code)}`;
case "javascript":
case "js":
case "node":
return `node -e ${JSON.stringify(code)}`;
case "bash":
case "sh":
case "shell":
return code;
default:
return code;
}
}
/**
* Format sandbox result for display
*/
export function formatSandboxResult(result: SandboxResult): string {
let output = "";
if (result.timedOut) {
output += "**Timed out**\n\n";
}
if (result.stdout) {
output += "**Output:**\n```\n" + result.stdout.trim() + "\n```\n";
}
if (result.stderr) {
output += "**Errors:**\n```\n" + result.stderr.trim() + "\n```\n";
}
if (!result.stdout && !result.stderr) {
output += result.exitCode === 0 ? "Command completed (no output)" : "Command failed (no output)";
}
output += `\n_Exit code: ${result.exitCode}, Duration: ${result.durationMs}ms_`;
return output;
}