/**
* Shell Execution — run_command and background process tools
*
* Extracted from local-tools.ts for single-responsibility.
*/
import { join } from "path";
import { spawn } from "child_process";
import { homedir } from "os";
import { spawnBackground, readProcessOutput, killProcess, listProcesses } from "../background-processes.js";
import { getGlobalEmitter } from "../agent-events.js";
import { sandboxCommand, cleanupSandbox } from "../sandbox.js";
import { debugLog } from "../debug-log.js";
import { ToolResult } from "../../../shared/types.js";
function resolvePath(p: string): string {
if (p.startsWith("~/")) return join(homedir(), p.slice(2));
return p;
}
export async function runCommand(input: Record<string, unknown>): Promise<ToolResult> {
let command = input.command as string;
const cwd = input.working_directory ? resolvePath(input.working_directory as string) : undefined;
const timeout = Math.min((input.timeout as number) || 30000, 300000);
const background = input.run_in_background as boolean;
const description = input.description as string | undefined;
debugLog("tools", `run_command: ${description || command.slice(0, 80)}`, { cwd, timeout, background });
// UX guardrail only — the sandbox (macOS) is the real security boundary.
// This catches obvious destructive commands before they reach the sandbox.
if (command.length > 10000) {
return { success: false, output: "Command too long (max 10000 chars)" };
}
const DANGEROUS_PATTERNS: RegExp[] = [
/\brm\s+(-[a-z]*f[a-z]*\s+)?(-[a-z]*r[a-z]*\s+)?(\/|~)/i,
/\brm\s+(-[a-z]*r[a-z]*\s+)?(-[a-z]*f[a-z]*\s+)?(\/|~)/i,
/\bmkfs\b/i,
/\bdd\s+.*\bif=/i,
/>\s*\/dev\/sd/,
/:\(\)\s*\{.*\|.*&\s*\}\s*;/,
/\bchmod\s+(-[a-z]*R[a-z]*\s+)?777\s+\//i,
/\bchown\s+(-[a-z]*R[a-z]*\s+).*\//i,
/\bcurl\b.*\|\s*(ba)?sh\b/i,
/\bwget\b.*\|\s*(ba)?sh\b/i,
/\b(python|perl|ruby|node)\s+-e\s+.*\b(system|exec|spawn)\b/i,
/base64\s+(-d|--decode)\s*\|\s*(ba)?sh/i,
];
const command_lower = command.toLowerCase().replace(/\s+/g, " ");
if (DANGEROUS_PATTERNS.some((p) => p.test(command_lower))) {
return { success: false, output: "Command blocked for safety" };
}
// Apply sandbox wrapping (macOS only)
let sandboxProfilePath: string | null = null;
{
const effectiveCwd = cwd || process.cwd();
const sandboxResult = sandboxCommand(command, effectiveCwd);
command = sandboxResult.wrapped;
sandboxProfilePath = sandboxResult.profilePath;
}
// Background mode — spawn detached, validate, return with status
if (background) {
const result = await spawnBackground(command, { cwd, timeout: 600_000, description: input.description as string });
return { success: result.status === "running", output: result.message };
}
// Foreground async — spawn + stream output via events
return new Promise<ToolResult>((resolve) => {
const stdout: string[] = [];
const stderr: string[] = [];
let killed = false;
const child = spawn(command, [], {
shell: true,
cwd,
env: { ...process.env, FORCE_COLOR: "0" },
stdio: ["pipe", "pipe", "pipe"],
});
const emitter = getGlobalEmitter();
child.stdout?.on("data", (data: Buffer) => {
const text = data.toString();
stdout.push(text);
// Emit live output for UI streaming
for (const line of text.split("\n")) {
if (line.trim()) emitter.emitToolOutput("run_command", line);
}
});
child.stderr?.on("data", (data: Buffer) => {
const text = data.toString();
stderr.push(text);
for (const line of text.split("\n")) {
if (line.trim()) emitter.emitToolOutput("run_command", line);
}
});
// Timeout kill
const timer = setTimeout(() => {
if (!killed) {
killed = true;
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 3000);
}
}, timeout);
child.on("exit", (code) => {
clearTimeout(timer);
cleanupSandbox(sandboxProfilePath);
const output = stdout.join("") + (stderr.length > 0 ? "\n" + stderr.join("") : "");
if (killed) {
resolve({ success: false, output: `Command timed out after ${timeout}ms.\n${output}`.slice(0, 5000) });
} else if (code === 0) {
let out = output || "(no output)";
if (out.length > 30_000) {
out = out.slice(0, 30_000) + `\n\n... (truncated — ${output.length.toLocaleString()} chars total)`;
}
resolve({ success: true, output: out });
} else {
resolve({ success: false, output: `Exit code ${code ?? "?"}:\n${output}`.slice(0, 5000) });
}
});
child.on("error", (err) => {
clearTimeout(timer);
cleanupSandbox(sandboxProfilePath);
resolve({ success: false, output: `Spawn error: ${err.message}` });
});
});
}
// Background tool handlers
export function bashOutput(input: Record<string, unknown>): ToolResult {
const id = input.bash_id as string;
const filter = input.filter as string | undefined;
const result = readProcessOutput(id, { filter });
if ("error" in result) return { success: false, output: result.error };
const statusIcon = result.status === "running" ? "\u25CF" : result.status === "completed" ? "\u2713" : "\u2715";
const statusColor = result.status === "running" ? "running" : result.status === "completed" ? "completed" : "failed";
const lines: string[] = [];
lines.push(`${statusIcon} Process ${id} — ${statusColor}`);
if (result.exitCode !== undefined) lines.push(` Exit code: ${result.exitCode}`);
if (result.newOutput) {
lines.push(` Output:`);
lines.push(result.newOutput);
}
if (result.newErrors) {
lines.push(` Errors:`);
lines.push(result.newErrors);
}
if (!result.newOutput && !result.newErrors) lines.push(" (no new output since last check)");
return { success: true, output: lines.join("\n") };
}
export function killShell(input: Record<string, unknown>): ToolResult {
const id = (input.shell_id || input.bash_id) as string;
const result = killProcess(id);
return { success: result.success, output: result.message };
}
export function listShellsFn(): ToolResult {
const procs = listProcesses();
if (procs.length === 0) return { success: true, output: "No background processes." };
const lines: string[] = [`${procs.length} background process${procs.length !== 1 ? "es" : ""}:`, ""];
for (const p of procs) {
const icon = p.status === "running" ? "\u25CF" : p.status === "completed" ? "\u2713" : "\u2715";
lines.push(` ${icon} ${p.id} ${p.status} ${p.runtime}`);
lines.push(` ${p.command}`);
if (p.pid) lines.push(` PID: ${p.pid}`);
lines.push(` stdout: ${p.outputLines} lines stderr: ${p.errorLines} lines`);
lines.push("");
}
return { success: true, output: lines.join("\n") };
}