src / sandbox.ts
/**
* Sandbox helpers: path validation and subprocess execution.
*
* safe() resolves a path relative to the workspace and throws if it escapes.
* run() executes a command asynchronously with a hard timeout + size cap.
*/
import { spawn } from "child_process";
import { resolve, isAbsolute } from "path";
import { homedir } from "os";
export class SandboxError extends Error {
constructor(message: string) {
super(message);
this.name = "SandboxError";
}
}
/** Resolve path and verify it stays inside the workspace. */
export function safe(workspace: string, requestedPath: string): string {
const base = resolve(workspace);
const full = isAbsolute(requestedPath)
? resolve(requestedPath)
: resolve(base, requestedPath);
// Case-insensitive on macOS/Windows
if (!full.toLowerCase().startsWith(base.toLowerCase())) {
throw new SandboxError(
`Path "${requestedPath}" escapes the workspace (workspace: ${base})`
);
}
return full;
}
export interface RunResult {
code: number;
stdout: string;
stderr: string;
success: boolean;
}
const MAX_OUTPUT = 200_000;
function trim(s: string): string {
if (s.length > MAX_OUTPUT) {
const half = MAX_OUTPUT >> 1;
return (
s.slice(0, half) +
`\n\n... [truncated ${s.length - MAX_OUTPUT} chars] ...\n\n` +
s.slice(-half)
);
}
return s;
}
/** Run a program (NOT via shell) with a timeout. */
export function run(
cmd: string,
args: string[],
opts: {
cwd?: string;
stdin?: string;
timeout?: number;
env?: NodeJS.ProcessEnv;
} = {}
): Promise<RunResult> {
return new Promise((resolve) => {
const timeout = opts.timeout ?? 60;
const proc = spawn(cmd, args, {
cwd: opts.cwd,
env: opts.env ?? process.env,
stdio: ["pipe", "pipe", "pipe"],
});
let stdoutBuf = "";
let stderrBuf = "";
proc.stdout.on("data", (d: Buffer) => { stdoutBuf += d.toString(); });
proc.stderr.on("data", (d: Buffer) => { stderrBuf += d.toString(); });
if (opts.stdin) {
proc.stdin.write(opts.stdin);
proc.stdin.end();
} else {
proc.stdin.end();
}
const timer = setTimeout(() => {
proc.kill("SIGKILL");
resolve({
code: -1,
stdout: trim(stdoutBuf),
stderr: `Process killed after ${timeout}s timeout`,
success: false,
});
}, timeout * 1000);
proc.on("close", (code) => {
clearTimeout(timer);
resolve({
code: code ?? -1,
stdout: trim(stdoutBuf),
stderr: trim(stderrBuf),
success: (code ?? -1) === 0,
});
});
proc.on("error", (err) => {
clearTimeout(timer);
resolve({
code: -1,
stdout: "",
stderr: `Failed to start process: ${err.message}`,
success: false,
});
});
});
}
/** Run a command via bash -c (shell string). Returns RunResult. */
export function runShell(
command: string,
opts: { cwd?: string; stdin?: string; timeout?: number; env?: NodeJS.ProcessEnv } = {}
): Promise<RunResult> {
const shell = process.platform === "win32" ? "cmd" : "/bin/bash";
const flag = process.platform === "win32" ? "/c" : "-c";
return run(shell, [flag, command], opts);
}
/** Detect the best C++ compiler available. */
export async function detectCxx(preferClang: boolean): Promise<string | null> {
const candidates = preferClang
? ["clang++", "g++", "c++"]
: ["g++", "clang++", "c++"];
for (const cxx of candidates) {
const r = await run("which", [cxx], { timeout: 5 });
if (r.success) return r.stdout.trim();
}
return null;
}
/** Return the workspace path — use configured path or cwd. */
export function getWorkspace(configured: string): string {
return configured.trim() || process.cwd();
}
/**
* Resolve the Python binary to use.
*
* Priority:
* 1. Blank → "python3" (system default)
* 2. Contains "/" → treat as a direct binary path
* 3. Otherwise → treat as a conda env name and search common conda prefixes
*
* Returns the resolved binary string (may be a full path or "python3").
*/
export async function resolvePython(configured: string): Promise<string> {
const val = configured.trim();
if (!val) return "python3";
if (val.includes("/")) return val; // explicit path
// Treat as conda env name — search known conda prefix directories
const condaPrefixes = [
`${homedir()}/conda_envs`,
`/Volumes/personal/conda_envs`,
`${homedir()}/miniconda3/envs`,
`${homedir()}/anaconda3/envs`,
`${homedir()}/opt/miniconda3/envs`,
`/opt/homebrew/Caskroom/miniconda/base/envs`,
`/opt/miniconda3/envs`,
`/opt/anaconda3/envs`,
];
for (const prefix of condaPrefixes) {
const candidate = resolve(prefix, val, "bin", "python");
const r = await run(candidate, ["--version"], { timeout: 5 });
if (r.success || r.stderr.startsWith("Python")) return candidate;
}
// Last resort: ask conda itself
const condaInfo = await run("conda", ["run", "-n", val, "which", "python"], { timeout: 10 });
if (condaInfo.success) return condaInfo.stdout.trim();
// Fall back to system python3 and warn
console.warn(`[high-perf-tools] Could not resolve conda env "${val}", falling back to python3`);
return "python3";
}