import { Type } from '@sinclair/typebox'; import type { ToolDefinition } from '@mariozechner/pi-coding-agent'; import { spawn } from 'node:child_process'; import { guardPath, SandboxEscapeError } from './path-guard.js'; const DEFAULT_TIMEOUT_MS = 30_000; const MAX_OUTPUT_BYTES = 100 * 1024; // 100 KB /** * Commands that are outright blocked for safety. * This is a denylist; the agent should be instructed to use * the least-privilege command necessary. */ const BLOCKED_COMMANDS = new Set([ 'rm', 'rmdir', 'mkfs', 'dd', 'format', 'fdisk', 'parted', 'shred', 'wipefs', 'sudo', 'su', 'chown', 'chmod', 'passwd', 'useradd', 'userdel', 'groupadd', 'shutdown', 'reboot', 'halt', 'poweroff', 'kill', 'killall', 'pkill', 'curl', 'wget', 'nc', 'netcat', 'ncat', 'ssh', 'scp', 'sftp', 'rsync', 'iptables', 'ip6tables', 'nft', 'ufw', 'firewall-cmd', 'docker', 'podman', 'kubectl', 'helm', 'terraform', 'ansible', 'crontab', 'at', 'batch', ]); function extractBaseCommand(command: string): string { // Extract the first word (the binary name), stripping path const trimmed = command.trim(); const firstToken = trimmed.split(/\s+/)[0] ?? ''; return firstToken.split('/').pop() ?? firstToken; } function runCommand( command: string, options: { timeoutMs: number; cwd?: string }, ): Promise<{ stdout: string; stderr: string; exitCode: number | null; timedOut: boolean }> { return new Promise((resolve) => { const child = spawn('sh', ['-c', command], { cwd: options.cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: false, }); let stdout = ''; let stderr = ''; let timedOut = false; let totalBytes = 0; let truncated = false; child.stdout?.on('data', (chunk: Buffer) => { if (truncated) return; totalBytes += chunk.length; if (totalBytes > MAX_OUTPUT_BYTES) { stdout += chunk.subarray(0, MAX_OUTPUT_BYTES - (totalBytes - chunk.length)).toString(); stdout += '\n[output truncated at 100 KB limit]'; truncated = true; child.kill('SIGTERM'); } else { stdout += chunk.toString(); } }); child.stderr?.on('data', (chunk: Buffer) => { if (stderr.length < MAX_OUTPUT_BYTES) { stderr += chunk.toString(); } }); const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); setTimeout(() => { try { child.kill('SIGKILL'); } catch { // already exited } }, 2000); }, options.timeoutMs); child.on('close', (exitCode) => { clearTimeout(timer); resolve({ stdout, stderr, exitCode, timedOut }); }); child.on('error', (err) => { clearTimeout(timer); resolve({ stdout, stderr: stderr + String(err), exitCode: null, timedOut: false }); }); }); } export function createShellTools(sandboxDir?: string): ToolDefinition[] { const defaultCwd = sandboxDir ?? process.cwd(); const shellExec: ToolDefinition = { name: 'shell_exec', label: 'Shell Execute', description: 'Execute a shell command with timeout and output limits. Dangerous commands (rm, sudo, docker, etc.) are blocked. Working directory is restricted to the session sandbox.', parameters: Type.Object({ command: Type.String({ description: 'Shell command to execute' }), cwd: Type.Optional( Type.String({ description: 'Working directory for the command (relative to sandbox or absolute within it).', }), ), timeout: Type.Optional( Type.Number({ description: 'Timeout in milliseconds (default 30000, max 60000)' }), ), }), async execute(_toolCallId, params) { const { command, cwd, timeout } = params as { command: string; cwd?: string; timeout?: number; }; const base = extractBaseCommand(command); if (BLOCKED_COMMANDS.has(base)) { return { content: [ { type: 'text' as const, text: `Error: command "${base}" is blocked for safety reasons.`, }, ], details: undefined, }; } const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 60_000); let safeCwd: string; try { safeCwd = guardPath(cwd ?? '.', defaultCwd); } catch (err) { if (err instanceof SandboxEscapeError) { return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], details: undefined, }; } return { content: [{ type: 'text' as const, text: `Error: ${String(err)}` }], details: undefined, }; } const result = await runCommand(command, { timeoutMs, cwd: safeCwd, }); if (result.timedOut) { return { content: [ { type: 'text' as const, text: `Command timed out after ${timeoutMs}ms.\nPartial stdout:\n${result.stdout}\nPartial stderr:\n${result.stderr}`, }, ], details: undefined, }; } const parts: string[] = []; if (result.stdout) parts.push(`stdout:\n${result.stdout}`); if (result.stderr) parts.push(`stderr:\n${result.stderr}`); parts.push(`exit code: ${result.exitCode ?? 'null'}`); return { content: [{ type: 'text' as const, text: parts.join('\n') }], details: undefined, }; }, }; return [shellExec]; }