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:
192
dexto/packages/tools-process/src/bash-exec-tool.ts
Normal file
192
dexto/packages/tools-process/src/bash-exec-tool.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
44
dexto/packages/tools-process/src/bash-output-tool.ts
Normal file
44
dexto/packages/tools-process/src/bash-output-tool.ts
Normal 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 }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
472
dexto/packages/tools-process/src/command-validator.ts
Normal file
472
dexto/packages/tools-process/src/command-validator.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
32
dexto/packages/tools-process/src/error-codes.ts
Normal file
32
dexto/packages/tools-process/src/error-codes.ts
Normal 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',
|
||||
}
|
||||
254
dexto/packages/tools-process/src/errors.ts
Normal file
254
dexto/packages/tools-process/src/errors.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
32
dexto/packages/tools-process/src/index.ts
Normal file
32
dexto/packages/tools-process/src/index.ts
Normal 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';
|
||||
43
dexto/packages/tools-process/src/kill-process-tool.ts
Normal file
43
dexto/packages/tools-process/src/kill-process-tool.ts
Normal 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}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
688
dexto/packages/tools-process/src/process-service.ts
Normal file
688
dexto/packages/tools-process/src/process-service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
161
dexto/packages/tools-process/src/tool-provider.ts
Normal file
161
dexto/packages/tools-process/src/tool-provider.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
114
dexto/packages/tools-process/src/types.ts
Normal file
114
dexto/packages/tools-process/src/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user