diff --git a/source/cli.tsx b/source/cli.tsx index fcabf84..5794289 100644 --- a/source/cli.tsx +++ b/source/cli.tsx @@ -42,6 +42,9 @@ const cli = meow( --truncate Max characters for tool output (default: 500) --truncate-lines Max lines for tool output (default: 10) --full-output Disable truncation (show full output) + --bash Enable local bash command execution (SECURITY: commands run on YOUR machine) + --auto-approve DANGEROUS: Auto-approve bash commands without confirmation + --bash-timeout Timeout for bash commands in ms (default: 30000) Create Options --name Assistant name (required) @@ -122,6 +125,18 @@ const cli = meow( tools: { type: 'string', }, + bash: { + type: 'boolean', + default: false, + }, + autoApprove: { + type: 'boolean', + default: false, + }, + bashTimeout: { + type: 'number', + default: 30_000, + }, }, }, ); @@ -191,6 +206,9 @@ async function main() { maxLength: cli.flags.truncate, maxLines: cli.flags.truncateLines, }, + enableBash: cli.flags.bash, + autoApprove: cli.flags.autoApprove, + bashTimeout: cli.flags.bashTimeout, }); break; } diff --git a/source/commands/chat.tsx b/source/commands/chat.tsx index 48b744a..0badd5b 100644 --- a/source/commands/chat.tsx +++ b/source/commands/chat.tsx @@ -25,7 +25,20 @@ import { StreamConnectionError, } from '../lib/services/stream-service.js'; import {truncate, type TruncateOptions} from '../lib/output/truncate.js'; -import {parseToolsFlag} from '../lib/tools.js'; +import {parseToolsFlag, buildToolsArray, isLocalTool} from '../lib/tools.js'; +import {useBashConsent} from '../hooks/use-bash-consent.js'; +import { + BashConsentPrompt, + BashBlockedPrompt, + BashResultDisplay, +} from '../components/bash-consent-prompt.js'; +import { + executeBash, + formatResultForLlm, + formatDeniedResult, + defaultTimeoutMs, + type BashToolResult, +} from '../lib/local-tools/index.js'; type ChatCommandProperties = { readonly message: string; @@ -34,6 +47,9 @@ type ChatCommandProperties = { readonly threadId?: string; readonly tools?: string[]; readonly truncateOptions?: TruncateOptions; + readonly isBashEnabled?: boolean; + readonly isAutoApprove?: boolean; + readonly bashTimeout?: number; }; /** @@ -140,11 +156,42 @@ function ChatCommandTui({ threadId, tools, truncateOptions, + isBashEnabled = false, + isAutoApprove = false, + bashTimeout = defaultTimeoutMs, }: Omit) { const {exit} = useApp(); const [config, setConfig] = useState(); const [authError, setAuthError] = useState(false); + // Bash consent state + const bashConsent = useBashConsent(); + + // Track pending bash tool call + const [pendingBashCall, setPendingBashCall] = useState< + {id: string; command: string} | undefined + >(undefined); + + // Track bash execution results for display + const [bashResults, setBashResults] = useState< + Array<{command: string; result: BashToolResult}> + >([]); + + // Track processed tool call IDs to avoid re-processing + const [processedToolCalls, setProcessedToolCalls] = useState>( + new Set(), + ); + + // Track conversation continuation request + const [continuationRequest, setContinuationRequest] = useState< + StreamRequest | undefined + >(); + + // Preserve thread ID across continuations (streamMetadata resets on new requests) + const [preservedThreadId, setPreservedThreadId] = useState< + string | undefined + >(); + // Load config on mount useEffect(() => { void loadConfig().then(cfg => { @@ -159,9 +206,9 @@ function ChatCommandTui({ }); }, [exit]); - // Build request (snake_case properties match backend API) + // Build initial request (snake_case properties match backend API) /* eslint-disable @typescript-eslint/naming-convention */ - const request = useMemo( + const initialRequest = useMemo( () => config ? { @@ -177,11 +224,146 @@ function ChatCommandTui({ ); /* eslint-enable @typescript-eslint/naming-convention */ + // Use continuation request if available, otherwise initial request + const activeRequest = continuationRequest ?? initialRequest; + // Stream - const {status, messages, events, streamMetadata, error} = useStream( - config, - request, - ); + const {status, messages, events, streamMetadata, finalResponse, error} = + useStream(config, activeRequest); + + // Preserve thread ID when we get it (survives across continuation resets) + useEffect(() => { + if (streamMetadata?.thread_id) { + setPreservedThreadId(streamMetadata.thread_id); + } + }, [streamMetadata?.thread_id]); + + // Use preserved thread ID or current streamMetadata + const displayThreadId = preservedThreadId ?? streamMetadata?.thread_id; + + // Detect bash_tool calls from finalResponse (complete tool_calls with full args) + useEffect(() => { + if (!isBashEnabled) return; + if (!finalResponse) return; // Wait for final response + if (pendingBashCall) return; // Already handling a call + if (bashConsent.isPending) return; // Waiting for consent + + // Look for tool_calls in the final response messages + for (const msg of finalResponse.messages) { + const toolCalls = msg['tool_calls'] as + | Array<{id: string; name: string; args: Record}> + | undefined; + if (!toolCalls) continue; + + for (const toolCall of toolCalls) { + // Skip already processed or non-local tool calls + if (processedToolCalls.has(toolCall.id)) continue; + if (!isLocalTool(toolCall.name)) continue; + + const command = String(toolCall.args?.['command'] ?? ''); + if (!command) continue; + + // Mark as being processed and trigger consent flow + setProcessedToolCalls(prev => new Set([...prev, toolCall.id])); + setPendingBashCall({id: toolCall.id, command}); + bashConsent.requestConsent(command, toolCall.id); + return; + } + } + }, [ + finalResponse, + isBashEnabled, + pendingBashCall, + bashConsent, + processedToolCalls, + ]); + + // Handle bash consent decisions + useEffect(() => { + if (!pendingBashCall) return; + + // Handle blocked commands + if (bashConsent.state.type === 'blocked') { + // Command was auto-blocked, continue conversation with denied result + const deniedResult = formatDeniedResult( + bashConsent.state.command, + bashConsent.state.reason, + ); + continueConversation(bashConsent.state.toolCallId, deniedResult); + setPendingBashCall(undefined); + bashConsent.reset(); + return; + } + + // Handle user decision + if (bashConsent.state.type === 'decided') { + if (bashConsent.state.response === 'approved') { + // Execute the command + void executeBashCommand( + bashConsent.state.command, + bashConsent.state.toolCallId, + ); + } else { + // User denied, continue conversation with denied result + const deniedResult = formatDeniedResult( + bashConsent.state.command, + bashConsent.state.reason ?? 'User denied execution', + ); + continueConversation(bashConsent.state.toolCallId, deniedResult); + } + + setPendingBashCall(undefined); + bashConsent.reset(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bashConsent.state, pendingBashCall]); + + // Auto-approve if enabled + useEffect(() => { + if (isAutoApprove && bashConsent.state.type === 'pending') { + bashConsent.approve(); + } + }, [isAutoApprove, bashConsent]); + + async function executeBashCommand(command: string, toolCallId: string) { + const result = await executeBash({ + command, + timeout: bashTimeout, + }); + + // Store result for display + setBashResults(prev => [...prev, {command, result}]); + + // Continue conversation with result + const formattedResult = formatResultForLlm(result); + continueConversation(toolCallId, formattedResult); + } + + function continueConversation(toolCallId: string, resultContent: string) { + if (!streamMetadata?.thread_id || !config) return; + + // Build continuation request with tool result + /* eslint-disable @typescript-eslint/naming-convention */ + const continuation: StreamRequest = { + input: { + messages: [ + { + role: 'tool' as const, + tool_call_id: toolCallId, + content: resultContent, + }, + ], + }, + tools, + metadata: { + ...(assistantId && {assistant_id: assistantId}), + thread_id: streamMetadata.thread_id, + }, + }; + /* eslint-enable @typescript-eslint/naming-convention */ + + setContinuationRequest(continuation); + } // Group messages into blocks by type + name boundaries const messageBlocks = useMemo( @@ -189,14 +371,46 @@ function ChatCommandTui({ [messages], ); - // Exit on completion + // Check if there are any unprocessed local tool calls in finalResponse + const hasUnprocessedToolCalls = useMemo(() => { + if (!isBashEnabled) return false; + if (!finalResponse) return false; + + for (const msg of finalResponse.messages) { + const toolCalls = msg['tool_calls'] as + | Array<{id: string; name: string; args: Record}> + | undefined; + if (!toolCalls) continue; + + for (const toolCall of toolCalls) { + if (processedToolCalls.has(toolCall.id)) continue; + if (isLocalTool(toolCall.name)) return true; + } + } + + return false; + }, [finalResponse, isBashEnabled, processedToolCalls]); + + // Exit on completion (only if not waiting for consent or executing bash) useEffect(() => { if (status === 'done' || status === 'error') { + // Don't exit if we're handling a bash tool call or have unprocessed ones + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (pendingBashCall || bashConsent.isPending || hasUnprocessedToolCalls) { + return; + } + setTimeout(() => { exit(); }, 100); } - }, [status, exit]); + }, [ + status, + exit, + pendingBashCall, + bashConsent.isPending, + hasUnprocessedToolCalls, + ]); // Auth error if (authError) { @@ -258,20 +472,54 @@ function ChatCommandTui({ ))} + {/* Bash execution results */} + {bashResults.map(bashResult => ( + + + + ))} + + {/* Bash consent prompt - only show when not auto-approving */} + {bashConsent.state.type === 'pending' && !isAutoApprove && ( + + + + )} + + {/* Bash blocked notification */} + {bashConsent.state.type === 'blocked' && ( + + + + )} + {/* Done indicator */} - {status === 'done' && ( + {status === 'done' && !pendingBashCall && !bashConsent.isPending && ( Done {extractModelFromEvents(events) && ( Model: {extractModelFromEvents(events)} )} - {streamMetadata?.thread_id && ( - Thread: {streamMetadata.thread_id} - )} - {streamMetadata?.thread_id && ( + {displayThreadId && Thread: {displayThreadId}} + {displayThreadId && ( - Continue: ruska chat -t {streamMetadata.thread_id}{' '} - "message" + Continue: ruska chat -t {displayThreadId} "message" )} @@ -384,6 +632,9 @@ function ChatCommand({ threadId, tools, truncateOptions, + isBashEnabled, + isAutoApprove, + bashTimeout, }: ChatCommandProperties) { const {exit} = useApp(); @@ -409,6 +660,9 @@ function ChatCommand({ threadId={threadId} tools={tools} truncateOptions={truncateOptions} + isBashEnabled={isBashEnabled} + isAutoApprove={isAutoApprove} + bashTimeout={bashTimeout} /> ); } @@ -424,6 +678,9 @@ export async function runChatCommand( threadId?: string; tools?: string; truncateOptions?: TruncateOptions; + enableBash?: boolean; + autoApprove?: boolean; + bashTimeout?: number; } = {}, ): Promise { // Auto-detect: use JSON mode if not TTY (piped) or explicitly requested @@ -432,14 +689,20 @@ export async function runChatCommand( // Parse tools flag (undefined = defaults, 'disabled' = [], 'a,b' = ['a', 'b']) const parsedTools = parseToolsFlag(options.tools); + // Build tools array with bash_tool if enabled + const tools = buildToolsArray(options.enableBash ?? false, parsedTools); + const {waitUntilExit} = render( , ); await waitUntilExit(); diff --git a/source/components/bash-consent-prompt.tsx b/source/components/bash-consent-prompt.tsx new file mode 100644 index 0000000..c7271ee --- /dev/null +++ b/source/components/bash-consent-prompt.tsx @@ -0,0 +1,233 @@ +/** + * React-Ink component for bash command consent prompt + * Displays command for user approval with warnings and keyboard handling + * @module components/bash-consent-prompt + */ + +import React from 'react'; +import {Text, Box, useInput} from 'ink'; +import type {CommandRisk} from '../lib/local-tools/index.js'; + +export type BashConsentPromptProperties = { + /** The bash command requiring approval */ + readonly command: string; + /** Risk level of the command */ + readonly risk: CommandRisk; + /** Warning messages to display */ + readonly warnings: readonly string[]; + /** Callback when user approves the command */ + readonly onApprove: () => void; + /** Callback when user denies the command */ + readonly onDeny: () => void; +}; + +/** + * Consent prompt component for bash command execution + * + * Displays: + * - Command in cyan with yellow border + * - Warnings in red if present + * - y/N prompt with default deny + * + * Keyboard handling: + * - y/Y: Approve command + * - n/N/Escape/Enter: Deny command (default) + */ +export function BashConsentPrompt({ + command, + risk, + warnings, + onApprove, + onDeny, +}: BashConsentPromptProperties) { + useInput((input, key) => { + if (input.toLowerCase() === 'y') { + onApprove(); + } else if ( + input.toLowerCase() === 'n' || + key.escape || + key.return // Enter defaults to deny + ) { + onDeny(); + } + }); + + // Determine border color based on risk + const borderColor = risk === 'dangerous' ? 'red' : 'yellow'; + const riskLabel = + risk === 'dangerous' ? 'DANGEROUS' : risk === 'moderate' ? 'WARNING' : ''; + + return ( + + {/* Header */} + + + Bash Command Approval Required + + {riskLabel && [{riskLabel}]} + + + {/* Command display */} + + Command: + + {command} + + + + {/* Warnings section */} + {warnings.length > 0 && ( + + + Warnings: + + {warnings.map(warning => ( + + • {warning} + + ))} + + )} + + {/* Prompt */} + + Execute this command? + + y + + / + + N + + (default: deny) + + + ); +} + +/** + * Component to display that a command was auto-blocked + */ +export type BashBlockedPromptProperties = { + /** The blocked command */ + readonly command: string; + /** Reason for blocking */ + readonly reason: string; +}; + +export function BashBlockedPrompt({ + command, + reason, +}: BashBlockedPromptProperties) { + return ( + + + + Command Blocked + + + + + Command: + + {command} + + + + + Reason: + {reason} + + + ); +} + +/** + * Component to display bash execution result + */ +export type BashResultDisplayProperties = { + /** The command that was executed */ + readonly command: string; + /** Exit code */ + readonly exitCode: number; + /** Standard output */ + readonly stdout: string; + /** Standard error */ + readonly stderr: string; + /** Whether the command timed out */ + readonly isTimedOut: boolean; + /** Execution time in ms */ + readonly executionTimeMs: number; +}; + +export function BashResultDisplay({ + command, + exitCode, + stdout, + stderr, + isTimedOut, + executionTimeMs, +}: BashResultDisplayProperties) { + const success = exitCode === 0; + const statusColor = success ? 'green' : 'red'; + const statusIcon = success ? '✓' : '✗'; + + return ( + + {/* Header */} + + + {statusIcon} Bash Execution Result + + {isTimedOut && [TIMED OUT]} + + + {/* Command echo */} + + $ + {command} + + + {/* Exit code and time */} + + Exit: + {exitCode} + | Time: {executionTimeMs}ms + + + {/* stdout */} + {stdout && ( + + ─── stdout ─── + {stdout} + + )} + + {/* stderr */} + {stderr && ( + + ─── stderr ─── + {stderr} + + )} + + {/* No output message */} + {!stdout && !stderr && (No output)} + + ); +} diff --git a/source/hooks/use-bash-consent.ts b/source/hooks/use-bash-consent.ts new file mode 100644 index 0000000..a367ca9 --- /dev/null +++ b/source/hooks/use-bash-consent.ts @@ -0,0 +1,172 @@ +/** + * React hook for managing bash command consent flow + * Implements a state machine for user approval of local command execution + * @module hooks/use-bash-consent + */ + +import {useState, useCallback} from 'react'; +import { + validateCommand, + assessCommandRisk, + type CommandRisk, +} from '../lib/local-tools/index.js'; + +/** + * Consent state when no command is pending + */ +export type ConsentStateIdle = { + type: 'idle'; +}; + +/** + * Consent state when waiting for user decision + */ +export type ConsentStatePending = { + type: 'pending'; + command: string; + toolCallId: string; + risk: CommandRisk; + warnings: string[]; +}; + +/** + * Consent state after user has made a decision + */ +export type ConsentStateDecided = { + type: 'decided'; + command: string; + toolCallId: string; + response: ConsentResponse; + reason?: string; +}; + +/** + * Consent state when command was auto-denied (blocked) + */ +export type ConsentStateBlocked = { + type: 'blocked'; + command: string; + toolCallId: string; + reason: string; +}; + +export type ConsentState = + | ConsentStateIdle + | ConsentStatePending + | ConsentStateDecided + | ConsentStateBlocked; + +export type ConsentResponse = 'approved' | 'denied'; + +export type UseBashConsentResult = { + /** Current consent state */ + state: ConsentState; + /** Request user consent for a command */ + requestConsent: (command: string, toolCallId: string) => void; + /** Approve the pending command */ + approve: () => void; + /** Deny the pending command */ + deny: (reason?: string) => void; + /** Reset to idle state */ + reset: () => void; + /** Check if a command is pending approval */ + isPending: boolean; + /** Check if a decision was made (approved or denied) */ + isDecided: boolean; +}; + +/** + * Hook for managing bash command consent flow + * + * State machine: + * - idle: No command pending + * - pending: Command awaiting user decision (shows consent prompt) + * - decided: User approved or denied the command + * - blocked: Command auto-denied due to blocklist + * + * @example + * const { state, requestConsent, approve, deny, reset } = useBashConsent(); + * + * // When tool call detected + * requestConsent('ls -la', 'tool-call-123'); + * + * // In UI, check state.type and render appropriate UI + * if (state.type === 'pending') { + * // Show consent prompt with state.command, state.warnings + * } + */ +export function useBashConsent(): UseBashConsentResult { + const [state, setState] = useState({type: 'idle'}); + + const requestConsent = useCallback((command: string, toolCallId: string) => { + // Validate command first + const validation = validateCommand(command); + + if (!validation.valid) { + // Auto-deny blocked commands without prompting + setState({ + type: 'blocked', + command, + toolCallId, + reason: validation.reason, + }); + return; + } + + // Command is valid, request user consent + const risk = assessCommandRisk(command); + + setState({ + type: 'pending', + command, + toolCallId, + risk, + warnings: validation.warnings, + }); + }, []); + + const approve = useCallback(() => { + setState(current => { + if (current.type !== 'pending') { + return current; + } + + return { + type: 'decided', + command: current.command, + toolCallId: current.toolCallId, + response: 'approved', + }; + }); + }, []); + + const deny = useCallback((reason?: string) => { + setState(current => { + if (current.type !== 'pending') { + return current; + } + + return { + type: 'decided', + command: current.command, + toolCallId: current.toolCallId, + response: 'denied', + reason, + }; + }); + }, []); + + const reset = useCallback(() => { + setState({type: 'idle'}); + }, []); + + return { + state, + requestConsent, + approve, + deny, + reset, + isPending: state.type === 'pending', + isDecided: state.type === 'decided' || state.type === 'blocked', + }; +} diff --git a/source/lib/local-tools/bash-executor.ts b/source/lib/local-tools/bash-executor.ts new file mode 100644 index 0000000..18193a6 --- /dev/null +++ b/source/lib/local-tools/bash-executor.ts @@ -0,0 +1,244 @@ +/** + * Bash command executor for local tool execution + * @module local-tools/bash-executor + */ + +import process from 'node:process'; +import {spawn} from 'node:child_process'; +import {platform} from 'node:os'; +import { + defaultTimeoutMs, + maxOutputSize, + type BashExecutionOptions, + type BashToolResult, +} from './types.js'; +import {validateCommand, isInteractiveCommand} from './security.js'; + +/** + * Execute a bash command locally with safety controls + * + * @param options - Execution options including command, cwd, timeout + * @returns Promise resolving to execution result + * @throws Error if command is blocked or on Windows without bash + * + * @example + * const result = await executeBash({ command: 'ls -la' }); + * console.log(result.stdout); + */ +export async function executeBash( + options: BashExecutionOptions, +): Promise { + const { + command, + cwd = process.cwd(), + timeout = defaultTimeoutMs, + maxOutput = maxOutputSize, + } = options; + + const startTime = Date.now(); + + // Validate command before execution + const validation = validateCommand(command); + if (!validation.valid) { + return { + stdout: '', + stderr: `Command blocked: ${validation.reason}`, + exitCode: 1, + timedOut: false, + truncated: false, + executionTimeMs: Date.now() - startTime, + }; + } + + // Check for interactive commands + if (isInteractiveCommand(command)) { + return { + stdout: '', + stderr: + 'Interactive commands are not supported. This command requires user input which cannot be provided in this environment.', + exitCode: 1, + timedOut: false, + truncated: false, + executionTimeMs: Date.now() - startTime, + }; + } + + // Platform check - only support Unix-like systems with bash + if (platform() === 'win32') { + return { + stdout: '', + stderr: + 'Bash execution is not supported on Windows. Consider using WSL (Windows Subsystem for Linux) or Git Bash.', + exitCode: 1, + timedOut: false, + truncated: false, + executionTimeMs: Date.now() - startTime, + }; + } + + return new Promise(resolve => { + const stdoutChunks: Uint8Array[] = []; + const stderrChunks: Uint8Array[] = []; + let stdoutSize = 0; + let stderrSize = 0; + let truncated = false; + let timedOut = false; + let killed = false; + + // Spawn bash with command + const child = spawn('bash', ['-c', command], { + cwd, + env: {...process.env}, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + // Handle stdout with size limiting + child.stdout.on('data', (chunk: Uint8Array) => { + if (stdoutSize + chunk.length <= maxOutput) { + stdoutChunks.push(chunk); + stdoutSize += chunk.length; + } else if (!truncated) { + // Add partial chunk up to limit + const remaining = maxOutput - stdoutSize; + if (remaining > 0) { + stdoutChunks.push(chunk.subarray(0, remaining)); + stdoutSize = maxOutput; + } + + truncated = true; + } + }); + + // Handle stderr with size limiting + child.stderr.on('data', (chunk: Uint8Array) => { + if (stderrSize + chunk.length <= maxOutput) { + stderrChunks.push(chunk); + stderrSize += chunk.length; + } else if (!truncated) { + const remaining = maxOutput - stderrSize; + if (remaining > 0) { + stderrChunks.push(chunk.subarray(0, remaining)); + stderrSize = maxOutput; + } + + truncated = true; + } + }); + + // Timeout handler with SIGTERM → SIGKILL escalation + const timeoutId = setTimeout(() => { + timedOut = true; + killed = true; + + // Try SIGTERM first (graceful shutdown) + child.kill('SIGTERM'); + + // Force kill after 2 seconds if still running + setTimeout(() => { + if (!child.killed) { + child.kill('SIGKILL'); + } + }, 2000); + }, timeout); + + // Handle process completion + child.on('close', (code, signal) => { + clearTimeout(timeoutId); + + const stdout = Buffer.concat(stdoutChunks).toString('utf8'); + const stderr = Buffer.concat(stderrChunks).toString('utf8'); + + // Add context to stderr if killed + let finalStderr = stderr; + if (timedOut) { + finalStderr = + `${stderr}\n[Process killed: timeout exceeded (${timeout}ms)]`.trim(); + } else if (killed && signal) { + finalStderr = + `${stderr}\n[Process terminated by signal: ${signal}]`.trim(); + } + + resolve({ + stdout, + stderr: finalStderr, + exitCode: code ?? (signal ? 128 : 1), + timedOut, + truncated, + executionTimeMs: Date.now() - startTime, + }); + }); + + // Handle spawn errors + child.on('error', (error_: Error) => { + clearTimeout(timeoutId); + resolve({ + stdout: '', + stderr: `Failed to execute command: ${error_.message}`, + exitCode: 1, + timedOut: false, + truncated: false, + executionTimeMs: Date.now() - startTime, + }); + }); + }); +} + +/** + * Format a bash execution result for LLM consumption + * + * @param result - The execution result to format + * @returns Formatted string suitable for LLM context + * + * @example + * const result = await executeBash({ command: 'ls -la' }); + * const formatted = formatResultForLlm(result); + * // "Exit code: 0\n\n--- STDOUT ---\nfile1.txt\nfile2.txt\n" + */ +export function formatResultForLlm(result: BashToolResult): string { + const parts: string[] = [ + `Exit code: ${result.exitCode}`, + ...(result.timedOut ? ['Status: TIMED OUT'] : []), + ...(result.truncated + ? ['Note: Output was truncated due to size limits'] + : []), + `Execution time: ${result.executionTimeMs}ms`, + '', + ]; + + // Stdout section + if (result.stdout) { + parts.push('--- STDOUT ---', result.stdout, ''); + } + + // Stderr section + if (result.stderr) { + parts.push('--- STDERR ---', result.stderr, ''); + } + + // Empty output message + if (!result.stdout && !result.stderr) { + parts.push('(No output)'); + } + + return parts.join('\n').trim(); +} + +/** + * Format a denied command result for LLM consumption + * + * @param command - The command that was denied + * @param reason - The reason for denial (optional) + * @returns Formatted string informing LLM of denial + */ +export function formatDeniedResult(command: string, reason?: string): string { + const parts = [ + 'Command execution was DENIED by the user.', + '', + `Command: ${command}`, + ...(reason ? [`Reason: ${reason}`] : []), + '', + 'Please suggest an alternative approach or ask the user for guidance.', + ]; + + return parts.join('\n'); +} diff --git a/source/lib/local-tools/index.ts b/source/lib/local-tools/index.ts new file mode 100644 index 0000000..c9b6cf6 --- /dev/null +++ b/source/lib/local-tools/index.ts @@ -0,0 +1,31 @@ +/** + * Local tools module for CLI-side tool execution + * @module local-tools + */ + +// Re-export types +export { + type BashToolRequest, + type BashToolResult, + type BashExecutionOptions, + type CommandRisk, + type ValidationResult, + defaultTimeoutMs, + maxOutputSize, + blockedCommands, + warningPatterns, +} from './types.js'; + +// Re-export security functions +export { + validateCommand, + assessCommandRisk, + isInteractiveCommand, +} from './security.js'; + +// Re-export executor functions +export { + executeBash, + formatResultForLlm, + formatDeniedResult, +} from './bash-executor.js'; diff --git a/source/lib/local-tools/security.ts b/source/lib/local-tools/security.ts new file mode 100644 index 0000000..02a66bf --- /dev/null +++ b/source/lib/local-tools/security.ts @@ -0,0 +1,170 @@ +/** + * Security module for bash command validation + * @module local-tools/security + */ + +import { + blockedCommands, + warningPatterns, + type CommandRisk, + type ValidationResult, +} from './types.js'; + +/** + * Normalize a command string for comparison + * Removes extra whitespace and converts to lowercase for pattern matching + */ +function normalizeCommand(command: string): string { + // eslint-disable-next-line unicorn/prefer-string-replace-all + return command.replace(/\s+/g, ' ').trim().toLowerCase(); +} + +/** + * Check if command matches any blocked pattern + */ +function matchesBlockedPattern(command: string): string | undefined { + const normalized = normalizeCommand(command); + + for (const blocked of blockedCommands) { + const normalizedBlocked = normalizeCommand(blocked); + if (normalized.includes(normalizedBlocked)) { + return blocked; + } + } + + // Additional heuristic checks for fork bomb variations + if (/:\(\)\s*{.*\|.*&\s*}.*:/.test(command)) { + return 'fork bomb pattern'; + } + + // Check for pipe-to-shell patterns with URLs + if (/\b(curl|wget)\b.*\|\s*(sh|bash|zsh)/.test(command)) { + return 'pipe to shell pattern'; + } + + // Check for destructive dd commands + if (/\bdd\b.*of=\/dev\/[a-z]+/.test(command)) { + return 'direct disk write'; + } + + return undefined; +} + +/** + * Get warnings for potentially risky commands + */ +function getCommandWarnings(command: string): string[] { + const warnings: string[] = []; + + for (const pattern of warningPatterns) { + if (pattern.test(command)) { + // Generate human-readable warning based on pattern + if (/\bsudo\b/.test(command)) { + warnings.push('Uses sudo - requires elevated privileges'); + } else if (/chmod\s+777/.test(command)) { + warnings.push( + 'chmod 777 makes files world-readable/writable/executable', + ); + } else if (/rm\s+-r/.test(command)) { + warnings.push('Recursive deletion - verify target carefully'); + } else if (/git\s+(push.*--force|reset\s+--hard)/.test(command)) { + warnings.push('Destructive git operation - may lose commits'); + } else if (/kill|pkill|killall/.test(command)) { + warnings.push('Process termination command'); + } else if (/\.(env|ssh|credentials|aws)/i.test(command)) { + warnings.push('Accesses potentially sensitive files'); + } else if (/apt|yum|npm\s+-g|pip.*--system/.test(command)) { + warnings.push('System-wide package operation'); + } else { + warnings.push(`Matches warning pattern: ${pattern.source}`); + } + } + } + + // Deduplicate warnings + return [...new Set(warnings)]; +} + +/** + * Validate a command for execution safety + * + * @param command - The bash command to validate + * @returns Validation result with either approval + warnings, or rejection + reason + * + * @example + * validateCommand('ls -la') + * // { valid: true, warnings: [] } + * + * validateCommand('rm -rf /') + * // { valid: false, reason: 'Blocked: rm -rf /' } + * + * validateCommand('sudo apt update') + * // { valid: true, warnings: ['Uses sudo - requires elevated privileges'] } + */ +export function validateCommand(command: string): ValidationResult { + // Check for empty command + if (!command || command.trim() === '') { + return {valid: false, reason: 'Empty command'}; + } + + // Check against blocklist + const blockedMatch = matchesBlockedPattern(command); + if (blockedMatch) { + return {valid: false, reason: `Blocked: ${blockedMatch}`}; + } + + // Get warnings for risky patterns + const warnings = getCommandWarnings(command); + + return {valid: true, warnings}; +} + +/** + * Assess the overall risk level of a command + * + * @param command - The bash command to assess + * @returns Risk level: 'safe', 'moderate', or 'dangerous' + * + * @example + * assessCommandRisk('ls -la') // 'safe' + * assessCommandRisk('sudo apt update') // 'moderate' + * assessCommandRisk('rm -rf /') // 'dangerous' + */ +export function assessCommandRisk(command: string): CommandRisk { + const validation = validateCommand(command); + + // Blocked commands are dangerous + if (!validation.valid) { + return 'dangerous'; + } + + // Commands with warnings are moderate risk + if (validation.warnings.length > 0) { + return 'moderate'; + } + + // No issues detected + return 'safe'; +} + +/** + * Check if a command appears to be interactive (would require user input) + * These commands may hang waiting for input + */ +export function isInteractiveCommand(command: string): boolean { + const interactivePatterns = [ + /\bvim?\b/, + /\bnano\b/, + /\bemacs\b/, + /\bless\b(?!\s+-)/, + /\bmore\b(?!\s+-)/, + /\bread\b\s+-p/, + /\bssh\b(?!.*-o\s*BatchMode)/, + /\bsudo\s+-S/, + /\bpasswd\b/, + /\btop\b$/, + /\bhtop\b$/, + ]; + + return interactivePatterns.some(pattern => pattern.test(command)); +} diff --git a/source/lib/local-tools/types.ts b/source/lib/local-tools/types.ts new file mode 100644 index 0000000..7ae7a67 --- /dev/null +++ b/source/lib/local-tools/types.ts @@ -0,0 +1,152 @@ +/** + * Type definitions for local bash tool execution + * @module local-tools/types + */ + +/** + * Request to execute a bash command locally + */ +export type BashToolRequest = { + /** The bash command to execute */ + command: string; + /** Working directory for command execution (defaults to cwd) */ + cwd?: string; + /** Timeout in milliseconds (defaults to defaultTimeoutMs) */ + timeout?: number; +}; + +/** + * Result of executing a bash command + */ +export type BashToolResult = { + /** Standard output from the command */ + stdout: string; + /** Standard error from the command */ + stderr: string; + /** Exit code of the process (0 = success) */ + exitCode: number; + /** Whether the command was killed due to timeout */ + timedOut: boolean; + /** Whether output was truncated due to size limits */ + truncated: boolean; + /** Actual execution time in milliseconds */ + executionTimeMs: number; +}; + +/** + * Options for bash execution + */ +export type BashExecutionOptions = { + /** The bash command to execute */ + command: string; + /** Working directory for command execution */ + cwd?: string; + /** Timeout in milliseconds */ + timeout?: number; + /** Maximum output size in bytes */ + maxOutput?: number; +}; + +/** + * Default timeout for bash command execution (30 seconds) + */ +export const defaultTimeoutMs = 30_000; + +/** + * Maximum output size to capture (1MB) + * Prevents memory exhaustion from large outputs + */ +export const maxOutputSize = 1_048_576; + +/** + * Commands that are always blocked due to high risk of system damage + * These patterns will auto-deny without prompting the user + */ +export const blockedCommands: readonly string[] = [ + // Catastrophic deletion patterns + 'rm -rf /', + 'rm -rf /*', + 'rm -rf ~', + 'rm -rf ~/*', + 'rm -rf .', + 'rm -rf ./*', + 'rm -rf $HOME', + 'rm -rf "$HOME"', + // Fork bombs + ':(){ :|:& };:', + 'fork()', + // Pipe to shell (potential for downloading and executing malicious code) + 'curl|sh', + 'curl|bash', + 'wget|sh', + 'wget|bash', + 'curl | sh', + 'curl | bash', + 'wget | sh', + 'wget | bash', + // Direct disk writes + '> /dev/sda', + '> /dev/hda', + '> /dev/nvme', + 'dd if=/dev/zero of=/dev/sda', + 'dd if=/dev/zero of=/dev/hda', + // System critical file overwrites + '> /etc/passwd', + '> /etc/shadow', + '> /etc/sudoers', + // Dangerous chmod patterns + 'chmod -R 777 /', + 'chmod -R 777 /*', +] as const; + +/** + * Patterns that trigger warnings but are not auto-blocked + * User will see elevated warnings for these commands + */ +export const warningPatterns: readonly RegExp[] = [ + // Sudo commands require elevated privileges + /\bsudo\b/, + // Potentially dangerous file permission changes + /chmod\s+777/, + /chmod\s+-R/, + // Parent directory recursive deletion + /rm\s+-rf*\s+\.\./, + /rm\s+-[a-z]*r[a-z]*\s+\.\./, + // System directories + /rm\s+.*\/etc\//, + /rm\s+.*\/usr\//, + /rm\s+.*\/var\//, + /rm\s+.*\/bin\//, + /rm\s+.*\/sbin\//, + // Credential/config file access + /cat\s+.*\.env/, + /cat\s+.*\.ssh\//, + /cat\s+.*credentials/i, + /cat\s+.*\.aws\//, + // Git force operations + /git\s+push\s+.*--force/, + /git\s+push\s+-f\b/, + /git\s+reset\s+--hard/, + // Package manager system-wide operations + /npm\s+.*-g\b/, + /pip\s+install\s+--system/, + /apt\s+remove/, + /apt-get\s+remove/, + /yum\s+remove/, + // Process killing + /kill\s+-9/, + /killall/, + /pkill/, +] as const; + +/** + * Command risk levels for display purposes + */ +export type CommandRisk = 'safe' | 'moderate' | 'dangerous'; + +/** + * Result of command validation + */ +export type ValidationResult = + | {valid: true; warnings: string[]} + | {valid: false; reason: string}; diff --git a/source/lib/tools.ts b/source/lib/tools.ts index c30e531..ab89b06 100644 --- a/source/lib/tools.ts +++ b/source/lib/tools.ts @@ -17,6 +17,21 @@ export const defaultAgentTools = [ export type DefaultAgentTool = (typeof defaultAgentTools)[number]; +/** + * Local tools that execute on the CLI side, not the backend + * These tools are intercepted before reaching the server + */ +export const localTools = ['bash_tool'] as const; + +export type LocalTool = (typeof localTools)[number]; + +/** + * Type guard to check if a tool name is a local tool + */ +export function isLocalTool(name: string): name is LocalTool { + return (localTools as readonly string[]).includes(name); +} + /** * Parse --tools flag value into array of tool names * @@ -46,3 +61,24 @@ export function parseToolsFlag(value: string | undefined): string[] { .map(t => t.trim()) .filter(Boolean); } + +/** + * Build tools array for stream request + * Optionally includes bash_tool if enabled + * + * @param enableBash - Whether to include bash_tool + * @param baseTools - Base tools array (defaults to parseToolsFlag result) + * @returns Array of tool names including bash_tool if enabled + */ +export function buildToolsArray( + enableBash: boolean, + baseTools?: string[], +): string[] { + const tools = baseTools ?? [...defaultAgentTools]; + + if (enableBash && !tools.includes('bash_tool')) { + return [...tools, 'bash_tool']; + } + + return tools; +} diff --git a/source/types/stream.ts b/source/types/stream.ts index 951cdbe..d68d24f 100644 --- a/source/types/stream.ts +++ b/source/types/stream.ts @@ -154,13 +154,30 @@ export type StreamEvent = | {type: 'metadata'; payload: MetadataPayload} | {type: 'done'; payload: undefined}; +/** + * Tool result message for continuing conversation after local tool execution + * Follows Anthropic's tool_result format + */ +export type ToolResultMessage = { + role: 'tool'; + tool_call_id: string; + content: string; +}; + +/** + * Message types that can be sent in a stream request + */ +export type StreamMessage = + | {role: 'user' | 'assistant' | 'system'; content: string} + | ToolResultMessage; + /** * Request body for /llm/stream endpoint * @see backend/src/schemas/entities/llm.py:LLMRequest */ export type StreamRequest = { input: { - messages: Array<{role: 'user' | 'assistant' | 'system'; content: string}>; + messages: StreamMessage[]; files?: Record; }; model?: string;