openclaw/secure/sandbox.ts
Claude 095d476acc
feat: add persistent personality, Piston API sandbox, and storage layer
- Add personality engine with learning from conversations
  - Tracks user preferences, interests, and communication style
  - Persists to Redis (cache) + PostgreSQL (durable)
  - Generates personalized system prompts per user

- Add Piston API fallback for sandbox execution
  - Auto-detects backend: Docker → Piston API → none
  - Supports 15+ languages via free cloud execution
  - Works on Railway and other managed platforms

- Add storage layer with layered persistence
  - PostgreSQL for tasks, user profiles, personality traits
  - Redis for conversation cache and fast profile access
  - Graceful fallback to in-memory when not configured

- Update scheduler with task persistence
  - Loads tasks from PostgreSQL on startup
  - Saves task status after execution

- Add document analysis (PDF, text files)
- Add railway-template.json for one-click deployment
- Enable sandbox by default (Piston fallback)
- Add OpenRouter support (100+ models)

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

483 lines
12 KiB
TypeScript

/**
* 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";
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();
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: code }],
}),
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();
return new Promise((resolve) => {
const args = buildDockerArgs(config, command);
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;
}