feat: Add intelligent auto-router and enhanced integrations

- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
/**
* Bash Execute Tool
*
* Internal tool for executing shell commands.
* Approval is handled at the ToolManager level with pattern-based approval.
*/
import * as path from 'node:path';
import { z } from 'zod';
import { InternalTool, ToolExecutionContext } from '@dexto/core';
import { ProcessService } from './process-service.js';
import { ProcessError } from './errors.js';
import type { ShellDisplayData } from '@dexto/core';
const BashExecInputSchema = z
.object({
command: z.string().describe('Shell command to execute'),
description: z
.string()
.optional()
.describe('Human-readable description of what the command does (5-10 words)'),
timeout: z
.number()
.int()
.positive()
.max(600000)
.optional()
.default(120000)
.describe(
'Timeout in milliseconds (max: 600000 = 10 minutes, default: 120000 = 2 minutes)'
),
run_in_background: z
.boolean()
.optional()
.default(false)
.describe('Execute command in background (default: false)'),
cwd: z.string().optional().describe('Working directory for command execution (optional)'),
})
.strict();
type BashExecInput = z.input<typeof BashExecInputSchema>;
/**
* Create the bash_exec internal tool
*/
export function createBashExecTool(processService: ProcessService): InternalTool {
return {
id: 'bash_exec',
description: `Execute a shell command in the project root directory.
IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. Do NOT use it for file operations - use the specialized tools instead:
- File search: Use glob_files (NOT find or ls)
- Content search: Use grep_content (NOT grep or rg)
- Read files: Use read_file (NOT cat/head/tail)
- Edit files: Use edit_file (NOT sed/awk)
- Write files: Use write_file (NOT echo/cat with heredoc)
Before executing the command, follow these steps:
1. Directory Verification:
- If the command will create new directories or files, first use ls to verify the parent directory exists and is the correct location
- For example, before running "mkdir foo/bar", first use ls foo to check that "foo" exists
2. Command Execution:
- Always quote file paths that contain spaces with double quotes
- Examples of proper quoting:
- cd "/Users/name/My Documents" (correct)
- cd /Users/name/My Documents (incorrect - will fail)
- python "/path/with spaces/script.py" (correct)
- python /path/with spaces/script.py (incorrect - will fail)
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds (max 600000ms / 10 minutes). Default is 120000ms (2 minutes).
- The description parameter should be a clear, concise summary of what the command does (5-10 words for simple commands, more context for complex commands).
- If the output exceeds 1MB, it will be truncated.
- You can use run_in_background=true to run the command in the background. Use this when you don't need the result immediately. You do not need to check the output right away - use bash_output to retrieve results later. Commands ending with & are blocked; use run_in_background instead.
When issuing multiple commands:
- If the commands are independent and can run in parallel, make multiple bash_exec calls in a single response.
- If the commands depend on each other and must run sequentially, use a single call with && to chain them (e.g., git add . && git commit -m "msg" && git push).
- Use ; only when you need to run commands sequentially but don't care if earlier commands fail.
- Do NOT use newlines to separate commands (newlines are ok in quoted strings).
Try to maintain your working directory throughout the session by using absolute paths and avoiding usage of cd. You may use cd if the user explicitly requests it.
- GOOD: pnpm test
- GOOD: pytest /absolute/path/to/tests
- BAD: cd /project && pnpm test
- BAD: cd /some/path && command
Each command runs in a fresh shell, so cd does not persist between calls.
Security: Dangerous commands are blocked. Injection attempts are detected. Requires approval with pattern-based session memory.`,
inputSchema: BashExecInputSchema,
/**
* Generate preview for approval UI - shows the command to be executed
*/
generatePreview: async (input: unknown, _context?: ToolExecutionContext) => {
const { command, run_in_background } = input as BashExecInput;
const preview: ShellDisplayData = {
type: 'shell',
command,
exitCode: 0, // Placeholder - not executed yet
duration: 0, // Placeholder - not executed yet
...(run_in_background !== undefined && { isBackground: run_in_background }),
};
return preview;
},
execute: async (input: unknown, context?: ToolExecutionContext) => {
// Input is validated by provider before reaching here
const { command, description, timeout, run_in_background, cwd } =
input as BashExecInput;
// Validate cwd to prevent path traversal
let validatedCwd: string | undefined = cwd;
if (cwd) {
const baseDir = processService.getConfig().workingDirectory || process.cwd();
// Resolve cwd to absolute path
const candidatePath = path.isAbsolute(cwd)
? path.resolve(cwd)
: path.resolve(baseDir, cwd);
// Check if cwd is within the base directory
const relativePath = path.relative(baseDir, candidatePath);
const isOutsideBase =
relativePath.startsWith('..') || path.isAbsolute(relativePath);
if (isOutsideBase) {
throw ProcessError.invalidWorkingDirectory(
cwd,
`Working directory must be within ${baseDir}`
);
}
validatedCwd = candidatePath;
}
// Execute command using ProcessService
// Note: Approval is handled at ToolManager level with pattern-based approval
const result = await processService.executeCommand(command, {
description,
timeout,
runInBackground: run_in_background,
cwd: validatedCwd,
// Pass abort signal for cancellation support
abortSignal: context?.abortSignal,
});
// Type guard: if result has 'stdout', it's a ProcessResult (foreground)
// Otherwise it's a ProcessHandle (background)
if ('stdout' in result) {
// Foreground execution result
const _display: ShellDisplayData = {
type: 'shell',
command,
exitCode: result.exitCode,
duration: result.duration,
isBackground: false,
stdout: result.stdout,
stderr: result.stderr,
};
return {
stdout: result.stdout,
stderr: result.stderr,
exit_code: result.exitCode,
duration: result.duration,
_display,
};
} else {
// Background execution handle
const _display: ShellDisplayData = {
type: 'shell',
command,
exitCode: 0, // Background process hasn't exited yet
duration: 0, // Still running
isBackground: true,
};
return {
process_id: result.processId,
message: `Command started in background with ID: ${result.processId}. Use bash_output to retrieve output.`,
_display,
};
}
},
};
}

View File

@@ -0,0 +1,44 @@
/**
* Bash Output Tool
*
* Internal tool for retrieving output from background processes
*/
import { z } from 'zod';
import { InternalTool, ToolExecutionContext } from '@dexto/core';
import { ProcessService } from './process-service.js';
const BashOutputInputSchema = z
.object({
process_id: z.string().describe('Process ID from bash_exec (when run_in_background=true)'),
})
.strict();
type BashOutputInput = z.input<typeof BashOutputInputSchema>;
/**
* Create the bash_output internal tool
*/
export function createBashOutputTool(processService: ProcessService): InternalTool {
return {
id: 'bash_output',
description:
'Retrieve output from a background process started with bash_exec. Returns stdout, stderr, status (running/completed/failed), exit code, and duration. Each call returns only new output since last read. The output buffer is cleared after reading. Use this tool to monitor long-running commands.',
inputSchema: BashOutputInputSchema,
execute: async (input: unknown, _context?: ToolExecutionContext) => {
// Input is validated by provider before reaching here
const { process_id } = input as BashOutputInput;
// Get output from ProcessService
const result = await processService.getProcessOutput(process_id);
return {
stdout: result.stdout,
stderr: result.stderr,
status: result.status,
...(result.exitCode !== undefined && { exit_code: result.exitCode }),
...(result.duration !== undefined && { duration: result.duration }),
};
},
};
}

View File

@@ -0,0 +1,472 @@
/**
* Command Validator
*
* Security-focused command validation for process execution
*/
import { ProcessConfig, CommandValidation } from './types.js';
import type { IDextoLogger } from '@dexto/core';
const MAX_COMMAND_LENGTH = 10000; // 10K characters
// Dangerous command patterns that should be blocked
// Validated against common security vulnerabilities and dangerous command patterns
const DANGEROUS_PATTERNS = [
// File system destruction
/rm\s+-rf\s+\//, // rm -rf /
/rm\s+-rf\s+\/\s*$/, // rm -rf / (end of line)
/rm\s+-rf\s+\/\s*2/, // rm -rf / 2>/dev/null (with error suppression)
// Fork bomb variations
/:\(\)\{\s*:\|:&\s*\};:/, // Classic fork bomb
/:\(\)\{\s*:\|:&\s*\};/, // Fork bomb without final colon
/:\(\)\{\s*:\|:&\s*\}/, // Fork bomb without semicolon
// Disk operations
/dd\s+if=.*of=\/dev\//, // dd to disk devices
/dd\s+if=\/dev\/zero.*of=\/dev\//, // dd zero to disk
/dd\s+if=\/dev\/urandom.*of=\/dev\//, // dd random to disk
/>\s*\/dev\/sd[a-z]/, // Write to disk devices
/>>\s*\/dev\/sd[a-z]/, // Append to disk devices
// Filesystem operations
/mkfs\./, // Format filesystem
/mkfs\s+/, // Format filesystem with space
/fdisk\s+\/dev\/sd[a-z]/, // Partition disk
/parted\s+\/dev\/sd[a-z]/, // Partition disk with parted
// Download and execute patterns
/wget.*\|\s*sh/, // wget | sh
/wget.*\|\s*bash/, // wget | bash
/curl.*\|\s*sh/, // curl | sh
/curl.*\|\s*bash/, // curl | bash
/wget.*\|\s*python/, // wget | python
/curl.*\|\s*python/, // curl | python
// Shell execution
/\|\s*bash/, // Pipe to bash
/\|\s*sh/, // Pipe to sh
/\|\s*zsh/, // Pipe to zsh
/\|\s*fish/, // Pipe to fish
// Command evaluation
/eval\s+\$\(/, // eval $()
/eval\s+`/, // eval backticks
/eval\s+"/, // eval double quotes
/eval\s+'/, // eval single quotes
// Permission changes
/chmod\s+777\s+\//, // chmod 777 /
/chmod\s+777\s+\/\s*$/, // chmod 777 / (end of line)
/chmod\s+-R\s+777\s+\//, // chmod -R 777 /
/chown\s+-R\s+root\s+\//, // chown -R root /
// Network operations
/nc\s+-l\s+-p\s+\d+/, // netcat listener
/ncat\s+-l\s+-p\s+\d+/, // ncat listener
/socat\s+.*LISTEN/, // socat listener
// Process manipulation
/killall\s+-9/, // killall -9
/pkill\s+-9/, // pkill -9
/kill\s+-9\s+-1/, // kill -9 -1 (kill all processes)
// System shutdown/reboot
/shutdown\s+now/, // shutdown now
/reboot/, // reboot
/halt/, // halt
/poweroff/, // poweroff
// Memory operations
/echo\s+3\s*>\s*\/proc\/sys\/vm\/drop_caches/, // Clear page cache
/sync\s*;\s*echo\s+3\s*>\s*\/proc\/sys\/vm\/drop_caches/, // Sync and clear cache
// Network interface manipulation
/ifconfig\s+.*down/, // Bring interface down
/ip\s+link\s+set\s+.*down/, // Bring interface down with ip
// Package manager operations
/apt\s+remove\s+--purge\s+.*/, // Remove packages
/yum\s+remove\s+.*/, // Remove packages
/dnf\s+remove\s+.*/, // Remove packages
/pacman\s+-R\s+.*/, // Remove packages
];
// Command injection patterns
// Note: We don't block compound commands with && here, as they're handled by
// the compound command detection logic in determineApprovalRequirement()
const INJECTION_PATTERNS = [
// Command chaining with dangerous commands using semicolon (more suspicious)
/;\s*rm\s+-rf/, // ; rm -rf
/;\s*chmod\s+777/, // ; chmod 777
/;\s*chown\s+root/, // ; chown root
// Command substitution with dangerous commands
/`.*rm.*`/, // backticks with rm
/\$\(.*rm.*\)/, // $() with rm
/`.*chmod.*`/, // backticks with chmod
/\$\(.*chmod.*\)/, // $() with chmod
/`.*chown.*`/, // backticks with chown
/\$\(.*chown.*\)/, // $() with chown
// Multiple command separators
/;\s*;\s*/, // Multiple semicolons
/&&\s*&&\s*/, // Multiple && operators
/\|\|\s*\|\|\s*/, // Multiple || operators
// Redirection with dangerous commands
/rm\s+.*>\s*\/dev\/null/, // rm with output redirection
/chmod\s+.*>\s*\/dev\/null/, // chmod with output redirection
/chown\s+.*>\s*\/dev\/null/, // chown with output redirection
// Environment variable manipulation
/\$[A-Z_]+\s*=\s*.*rm/, // Environment variable with rm
/\$[A-Z_]+\s*=\s*.*chmod/, // Environment variable with chmod
/\$[A-Z_]+\s*=\s*.*chown/, // Environment variable with chown
];
// Commands that require approval
const REQUIRES_APPROVAL_PATTERNS = [
// File operations
/^rm\s+/, // rm (removal)
/^mv\s+/, // move files
/^cp\s+/, // copy files
/^chmod\s+/, // chmod
/^chown\s+/, // chown
/^chgrp\s+/, // chgrp
/^ln\s+/, // create links
/^unlink\s+/, // unlink files
// Git operations
/^git\s+push/, // git push
/^git\s+commit/, // git commit
/^git\s+reset/, // git reset
/^git\s+rebase/, // git rebase
/^git\s+merge/, // git merge
/^git\s+checkout/, // git checkout
/^git\s+branch/, // git branch
/^git\s+tag/, // git tag
// Package management
/^npm\s+publish/, // npm publish
/^npm\s+uninstall/, // npm uninstall
/^yarn\s+publish/, // yarn publish
/^yarn\s+remove/, // yarn remove
/^pip\s+install/, // pip install
/^pip\s+uninstall/, // pip uninstall
/^apt\s+install/, // apt install
/^apt\s+remove/, // apt remove
/^yum\s+install/, // yum install
/^yum\s+remove/, // yum remove
/^dnf\s+install/, // dnf install
/^dnf\s+remove/, // dnf remove
/^pacman\s+-S/, // pacman install
/^pacman\s+-R/, // pacman remove
// Container operations
/^docker\s+/, // docker commands
/^podman\s+/, // podman commands
/^kubectl\s+/, // kubectl commands
// System operations
/^sudo\s+/, // sudo commands
/^su\s+/, // su commands
/^systemctl\s+/, // systemctl commands
/^service\s+/, // service commands
/^mount\s+/, // mount commands
/^umount\s+/, // umount commands
/^fdisk\s+/, // fdisk commands
/^parted\s+/, // parted commands
/^mkfs\s+/, // mkfs commands
/^fsck\s+/, // fsck commands
// Network operations
/^iptables\s+/, // iptables commands
/^ufw\s+/, // ufw commands
/^firewall-cmd\s+/, // firewall-cmd commands
/^sshd\s+/, // sshd commands
/^ssh\s+/, // ssh commands
/^scp\s+/, // scp commands
/^rsync\s+/, // rsync commands
// Process management
/^kill\s+/, // kill commands
/^killall\s+/, // killall commands
/^pkill\s+/, // pkill commands
/^nohup\s+/, // nohup commands
/^screen\s+/, // screen commands
/^tmux\s+/, // tmux commands
// Database operations
/^mysql\s+/, // mysql commands
/^psql\s+/, // psql commands
/^sqlite3\s+/, // sqlite3 commands
/^mongodb\s+/, // mongodb commands
/^redis-cli\s+/, // redis-cli commands
];
// Safe command patterns for strict mode
const SAFE_PATTERNS = [
// Directory navigation with commands
/^cd\s+.*&&\s+\w+/, // cd && command
/^cd\s+.*;\s+\w+/, // cd ; command
// Safe pipe operations
/\|\s*grep/, // | grep
/\|\s*head/, // | head
/\|\s*tail/, // | tail
/\|\s*sort/, // | sort
/\|\s*uniq/, // | uniq
/\|\s*wc/, // | wc
/\|\s*cat/, // | cat
/\|\s*less/, // | less
/\|\s*more/, // | more
/\|\s*awk/, // | awk
/\|\s*sed/, // | sed
/\|\s*cut/, // | cut
/\|\s*tr/, // | tr
/\|\s*xargs/, // | xargs
// Safe redirection
/^ls\s+.*>/, // ls with output redirection
/^find\s+.*>/, // find with output redirection
/^grep\s+.*>/, // grep with output redirection
/^cat\s+.*>/, // cat with output redirection
];
// Write operation patterns for moderate mode
const WRITE_PATTERNS = [
// Output redirection
/>/, // output redirection
/>>/, // append redirection
/2>/, // error redirection
/2>>/, // error append redirection
/&>/, // both output and error redirection
/&>>/, // both output and error append redirection
// File operations
/tee\s+/, // tee command
/touch\s+/, // touch command
/mkdir\s+/, // mkdir command
/rmdir\s+/, // rmdir command
// Text editors
/vim\s+/, // vim command
/nano\s+/, // nano command
/emacs\s+/, // emacs command
/code\s+/, // code command (VS Code)
// File copying and moving
/cp\s+/, // cp command
/mv\s+/, // mv command
/scp\s+/, // scp command
/rsync\s+/, // rsync command
];
/**
* CommandValidator - Validates commands for security and policy compliance
*
* Security checks:
* 1. Command length limits
* 2. Dangerous command patterns
* 3. Command injection detection
* 4. Allowed/blocked command lists
* 5. Shell metacharacter analysis
* TODO: Add tests for this class
*/
export class CommandValidator {
private config: ProcessConfig;
private logger: IDextoLogger;
constructor(config: ProcessConfig, logger: IDextoLogger) {
this.config = config;
this.logger = logger;
this.logger.debug(
`CommandValidator initialized with security level: ${config.securityLevel}`
);
}
/**
* Validate a command for security and policy compliance
*/
validateCommand(command: string): CommandValidation {
// 1. Check for empty command
if (!command || command.trim() === '') {
return {
isValid: false,
error: 'Command cannot be empty',
};
}
const trimmedCommand = command.trim();
// 2. Check for shell backgrounding (trailing &)
// This bypasses timeout and creates orphaned processes that can't be controlled
if (/&\s*$/.test(trimmedCommand)) {
return {
isValid: false,
error: 'Commands ending with & (shell backgrounding) are not allowed. Use run_in_background parameter instead for proper process management.',
};
}
// 3. Check command length
if (trimmedCommand.length > MAX_COMMAND_LENGTH) {
return {
isValid: false,
error: `Command too long: ${trimmedCommand.length} characters. Maximum: ${MAX_COMMAND_LENGTH}`,
};
}
// 4. Check against dangerous patterns (strict and moderate)
if (this.config.securityLevel !== 'permissive') {
for (const pattern of DANGEROUS_PATTERNS) {
if (pattern.test(trimmedCommand)) {
return {
isValid: false,
error: `Command matches dangerous pattern: ${pattern.source}`,
};
}
}
}
// 5. Check for command injection attempts (all security levels)
const injectionResult = this.detectInjection(trimmedCommand);
if (!injectionResult.isValid) {
return injectionResult;
}
// 6. Check against blocked commands list
for (const blockedPattern of this.config.blockedCommands) {
if (trimmedCommand.includes(blockedPattern)) {
return {
isValid: false,
error: `Command is blocked: matches "${blockedPattern}"`,
};
}
}
// 7. Check against allowed commands list (if not empty)
if (this.config.allowedCommands.length > 0) {
const isAllowed = this.config.allowedCommands.some((allowedCmd) =>
trimmedCommand.startsWith(allowedCmd)
);
if (!isAllowed) {
return {
isValid: false,
error: `Command not in allowed list. Allowed: ${this.config.allowedCommands.join(', ')}`,
};
}
}
// 8. Determine if approval is required based on security level
const requiresApproval = this.determineApprovalRequirement(trimmedCommand);
return {
isValid: true,
normalizedCommand: trimmedCommand,
requiresApproval,
};
}
/**
* Detect command injection attempts
*/
private detectInjection(command: string): CommandValidation {
// Check for obvious injection patterns
for (const pattern of INJECTION_PATTERNS) {
if (pattern.test(command)) {
return {
isValid: false,
error: `Potential command injection detected: ${pattern.source}`,
};
}
}
// In strict mode, be more aggressive
if (this.config.securityLevel === 'strict') {
// Check for multiple commands chained together (except safe ones)
const hasMultipleCommands = /;|\|{1,2}|&&/.test(command);
if (hasMultipleCommands) {
// Allow safe patterns like "cd dir && ls" or "command | grep pattern"
const isSafe = SAFE_PATTERNS.some((pattern) => pattern.test(command));
if (!isSafe) {
return {
isValid: false,
error: 'Multiple commands detected in strict mode. Use moderate or permissive mode if this is intentional.',
};
}
}
}
return {
isValid: true,
};
}
/**
* Determine if a command requires approval
* Handles compound commands (with &&, ||, ;) by checking each sub-command
*/
private determineApprovalRequirement(command: string): boolean {
// Split compound commands by &&, ||, or ; to check each part independently
// This ensures dangerous operations in the middle of compound commands are detected
const subCommands = command.split(/\s*(?:&&|\|\||;)\s*/).map((cmd) => cmd.trim());
// Check if ANY sub-command requires approval
for (const subCmd of subCommands) {
if (!subCmd) continue; // Skip empty parts
// Strip leading shell keywords and braces to get the actual command
// This prevents bypassing approval checks via control-flow wrapping
const normalizedSubCmd = subCmd
.replace(/^(?:then|do|else)\b\s*/, '')
.replace(/^\{\s*/, '')
.trim();
if (!normalizedSubCmd) continue;
// Commands that modify system state always require approval
for (const pattern of REQUIRES_APPROVAL_PATTERNS) {
if (pattern.test(normalizedSubCmd)) {
return true;
}
}
// In strict mode, all commands require approval
if (this.config.securityLevel === 'strict') {
return true;
}
// In moderate mode, write operations require approval
if (this.config.securityLevel === 'moderate') {
if (WRITE_PATTERNS.some((pattern) => pattern.test(normalizedSubCmd))) {
return true;
}
}
}
// Permissive mode - no additional approval required
return false;
}
/**
* Get list of blocked commands
*/
getBlockedCommands(): string[] {
return [...this.config.blockedCommands];
}
/**
* Get list of allowed commands
*/
getAllowedCommands(): string[] {
return [...this.config.allowedCommands];
}
/**
* Get security level
*/
getSecurityLevel(): string {
return this.config.securityLevel;
}
}

View File

@@ -0,0 +1,32 @@
/**
* Process Service Error Codes
*
* Standardized error codes for process execution and management
*/
export enum ProcessErrorCode {
// Command validation errors
INVALID_COMMAND = 'PROCESS_INVALID_COMMAND',
COMMAND_BLOCKED = 'PROCESS_COMMAND_BLOCKED',
COMMAND_TOO_LONG = 'PROCESS_COMMAND_TOO_LONG',
INJECTION_DETECTED = 'PROCESS_INJECTION_DETECTED',
APPROVAL_REQUIRED = 'PROCESS_APPROVAL_REQUIRED',
APPROVAL_DENIED = 'PROCESS_APPROVAL_DENIED',
// Execution errors
EXECUTION_FAILED = 'PROCESS_EXECUTION_FAILED',
TIMEOUT = 'PROCESS_TIMEOUT',
PERMISSION_DENIED = 'PROCESS_PERMISSION_DENIED',
COMMAND_NOT_FOUND = 'PROCESS_COMMAND_NOT_FOUND',
WORKING_DIRECTORY_INVALID = 'PROCESS_WORKING_DIRECTORY_INVALID',
// Process management errors
PROCESS_NOT_FOUND = 'PROCESS_NOT_FOUND',
TOO_MANY_PROCESSES = 'PROCESS_TOO_MANY_PROCESSES',
KILL_FAILED = 'PROCESS_KILL_FAILED',
OUTPUT_BUFFER_FULL = 'PROCESS_OUTPUT_BUFFER_FULL',
// Configuration errors
INVALID_CONFIG = 'PROCESS_INVALID_CONFIG',
SERVICE_NOT_INITIALIZED = 'PROCESS_SERVICE_NOT_INITIALIZED',
}

View File

@@ -0,0 +1,254 @@
/**
* Process Service Errors
*
* Error classes for process execution and management
*/
import { DextoRuntimeError, ErrorType } from '@dexto/core';
/** Error scope for process operations */
const PROCESS_SCOPE = 'process';
import { ProcessErrorCode } from './error-codes.js';
export interface ProcessErrorContext {
command?: string;
processId?: string;
timeout?: number;
[key: string]: unknown;
}
/**
* Factory class for creating Process-related errors
*/
export class ProcessError {
private constructor() {
// Private constructor prevents instantiation
}
/**
* Invalid command error
*/
static invalidCommand(command: string, reason: string): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.INVALID_COMMAND,
PROCESS_SCOPE,
ErrorType.USER,
`Invalid command: ${command}. ${reason}`,
{ command, reason }
);
}
/**
* Command blocked error
*/
static commandBlocked(command: string, reason: string): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.COMMAND_BLOCKED,
PROCESS_SCOPE,
ErrorType.FORBIDDEN,
`Command is blocked: ${command}. ${reason}`,
{ command, reason }
);
}
/**
* Command too long error
*/
static commandTooLong(length: number, maxLength: number): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.COMMAND_TOO_LONG,
PROCESS_SCOPE,
ErrorType.USER,
`Command too long: ${length} characters. Maximum allowed: ${maxLength}`,
{ length, maxLength }
);
}
/**
* Command injection detected error
*/
static commandInjection(command: string, pattern: string): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.INJECTION_DETECTED,
PROCESS_SCOPE,
ErrorType.FORBIDDEN,
`Potential command injection detected in: ${command}. Pattern: ${pattern}`,
{ command, pattern }
);
}
/**
* Command approval required error
*/
static approvalRequired(command: string, reason?: string): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.APPROVAL_REQUIRED,
PROCESS_SCOPE,
ErrorType.FORBIDDEN,
`Command requires approval: ${command}${reason ? `. ${reason}` : ''}`,
{ command, reason },
'Provide an approval function to execute dangerous commands'
);
}
/**
* Command approval denied error
*/
static approvalDenied(command: string): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.APPROVAL_DENIED,
PROCESS_SCOPE,
ErrorType.FORBIDDEN,
`Command approval denied by user: ${command}`,
{ command }
);
}
/**
* Command execution failed error
*/
static executionFailed(command: string, cause: string): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.EXECUTION_FAILED,
PROCESS_SCOPE,
ErrorType.SYSTEM,
`Command execution failed: ${command}. ${cause}`,
{ command, cause }
);
}
/**
* Command timeout error
*/
static timeout(command: string, timeout: number): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.TIMEOUT,
PROCESS_SCOPE,
ErrorType.TIMEOUT,
`Command timed out after ${timeout}ms: ${command}`,
{ command, timeout },
'Increase timeout or optimize the command'
);
}
/**
* Permission denied error
*/
static permissionDenied(command: string): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.PERMISSION_DENIED,
PROCESS_SCOPE,
ErrorType.FORBIDDEN,
`Permission denied: ${command}`,
{ command }
);
}
/**
* Command not found error
*/
static commandNotFound(command: string): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.COMMAND_NOT_FOUND,
PROCESS_SCOPE,
ErrorType.NOT_FOUND,
`Command not found: ${command}`,
{ command },
'Ensure the command is installed and available in PATH'
);
}
/**
* Invalid working directory error
*/
static invalidWorkingDirectory(path: string, reason: string): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.WORKING_DIRECTORY_INVALID,
PROCESS_SCOPE,
ErrorType.USER,
`Invalid working directory: ${path}. ${reason}`,
{ path, reason }
);
}
/**
* Process not found error
*/
static processNotFound(processId: string): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.PROCESS_NOT_FOUND,
PROCESS_SCOPE,
ErrorType.NOT_FOUND,
`Process not found: ${processId}`,
{ processId }
);
}
/**
* Too many concurrent processes error
*/
static tooManyProcesses(current: number, max: number): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.TOO_MANY_PROCESSES,
PROCESS_SCOPE,
ErrorType.USER,
`Too many concurrent processes: ${current}. Maximum allowed: ${max}`,
{ current, max },
'Wait for running processes to complete or increase the limit'
);
}
/**
* Kill process failed error
*/
static killFailed(processId: string, cause: string): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.KILL_FAILED,
PROCESS_SCOPE,
ErrorType.SYSTEM,
`Failed to kill process ${processId}: ${cause}`,
{ processId, cause }
);
}
/**
* Output buffer full error
*/
static outputBufferFull(processId: string, size: number, maxSize: number): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.OUTPUT_BUFFER_FULL,
PROCESS_SCOPE,
ErrorType.SYSTEM,
`Output buffer full for process ${processId}: ${size} bytes. Maximum: ${maxSize}`,
{ processId, size, maxSize },
'Process output exceeded buffer limit'
);
}
/**
* Invalid configuration error
*/
static invalidConfig(reason: string): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.INVALID_CONFIG,
PROCESS_SCOPE,
ErrorType.USER,
`Invalid Process configuration: ${reason}`,
{ reason }
);
}
/**
* Service not initialized error
*/
static notInitialized(): DextoRuntimeError {
return new DextoRuntimeError(
ProcessErrorCode.SERVICE_NOT_INITIALIZED,
PROCESS_SCOPE,
ErrorType.SYSTEM,
'ProcessService has not been initialized',
{},
'Initialize the ProcessService before using it'
);
}
}

View File

@@ -0,0 +1,32 @@
/**
* @dexto/tools-process
*
* Process tools provider for Dexto agents.
* Provides process operation tools: bash exec, output, kill.
*/
// Main provider export
export { processToolsProvider } from './tool-provider.js';
// Service and utilities (for advanced use cases)
export { ProcessService } from './process-service.js';
export { CommandValidator } from './command-validator.js';
export { ProcessError } from './errors.js';
export { ProcessErrorCode } from './error-codes.js';
// Types
export type {
ProcessConfig,
ExecuteOptions,
ProcessResult,
ProcessHandle,
ProcessOutput,
ProcessInfo,
CommandValidation,
OutputBuffer,
} from './types.js';
// Tool implementations (for custom integrations)
export { createBashExecTool } from './bash-exec-tool.js';
export { createBashOutputTool } from './bash-output-tool.js';
export { createKillProcessTool } from './kill-process-tool.js';

View File

@@ -0,0 +1,43 @@
/**
* Kill Process Tool
*
* Internal tool for terminating background processes
*/
import { z } from 'zod';
import { InternalTool, ToolExecutionContext } from '@dexto/core';
import { ProcessService } from './process-service.js';
const KillProcessInputSchema = z
.object({
process_id: z.string().describe('Process ID of the background process to terminate'),
})
.strict();
type KillProcessInput = z.input<typeof KillProcessInputSchema>;
/**
* Create the kill_process internal tool
*/
export function createKillProcessTool(processService: ProcessService): InternalTool {
return {
id: 'kill_process',
description:
"Terminate a background process started with bash_exec. Sends SIGTERM signal first, then SIGKILL if process doesn't terminate within 5 seconds. Only works on processes started by this agent. Returns success status and whether the process was running. Does not require additional approval (process was already approved when started).",
inputSchema: KillProcessInputSchema,
execute: async (input: unknown, _context?: ToolExecutionContext) => {
// Input is validated by provider before reaching here
const { process_id } = input as KillProcessInput;
// Kill process using ProcessService
await processService.killProcess(process_id);
// Note: killProcess returns void and doesn't throw if process already stopped
return {
success: true,
process_id,
message: `Termination signal sent to process ${process_id}`,
};
},
};
}

View File

@@ -0,0 +1,688 @@
/**
* Process Service
*
* Secure command execution and process management for Dexto internal tools
*/
import { spawn, ChildProcess } from 'node:child_process';
import * as crypto from 'node:crypto';
import * as path from 'node:path';
import {
ProcessConfig,
ExecuteOptions,
ProcessResult,
ProcessHandle,
ProcessOutput,
ProcessInfo,
OutputBuffer,
} from './types.js';
import { CommandValidator } from './command-validator.js';
import { ProcessError } from './errors.js';
import type { IDextoLogger } from '@dexto/core';
import { DextoLogComponent } from '@dexto/core';
const DEFAULT_TIMEOUT = 120000; // 2 minutes
/**
* Background process tracking
*/
interface BackgroundProcess {
processId: string;
command: string;
child: ChildProcess;
startedAt: Date;
completedAt?: Date | undefined;
status: 'running' | 'completed' | 'failed';
exitCode?: number | undefined;
outputBuffer: OutputBuffer;
description?: string | undefined;
}
/**
* ProcessService - Handles command execution and process management
*
* This service receives fully-validated configuration from the Process Tools Provider.
* All defaults have been applied by the provider's schema, so the service trusts the config
* and uses it as-is without any fallback logic.
*
* TODO: Add tests for this class
*/
export class ProcessService {
private config: ProcessConfig;
private commandValidator: CommandValidator;
private initialized: boolean = false;
private initPromise: Promise<void> | null = null;
private backgroundProcesses: Map<string, BackgroundProcess> = new Map();
private logger: IDextoLogger;
/**
* Create a new ProcessService with validated configuration.
*
* @param config - Fully-validated configuration from provider schema.
* All required fields have values, defaults already applied.
* @param logger - Logger instance for this service
*/
constructor(config: ProcessConfig, logger: IDextoLogger) {
// Config is already fully validated with defaults applied - just use it
this.config = config;
this.logger = logger.createChild(DextoLogComponent.PROCESS);
this.commandValidator = new CommandValidator(this.config, this.logger);
}
/**
* Initialize the service.
* Safe to call multiple times - subsequent calls return the same promise.
*/
initialize(): Promise<void> {
if (this.initPromise) {
return this.initPromise;
}
this.initPromise = this.doInitialize();
return this.initPromise;
}
/**
* Internal initialization logic.
*/
private async doInitialize(): Promise<void> {
if (this.initialized) {
this.logger.debug('ProcessService already initialized');
return;
}
// Clean up any stale processes on startup
this.backgroundProcesses.clear();
this.initialized = true;
this.logger.info('ProcessService initialized successfully');
}
/**
* Ensure the service is initialized before use.
* Tools should call this at the start of their execute methods.
* Safe to call multiple times - will await the same initialization promise.
*/
async ensureInitialized(): Promise<void> {
if (this.initialized) {
return;
}
await this.initialize();
}
/**
* Execute a command
*/
async executeCommand(
command: string,
options: ExecuteOptions = {}
): Promise<ProcessResult | ProcessHandle> {
await this.ensureInitialized();
// Validate command
const validation = this.commandValidator.validateCommand(command);
if (!validation.isValid || !validation.normalizedCommand) {
throw ProcessError.invalidCommand(command, validation.error || 'Unknown error');
}
const normalizedCommand = validation.normalizedCommand;
// Note: Command-level approval removed - approval is now handled at the tool level
// in ToolManager with pattern-based approval for bash commands.
// CommandValidator still validates for dangerous patterns (blocks truly dangerous commands)
// but no longer triggers a second approval prompt.
// Handle timeout - clamp to valid range to prevent negative/NaN/invalid values
const rawTimeout =
options.timeout !== undefined && Number.isFinite(options.timeout)
? options.timeout
: DEFAULT_TIMEOUT;
const timeout = Math.max(1, Math.min(rawTimeout, this.config.maxTimeout));
// Setup working directory
const cwd: string = this.resolveSafeCwd(options.cwd);
// Setup environment - filter out undefined values
const env: Record<string, string> = {};
for (const [key, value] of Object.entries({
...process.env,
...this.config.environment,
...options.env,
})) {
if (value !== undefined) {
env[key] = value;
}
}
// If running in background, return the process handle directly
if (options.runInBackground) {
return await this.executeInBackground(normalizedCommand, options);
}
// Execute command in foreground
return await this.executeForeground(normalizedCommand, {
cwd,
timeout,
env,
...(options.description !== undefined && { description: options.description }),
...(options.abortSignal !== undefined && { abortSignal: options.abortSignal }),
});
}
private static readonly SIGKILL_TIMEOUT_MS = 200;
/**
* Kill a process tree (process group on Unix, taskkill on Windows)
*/
private async killProcessTree(pid: number, child: ChildProcess): Promise<void> {
if (process.platform === 'win32') {
// Windows: use taskkill with /t flag to kill process tree
await new Promise<void>((resolve) => {
const killer = spawn('taskkill', ['/pid', String(pid), '/f', '/t'], {
stdio: 'ignore',
});
killer.once('exit', () => resolve());
killer.once('error', () => resolve());
});
} else {
// Unix: kill process group using negative PID
try {
process.kill(-pid, 'SIGTERM');
await new Promise((res) => setTimeout(res, ProcessService.SIGKILL_TIMEOUT_MS));
if (child.exitCode === null) {
process.kill(-pid, 'SIGKILL');
}
} catch {
// Fallback to killing just the process if group kill fails
child.kill('SIGTERM');
await new Promise((res) => setTimeout(res, ProcessService.SIGKILL_TIMEOUT_MS));
if (child.exitCode === null) {
child.kill('SIGKILL');
}
}
}
}
/**
* Execute command in foreground with timeout and abort support
*/
private executeForeground(
command: string,
options: {
cwd: string;
timeout: number;
env: Record<string, string>;
description?: string;
abortSignal?: AbortSignal;
}
): Promise<ProcessResult> {
return new Promise((resolve, reject) => {
const startTime = Date.now();
let stdout = '';
let stderr = '';
let stdoutBytes = 0;
let stderrBytes = 0;
let outputTruncated = false;
let killed = false;
let aborted = false;
let closed = false;
const maxBuffer = this.config.maxOutputBuffer;
// Check if already aborted before starting
if (options.abortSignal?.aborted) {
this.logger.debug(`Command cancelled before execution: ${command}`);
resolve({
stdout: '',
stderr: '(Command was cancelled)',
exitCode: 130, // Standard exit code for SIGINT
duration: 0,
});
return;
}
this.logger.debug(`Executing command: ${command}`);
// Spawn process with shell and detached for process group support (Unix)
const child = spawn(command, {
cwd: options.cwd,
env: options.env,
shell: true,
detached: process.platform !== 'win32', // Create process group on Unix
});
// Setup timeout
const timeoutHandle = setTimeout(() => {
killed = true;
if (child.pid) {
void this.killProcessTree(child.pid, child);
} else {
child.kill('SIGTERM');
}
}, options.timeout);
// Setup abort handler
const abortHandler = () => {
if (closed) return;
aborted = true;
this.logger.debug(`Command cancelled by user: ${command}`);
clearTimeout(timeoutHandle);
if (child.pid) {
void this.killProcessTree(child.pid, child);
} else {
child.kill('SIGTERM');
}
};
options.abortSignal?.addEventListener('abort', abortHandler, { once: true });
// Collect stdout with buffer limit
child.stdout?.on('data', (data) => {
if (outputTruncated) return; // Ignore further data after truncation
const chunk = data.toString();
const chunkBytes = Buffer.byteLength(chunk, 'utf8');
if (stdoutBytes + stderrBytes + chunkBytes <= maxBuffer) {
stdout += chunk;
stdoutBytes += chunkBytes;
} else {
// Add remaining bytes up to limit, then truncate
const remaining = maxBuffer - stdoutBytes - stderrBytes;
if (remaining > 0) {
stdout += chunk.slice(0, remaining);
stdoutBytes += remaining;
}
stdout += '\n...[truncated]';
outputTruncated = true;
this.logger.warn(`Output buffer full for command: ${command}`);
}
});
// Collect stderr with buffer limit
child.stderr?.on('data', (data) => {
if (outputTruncated) return; // Ignore further data after truncation
const chunk = data.toString();
const chunkBytes = Buffer.byteLength(chunk, 'utf8');
if (stdoutBytes + stderrBytes + chunkBytes <= maxBuffer) {
stderr += chunk;
stderrBytes += chunkBytes;
} else {
// Add remaining bytes up to limit, then truncate
const remaining = maxBuffer - stdoutBytes - stderrBytes;
if (remaining > 0) {
stderr += chunk.slice(0, remaining);
stderrBytes += remaining;
}
stderr += '\n...[truncated]';
outputTruncated = true;
this.logger.warn(`Output buffer full for command: ${command}`);
}
});
// Handle completion
child.on('close', (code, signal) => {
closed = true;
clearTimeout(timeoutHandle);
options.abortSignal?.removeEventListener('abort', abortHandler);
const duration = Date.now() - startTime;
// Handle abort - return result instead of rejecting
if (aborted) {
stdout += '\n\n(Command was cancelled)';
this.logger.debug(`Command cancelled after ${duration}ms: ${command}`);
resolve({
stdout,
stderr,
exitCode: 130, // Standard exit code for SIGINT
duration,
});
return;
}
if (killed) {
reject(ProcessError.timeout(command, options.timeout));
return;
}
let exitCode = typeof code === 'number' ? code : 1;
if (code === null) {
stderr += `\nProcess terminated by signal ${signal ?? 'UNKNOWN'}`;
}
this.logger.debug(
`Command completed with exit code ${exitCode} in ${duration}ms: ${command}`
);
resolve({
stdout,
stderr,
exitCode,
duration,
});
});
// Handle errors
child.on('error', (error) => {
clearTimeout(timeoutHandle);
options.abortSignal?.removeEventListener('abort', abortHandler);
// Check for specific error types
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
reject(ProcessError.commandNotFound(command));
} else if ((error as NodeJS.ErrnoException).code === 'EACCES') {
reject(ProcessError.permissionDenied(command));
} else {
reject(ProcessError.executionFailed(command, error.message));
}
});
});
}
/**
* Execute command in background
*/
private async executeInBackground(
command: string,
options: ExecuteOptions
): Promise<ProcessHandle> {
// Check concurrent process limit
const runningCount = Array.from(this.backgroundProcesses.values()).filter(
(p) => p.status === 'running'
).length;
if (runningCount >= this.config.maxConcurrentProcesses) {
throw ProcessError.tooManyProcesses(runningCount, this.config.maxConcurrentProcesses);
}
// Generate unique process ID
const processId = crypto.randomBytes(4).toString('hex');
// Setup working directory
const cwd: string = this.resolveSafeCwd(options.cwd);
// Setup environment - filter out undefined values
const env: Record<string, string> = {};
for (const [key, value] of Object.entries({
...process.env,
...this.config.environment,
...options.env,
})) {
if (value !== undefined) {
env[key] = value;
}
}
this.logger.debug(`Starting background process ${processId}: ${command}`);
// Spawn process
const child = spawn(command, {
cwd,
env,
shell: true,
detached: false,
});
// Create output buffer
const outputBuffer: OutputBuffer = {
stdout: [],
stderr: [],
complete: false,
lastRead: Date.now(),
bytesUsed: 0,
truncated: false,
};
// Track background process
const bgProcess: BackgroundProcess = {
processId,
command,
child,
startedAt: new Date(),
status: 'running',
outputBuffer,
description: options.description,
};
this.backgroundProcesses.set(processId, bgProcess);
// Enforce background timeout
const bgTimeout = Math.max(
1,
Math.min(options.timeout || DEFAULT_TIMEOUT, this.config.maxTimeout)
);
let killEscalationTimer: ReturnType<typeof setTimeout> | null = null;
const killTimer = setTimeout(() => {
if (bgProcess.status === 'running') {
this.logger.warn(
`Background process ${processId} timed out after ${bgTimeout}ms, sending SIGTERM`
);
child.kill('SIGTERM');
// Escalate to SIGKILL if process doesn't terminate within 5s
killEscalationTimer = setTimeout(() => {
if (bgProcess.status === 'running') {
this.logger.warn(
`Background process ${processId} did not respond to SIGTERM, sending SIGKILL`
);
child.kill('SIGKILL');
}
}, 5000);
}
}, bgTimeout);
// bytesUsed is kept on outputBuffer for correct accounting across reads
// Setup output collection with buffer limit
child.stdout?.on('data', (data) => {
const chunk = data.toString();
const chunkBytes = Buffer.byteLength(chunk, 'utf8');
if (outputBuffer.bytesUsed + chunkBytes <= this.config.maxOutputBuffer) {
outputBuffer.stdout.push(chunk);
outputBuffer.bytesUsed += chunkBytes;
} else {
if (!outputBuffer.truncated) {
outputBuffer.truncated = true;
this.logger.warn(`Output buffer full for process ${processId}`);
}
}
});
child.stderr?.on('data', (data) => {
const chunk = data.toString();
const chunkBytes = Buffer.byteLength(chunk, 'utf8');
if (outputBuffer.bytesUsed + chunkBytes <= this.config.maxOutputBuffer) {
outputBuffer.stderr.push(chunk);
outputBuffer.bytesUsed += chunkBytes;
} else {
if (!outputBuffer.truncated) {
outputBuffer.truncated = true;
this.logger.warn(`Error buffer full for process ${processId}`);
}
}
});
// Handle completion
child.on('close', (code) => {
clearTimeout(killTimer);
if (killEscalationTimer) clearTimeout(killEscalationTimer);
bgProcess.status = code === 0 ? 'completed' : 'failed';
bgProcess.exitCode = code ?? undefined;
bgProcess.completedAt = new Date();
bgProcess.outputBuffer.complete = true;
this.logger.debug(`Background process ${processId} completed with exit code ${code}`);
});
// Handle errors
child.on('error', (error) => {
clearTimeout(killTimer);
if (killEscalationTimer) clearTimeout(killEscalationTimer);
bgProcess.status = 'failed';
bgProcess.completedAt = new Date();
bgProcess.outputBuffer.complete = true;
const chunk = `Error: ${error.message}`;
const chunkBytes = Buffer.byteLength(chunk, 'utf8');
if (bgProcess.outputBuffer.bytesUsed + chunkBytes <= this.config.maxOutputBuffer) {
bgProcess.outputBuffer.stderr.push(chunk);
bgProcess.outputBuffer.bytesUsed += chunkBytes;
} else {
if (!bgProcess.outputBuffer.truncated) {
bgProcess.outputBuffer.truncated = true;
this.logger.warn(`Error buffer full for process ${processId}`);
}
}
this.logger.error(`Background process ${processId} failed: ${error.message}`);
});
return {
processId,
command,
pid: child.pid,
startedAt: bgProcess.startedAt,
description: options.description,
};
}
/**
* Get output from a background process
*/
async getProcessOutput(processId: string): Promise<ProcessOutput> {
await this.ensureInitialized();
const bgProcess = this.backgroundProcesses.get(processId);
if (!bgProcess) {
throw ProcessError.processNotFound(processId);
}
// Get new output since last read
const stdout = bgProcess.outputBuffer.stdout.join('');
const stderr = bgProcess.outputBuffer.stderr.join('');
// Clear the buffer (data has been read) and reset byte counter
bgProcess.outputBuffer.stdout = [];
bgProcess.outputBuffer.stderr = [];
bgProcess.outputBuffer.lastRead = Date.now();
bgProcess.outputBuffer.bytesUsed = 0;
return {
stdout,
stderr,
status: bgProcess.status,
exitCode: bgProcess.exitCode,
duration: bgProcess.completedAt
? bgProcess.completedAt.getTime() - bgProcess.startedAt.getTime()
: undefined,
};
}
/**
* Kill a background process
*/
async killProcess(processId: string): Promise<void> {
await this.ensureInitialized();
const bgProcess = this.backgroundProcesses.get(processId);
if (!bgProcess) {
throw ProcessError.processNotFound(processId);
}
if (bgProcess.status !== 'running') {
this.logger.debug(`Process ${processId} is not running (status: ${bgProcess.status})`);
return; // Already completed
}
try {
bgProcess.child.kill('SIGTERM');
// Force kill after timeout
setTimeout(() => {
// Escalate based on actual process state, not our status flag
if (bgProcess.child.exitCode === null) {
bgProcess.child.kill('SIGKILL');
}
}, 5000);
this.logger.debug(`Process ${processId} sent SIGTERM`);
} catch (error) {
throw ProcessError.killFailed(
processId,
error instanceof Error ? error.message : String(error)
);
}
}
/**
* List all background processes
*/
async listProcesses(): Promise<ProcessInfo[]> {
await this.ensureInitialized();
return Array.from(this.backgroundProcesses.values()).map((bgProcess) => ({
processId: bgProcess.processId,
command: bgProcess.command,
pid: bgProcess.child.pid,
status: bgProcess.status,
startedAt: bgProcess.startedAt,
completedAt: bgProcess.completedAt,
exitCode: bgProcess.exitCode,
description: bgProcess.description,
}));
}
/**
* Get buffer size in bytes
*/
private getBufferSize(buffer: OutputBuffer): number {
const stdoutSize = buffer.stdout.reduce((sum, line) => sum + line.length, 0);
const stderrSize = buffer.stderr.reduce((sum, line) => sum + line.length, 0);
return stdoutSize + stderrSize;
}
/**
* Get service configuration
*/
getConfig(): Readonly<ProcessConfig> {
return { ...this.config };
}
/**
* Resolve and confine cwd to the configured working directory
*/
private resolveSafeCwd(cwd?: string): string {
const baseDir = this.config.workingDirectory || process.cwd();
if (!cwd) return baseDir;
const candidate = path.isAbsolute(cwd) ? path.resolve(cwd) : path.resolve(baseDir, cwd);
const rel = path.relative(baseDir, candidate);
const outside = rel.startsWith('..') || path.isAbsolute(rel);
if (outside) {
throw ProcessError.invalidWorkingDirectory(
cwd,
`Working directory must be within ${baseDir}`
);
}
return candidate;
}
/**
* Cleanup completed processes
*/
async cleanup(): Promise<void> {
const now = Date.now();
const CLEANUP_AGE = 3600000; // 1 hour
for (const [processId, bgProcess] of this.backgroundProcesses.entries()) {
if (bgProcess.status !== 'running' && bgProcess.completedAt) {
const age = now - bgProcess.completedAt.getTime();
if (age > CLEANUP_AGE) {
this.backgroundProcesses.delete(processId);
this.logger.debug(`Cleaned up old process ${processId}`);
}
}
}
}
}

View File

@@ -0,0 +1,161 @@
/**
* Process Tools Provider
*
* Provides process execution and management tools by wrapping ProcessService.
* When registered, the provider initializes ProcessService and creates tools
* for command execution and process management.
*/
import { z } from 'zod';
import type { CustomToolProvider, ToolCreationContext } from '@dexto/core';
import type { InternalTool } from '@dexto/core';
import { ProcessService } from './process-service.js';
import { createBashExecTool } from './bash-exec-tool.js';
import { createBashOutputTool } from './bash-output-tool.js';
import { createKillProcessTool } from './kill-process-tool.js';
/**
* Default configuration constants for Process tools.
* These are the SINGLE SOURCE OF TRUTH for all default values.
*/
const DEFAULT_SECURITY_LEVEL = 'moderate';
const DEFAULT_MAX_TIMEOUT = 600000; // 10 minutes
const DEFAULT_MAX_CONCURRENT_PROCESSES = 5;
const DEFAULT_MAX_OUTPUT_BUFFER = 1 * 1024 * 1024; // 1MB
const DEFAULT_ALLOWED_COMMANDS: string[] = [];
const DEFAULT_BLOCKED_COMMANDS: string[] = [];
const DEFAULT_ENVIRONMENT: Record<string, string> = {};
/**
* Configuration schema for Process tools provider.
*
* This is the SINGLE SOURCE OF TRUTH for all configuration:
* - Validation rules
* - Default values (using constants above)
* - Documentation
* - Type definitions
*
* Services receive fully-validated config from this schema and use it as-is,
* with no additional defaults or fallbacks needed.
*/
const ProcessToolsConfigSchema = z
.object({
type: z.literal('process-tools'),
securityLevel: z
.enum(['strict', 'moderate', 'permissive'])
.default(DEFAULT_SECURITY_LEVEL)
.describe('Security level for command execution validation'),
maxTimeout: z
.number()
.int()
.positive()
.max(DEFAULT_MAX_TIMEOUT)
.default(DEFAULT_MAX_TIMEOUT)
.describe(
`Maximum timeout for commands in milliseconds (max: ${DEFAULT_MAX_TIMEOUT / 1000 / 60} minutes)`
),
maxConcurrentProcesses: z
.number()
.int()
.positive()
.default(DEFAULT_MAX_CONCURRENT_PROCESSES)
.describe(
`Maximum number of concurrent background processes (default: ${DEFAULT_MAX_CONCURRENT_PROCESSES})`
),
maxOutputBuffer: z
.number()
.int()
.positive()
.default(DEFAULT_MAX_OUTPUT_BUFFER)
.describe(
`Maximum output buffer size in bytes (default: ${DEFAULT_MAX_OUTPUT_BUFFER / 1024 / 1024}MB)`
),
workingDirectory: z
.string()
.optional()
.describe('Working directory for process execution (defaults to process.cwd())'),
allowedCommands: z
.array(z.string())
.default(DEFAULT_ALLOWED_COMMANDS)
.describe(
'Explicitly allowed commands (empty = all allowed with approval, strict mode only)'
),
blockedCommands: z
.array(z.string())
.default(DEFAULT_BLOCKED_COMMANDS)
.describe('Blocked command patterns (applies to all security levels)'),
environment: z
.record(z.string())
.default(DEFAULT_ENVIRONMENT)
.describe('Custom environment variables to set for command execution'),
timeout: z
.number()
.int()
.positive()
.max(DEFAULT_MAX_TIMEOUT)
.optional()
.describe(
`Default timeout in milliseconds (max: ${DEFAULT_MAX_TIMEOUT / 1000 / 60} minutes)`
),
})
.strict();
type ProcessToolsConfig = z.output<typeof ProcessToolsConfigSchema>;
/**
* Process tools provider.
*
* Wraps ProcessService and provides process operation tools:
* - bash_exec: Execute bash commands (foreground or background)
* - bash_output: Retrieve output from background processes
* - kill_process: Terminate background processes
*
* When registered via customToolRegistry, ProcessService is automatically
* initialized and process operation tools become available to the agent.
*/
export const processToolsProvider: CustomToolProvider<'process-tools', ProcessToolsConfig> = {
type: 'process-tools',
configSchema: ProcessToolsConfigSchema,
create: (config: ProcessToolsConfig, context: ToolCreationContext): InternalTool[] => {
const { logger } = context;
logger.debug('Creating ProcessService for process tools');
// Create ProcessService with validated config
const processService = new ProcessService(
{
securityLevel: config.securityLevel,
maxTimeout: config.maxTimeout,
maxConcurrentProcesses: config.maxConcurrentProcesses,
maxOutputBuffer: config.maxOutputBuffer,
workingDirectory: config.workingDirectory || process.cwd(),
allowedCommands: config.allowedCommands,
blockedCommands: config.blockedCommands,
environment: config.environment,
},
logger
);
// Start initialization in background - service methods use ensureInitialized() for lazy init
// This means tools will wait for initialization to complete before executing
processService.initialize().catch((error) => {
logger.error(`Failed to initialize ProcessService: ${error.message}`);
});
logger.debug('ProcessService created - initialization will complete on first tool use');
// Create and return all process operation tools
return [
createBashExecTool(processService),
createBashOutputTool(processService),
createKillProcessTool(processService),
];
},
metadata: {
displayName: 'Process Tools',
description: 'Process execution and management (bash, output, kill)',
category: 'process',
},
};

View File

@@ -0,0 +1,114 @@
/**
* Process Service Types
*
* Types and interfaces for command execution and process management
*/
/**
* Process execution options
*/
export interface ExecuteOptions {
/** Working directory */
cwd?: string | undefined;
/** Timeout in milliseconds (max: 600000) */
timeout?: number | undefined;
/** Run command in background */
runInBackground?: boolean | undefined;
/** Environment variables */
env?: Record<string, string> | undefined;
/** Description of what the command does (5-10 words) */
description?: string | undefined;
/** Abort signal for cancellation support */
abortSignal?: AbortSignal | undefined;
}
/**
* Process execution result (foreground execution only)
* For background execution, see ProcessHandle
*/
export interface ProcessResult {
stdout: string;
stderr: string;
exitCode: number;
duration: number;
}
/**
* Background process handle
*/
export interface ProcessHandle {
processId: string;
command: string;
pid?: number | undefined; // System process ID
startedAt: Date;
description?: string | undefined;
}
/**
* Process output (for retrieving from background processes)
*/
export interface ProcessOutput {
stdout: string;
stderr: string;
status: 'running' | 'completed' | 'failed';
exitCode?: number | undefined;
duration?: number | undefined;
}
/**
* Process information
*/
export interface ProcessInfo {
processId: string;
command: string;
pid?: number | undefined;
status: 'running' | 'completed' | 'failed';
startedAt: Date;
completedAt?: Date | undefined;
exitCode?: number | undefined;
description?: string | undefined;
}
/**
* Command validation result
*/
export interface CommandValidation {
isValid: boolean;
error?: string;
normalizedCommand?: string;
requiresApproval?: boolean;
}
/**
* Process service configuration
*/
export interface ProcessConfig {
/** Security level for command execution */
securityLevel: 'strict' | 'moderate' | 'permissive';
/** Maximum timeout for commands in milliseconds */
maxTimeout: number;
/** Maximum concurrent background processes */
maxConcurrentProcesses: number;
/** Maximum output buffer size in bytes */
maxOutputBuffer: number;
/** Explicitly allowed commands (empty = all allowed with approval) */
allowedCommands: string[];
/** Blocked command patterns */
blockedCommands: string[];
/** Custom environment variables */
environment: Record<string, string>;
/** Working directory (defaults to process.cwd()) */
workingDirectory?: string | undefined;
}
/**
* Output buffer management
*/
export interface OutputBuffer {
stdout: string[];
stderr: string[];
complete: boolean;
lastRead: number; // Timestamp of last read
bytesUsed: number; // Running byte count for O(1) limit checks
truncated?: boolean; // True if content was dropped due to limits
}