tunacode-cli 0.0.34__py3-none-any.whl → 0.0.36__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of tunacode-cli might be problematic. Click here for more details.

@@ -0,0 +1,177 @@
1
+ """System-level commands for TunaCode CLI."""
2
+
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ from typing import List
7
+
8
+ from ....types import CommandContext
9
+ from ....ui import console as ui
10
+ from ..base import CommandCategory, CommandSpec, SimpleCommand
11
+
12
+
13
+ class HelpCommand(SimpleCommand):
14
+ """Show help information."""
15
+
16
+ spec = CommandSpec(
17
+ name="help",
18
+ aliases=["/help"],
19
+ description="Show help information",
20
+ category=CommandCategory.SYSTEM,
21
+ )
22
+
23
+ def __init__(self, command_registry=None):
24
+ self._command_registry = command_registry
25
+
26
+ async def execute(self, args: List[str], context: CommandContext) -> None:
27
+ await ui.help(self._command_registry)
28
+
29
+
30
+ class ClearCommand(SimpleCommand):
31
+ """Clear screen and message history."""
32
+
33
+ spec = CommandSpec(
34
+ name="clear",
35
+ aliases=["/clear"],
36
+ description="Clear the screen and message history",
37
+ category=CommandCategory.NAVIGATION,
38
+ )
39
+
40
+ async def execute(self, args: List[str], context: CommandContext) -> None:
41
+ # Patch any orphaned tool calls before clearing
42
+ from tunacode.core.agents.main import patch_tool_messages
43
+
44
+ patch_tool_messages("Conversation cleared", context.state_manager)
45
+
46
+ await ui.clear()
47
+ context.state_manager.session.messages = []
48
+ context.state_manager.session.files_in_context.clear()
49
+ await ui.success("Message history and file context cleared")
50
+
51
+
52
+ class RefreshConfigCommand(SimpleCommand):
53
+ """Refresh configuration from defaults."""
54
+
55
+ spec = CommandSpec(
56
+ name="refresh",
57
+ aliases=["/refresh"],
58
+ description="Refresh configuration from defaults (useful after updates)",
59
+ category=CommandCategory.SYSTEM,
60
+ )
61
+
62
+ async def execute(self, args: List[str], context: CommandContext) -> None:
63
+ from tunacode.configuration.defaults import DEFAULT_USER_CONFIG
64
+
65
+ # Update current session config with latest defaults
66
+ for key, value in DEFAULT_USER_CONFIG.items():
67
+ if key not in context.state_manager.session.user_config:
68
+ context.state_manager.session.user_config[key] = value
69
+ elif isinstance(value, dict):
70
+ # Merge dict values, preserving user overrides
71
+ for subkey, subvalue in value.items():
72
+ if subkey not in context.state_manager.session.user_config[key]:
73
+ context.state_manager.session.user_config[key][subkey] = subvalue
74
+
75
+ # Show updated max_iterations
76
+ max_iterations = context.state_manager.session.user_config.get("settings", {}).get(
77
+ "max_iterations", 20
78
+ )
79
+ await ui.success(f"Configuration refreshed - max iterations: {max_iterations}")
80
+
81
+
82
+ class UpdateCommand(SimpleCommand):
83
+ """Update TunaCode to the latest version."""
84
+
85
+ spec = CommandSpec(
86
+ name="update",
87
+ aliases=["/update"],
88
+ description="Update TunaCode to the latest version",
89
+ category=CommandCategory.SYSTEM,
90
+ )
91
+
92
+ async def execute(self, args: List[str], context: CommandContext) -> None:
93
+ await ui.info("Checking for TunaCode updates...")
94
+
95
+ # Detect installation method
96
+ installation_method = None
97
+
98
+ # Check if installed via pipx
99
+ if shutil.which("pipx"):
100
+ try:
101
+ result = subprocess.run(
102
+ ["pipx", "list"], capture_output=True, text=True, timeout=10
103
+ )
104
+ pipx_installed = "tunacode" in result.stdout.lower()
105
+ if pipx_installed:
106
+ installation_method = "pipx"
107
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
108
+ pass
109
+
110
+ # Check if installed via pip
111
+ if not installation_method:
112
+ try:
113
+ result = subprocess.run(
114
+ [sys.executable, "-m", "pip", "show", "tunacode-cli"],
115
+ capture_output=True,
116
+ text=True,
117
+ timeout=10,
118
+ )
119
+ if result.returncode == 0:
120
+ installation_method = "pip"
121
+ except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
122
+ pass
123
+
124
+ if not installation_method:
125
+ await ui.error("Could not detect TunaCode installation method")
126
+ await ui.muted("Manual update options:")
127
+ await ui.muted(" pipx: pipx upgrade tunacode")
128
+ await ui.muted(" pip: pip install --upgrade tunacode-cli")
129
+ return
130
+
131
+ # Perform update based on detected method
132
+ try:
133
+ if installation_method == "pipx":
134
+ await ui.info("Updating via pipx...")
135
+ result = subprocess.run(
136
+ ["pipx", "upgrade", "tunacode"],
137
+ capture_output=True,
138
+ text=True,
139
+ timeout=60,
140
+ )
141
+ else: # pip
142
+ await ui.info("Updating via pip...")
143
+ result = subprocess.run(
144
+ [
145
+ sys.executable,
146
+ "-m",
147
+ "pip",
148
+ "install",
149
+ "--upgrade",
150
+ "tunacode-cli",
151
+ ],
152
+ capture_output=True,
153
+ text=True,
154
+ timeout=60,
155
+ )
156
+
157
+ if result.returncode == 0:
158
+ await ui.success("TunaCode updated successfully!")
159
+ await ui.muted("Restart TunaCode to use the new version")
160
+
161
+ # Show update output if available
162
+ if result.stdout.strip():
163
+ output_lines = result.stdout.strip().split("\n")
164
+ for line in output_lines[-5:]: # Show last 5 lines
165
+ if line.strip():
166
+ await ui.muted(f" {line}")
167
+ else:
168
+ await ui.error("Update failed")
169
+ if result.stderr:
170
+ await ui.muted(f"Error: {result.stderr.strip()}")
171
+
172
+ except subprocess.TimeoutExpired:
173
+ await ui.error("Update timed out")
174
+ except subprocess.CalledProcessError as e:
175
+ await ui.error(f"Update failed: {e}")
176
+ except FileNotFoundError:
177
+ await ui.error(f"Could not find {installation_method} executable")
@@ -0,0 +1,229 @@
1
+ """Command registry and factory for TunaCode CLI commands."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, List, Optional, Type
5
+
6
+ from ...exceptions import ValidationError
7
+ from ...types import CommandArgs, CommandContext, ProcessRequestCallback
8
+ from .base import Command, CommandCategory
9
+
10
+ # Import all command implementations
11
+ from .implementations.conversation import CompactCommand
12
+ from .implementations.debug import (
13
+ DumpCommand,
14
+ FixCommand,
15
+ IterationsCommand,
16
+ ParseToolsCommand,
17
+ ThoughtsCommand,
18
+ YoloCommand,
19
+ )
20
+ from .implementations.development import BranchCommand, InitCommand
21
+ from .implementations.model import ModelCommand
22
+ from .implementations.system import ClearCommand, HelpCommand, RefreshConfigCommand, UpdateCommand
23
+
24
+
25
+ @dataclass
26
+ class CommandDependencies:
27
+ """Container for command dependencies."""
28
+
29
+ process_request_callback: Optional[ProcessRequestCallback] = None
30
+ command_registry: Optional[Any] = None # Reference to the registry itself
31
+
32
+
33
+ class CommandFactory:
34
+ """Factory for creating commands with proper dependency injection."""
35
+
36
+ def __init__(self, dependencies: Optional[CommandDependencies] = None):
37
+ self.dependencies = dependencies or CommandDependencies()
38
+
39
+ def create_command(self, command_class: Type[Command]) -> Command:
40
+ """Create a command instance with proper dependencies."""
41
+ # Special handling for commands that need dependencies
42
+ if command_class == CompactCommand:
43
+ return CompactCommand(self.dependencies.process_request_callback)
44
+ elif command_class == HelpCommand:
45
+ return HelpCommand(self.dependencies.command_registry)
46
+
47
+ # Default creation for commands without dependencies
48
+ return command_class()
49
+
50
+ def update_dependencies(self, **kwargs) -> None:
51
+ """Update factory dependencies."""
52
+ for key, value in kwargs.items():
53
+ if hasattr(self.dependencies, key):
54
+ setattr(self.dependencies, key, value)
55
+
56
+
57
+ class CommandRegistry:
58
+ """Registry for managing commands with auto-discovery and categories."""
59
+
60
+ def __init__(self, factory: Optional[CommandFactory] = None):
61
+ self._commands: Dict[str, Command] = {}
62
+ self._categories: Dict[CommandCategory, List[Command]] = {
63
+ category: [] for category in CommandCategory
64
+ }
65
+ self._factory = factory or CommandFactory()
66
+ self._discovered = False
67
+
68
+ # Set registry reference in factory dependencies
69
+ self._factory.update_dependencies(command_registry=self)
70
+
71
+ def register(self, command: Command) -> None:
72
+ """Register a command and its aliases."""
73
+ # Register by primary name
74
+ self._commands[command.name] = command
75
+
76
+ # Register all aliases
77
+ for alias in command.aliases:
78
+ self._commands[alias.lower()] = command
79
+
80
+ # Add to category (remove existing instance first to prevent duplicates)
81
+ category_commands = self._categories[command.category]
82
+ # Remove any existing instance of this command class
83
+ self._categories[command.category] = [
84
+ cmd for cmd in category_commands if cmd.__class__ != command.__class__
85
+ ]
86
+ # Add the new instance
87
+ self._categories[command.category].append(command)
88
+
89
+ def register_command_class(self, command_class: Type[Command]) -> None:
90
+ """Register a command class using the factory."""
91
+ command = self._factory.create_command(command_class)
92
+ self.register(command)
93
+
94
+ def discover_commands(self) -> None:
95
+ """Auto-discover and register all command classes."""
96
+ if self._discovered:
97
+ return
98
+
99
+ # List of all command classes to register
100
+ command_classes = [
101
+ YoloCommand,
102
+ DumpCommand,
103
+ ThoughtsCommand,
104
+ IterationsCommand,
105
+ ClearCommand,
106
+ FixCommand,
107
+ ParseToolsCommand,
108
+ RefreshConfigCommand,
109
+ UpdateCommand,
110
+ HelpCommand,
111
+ BranchCommand,
112
+ CompactCommand,
113
+ ModelCommand,
114
+ InitCommand,
115
+ ]
116
+
117
+ # Register all discovered commands
118
+ for command_class in command_classes:
119
+ self.register_command_class(command_class)
120
+
121
+ self._discovered = True
122
+
123
+ def register_all_default_commands(self) -> None:
124
+ """Register all default commands (backward compatibility)."""
125
+ self.discover_commands()
126
+
127
+ def set_process_request_callback(self, callback: ProcessRequestCallback) -> None:
128
+ """Set the process_request callback for commands that need it."""
129
+ # Only update if callback has changed
130
+ if self._factory.dependencies.process_request_callback == callback:
131
+ return
132
+
133
+ self._factory.update_dependencies(process_request_callback=callback)
134
+
135
+ # Re-register CompactCommand with new dependency if already registered
136
+ if "compact" in self._commands:
137
+ self.register_command_class(CompactCommand)
138
+
139
+ async def execute(self, command_text: str, context: CommandContext) -> Any:
140
+ """
141
+ Execute a command.
142
+
143
+ Args:
144
+ command_text: The full command text
145
+ context: Execution context
146
+
147
+ Returns:
148
+ Command-specific return value, or None if command not found
149
+
150
+ Raises:
151
+ ValidationError: If command is not found or empty
152
+ """
153
+ # Ensure commands are discovered
154
+ self.discover_commands()
155
+
156
+ parts = command_text.split()
157
+ if not parts:
158
+ raise ValidationError("Empty command")
159
+
160
+ command_name = parts[0].lower()
161
+ args = parts[1:]
162
+
163
+ # First try exact match
164
+ if command_name in self._commands:
165
+ command = self._commands[command_name]
166
+ return await command.execute(args, context)
167
+
168
+ # Try partial matching
169
+ matches = self.find_matching_commands(command_name)
170
+
171
+ if not matches:
172
+ raise ValidationError(f"Unknown command: {command_name}")
173
+ elif len(matches) == 1:
174
+ # Unambiguous match
175
+ command = self._commands[matches[0]]
176
+ return await command.execute(args, context)
177
+ else:
178
+ # Ambiguous - show possibilities
179
+ matches_str = ", ".join(sorted(set(matches)))
180
+ raise ValidationError(
181
+ f"Ambiguous command '{command_name}'. Did you mean: {matches_str}?"
182
+ )
183
+
184
+ def find_matching_commands(self, partial_command: str) -> List[str]:
185
+ """
186
+ Find all commands that start with the given partial command.
187
+
188
+ Args:
189
+ partial_command: The partial command to match
190
+
191
+ Returns:
192
+ List of matching command names
193
+ """
194
+ self.discover_commands()
195
+ partial = partial_command.lower()
196
+ return [cmd for cmd in self._commands.keys() if cmd.startswith(partial)]
197
+
198
+ def is_command(self, text: str) -> bool:
199
+ """Check if text starts with a registered command (supports partial matching)."""
200
+ if not text:
201
+ return False
202
+
203
+ parts = text.split()
204
+ if not parts:
205
+ return False
206
+
207
+ command_name = parts[0].lower()
208
+
209
+ # Check exact match first
210
+ if command_name in self._commands:
211
+ return True
212
+
213
+ # Check partial match
214
+ return len(self.find_matching_commands(command_name)) > 0
215
+
216
+ def get_command_names(self) -> CommandArgs:
217
+ """Get all registered command names (including aliases)."""
218
+ self.discover_commands()
219
+ return sorted(self._commands.keys())
220
+
221
+ def get_commands_by_category(self, category: CommandCategory) -> List[Command]:
222
+ """Get all commands in a specific category."""
223
+ self.discover_commands()
224
+ return self._categories.get(category, [])
225
+
226
+ def get_all_categories(self) -> Dict[CommandCategory, List[Command]]:
227
+ """Get all commands organized by category."""
228
+ self.discover_commands()
229
+ return self._categories.copy()
tunacode/cli/repl.py CHANGED
@@ -22,6 +22,7 @@ from tunacode.core.tool_handler import ToolHandler
22
22
  from tunacode.exceptions import AgentError, UserAbortError, ValidationError
23
23
  from tunacode.ui import console as ui
24
24
  from tunacode.ui.tool_ui import ToolUI
25
+ from tunacode.utils.security import CommandSecurityError, safe_subprocess_run
25
26
 
26
27
  from ..types import CommandContext, CommandResult, StateManager, ToolArgs
27
28
  from .commands import CommandRegistry
@@ -320,13 +321,24 @@ async def repl(state_manager: StateManager):
320
321
  def run_shell():
321
322
  try:
322
323
  if command:
323
- result = subprocess.run(command, shell=True, capture_output=False)
324
- if result.returncode != 0:
325
- # Use print directly since we're in a terminal context
326
- print(f"\nCommand exited with code {result.returncode}")
324
+ # Use secure subprocess execution for shell commands
325
+ # Note: User shell commands are inherently risky but this is by design
326
+ # We validate but allow shell features since it's explicit user intent
327
+ try:
328
+ result = safe_subprocess_run(
329
+ command,
330
+ shell=True,
331
+ validate=True, # Still validate for basic safety
332
+ capture_output=False,
333
+ )
334
+ if result.returncode != 0:
335
+ print(f"\nCommand exited with code {result.returncode}")
336
+ except CommandSecurityError as e:
337
+ print(f"\nSecurity validation failed: {str(e)}")
338
+ print("If you need to run this command, please ensure it's safe.")
327
339
  else:
328
340
  shell = os.environ.get("SHELL", "bash")
329
- subprocess.run(shell)
341
+ subprocess.run(shell) # Interactive shell is safe
330
342
  except Exception as e:
331
343
  print(f"\nShell command failed: {str(e)}")
332
344
 
@@ -7,8 +7,15 @@ Handles configuration paths, model registries, and application metadata.
7
7
 
8
8
  from pathlib import Path
9
9
 
10
- from tunacode.constants import (APP_NAME, APP_VERSION, CONFIG_FILE_NAME, TOOL_READ_FILE,
11
- TOOL_RUN_COMMAND, TOOL_UPDATE_FILE, TOOL_WRITE_FILE)
10
+ from tunacode.constants import (
11
+ APP_NAME,
12
+ APP_VERSION,
13
+ CONFIG_FILE_NAME,
14
+ TOOL_READ_FILE,
15
+ TOOL_RUN_COMMAND,
16
+ TOOL_UPDATE_FILE,
17
+ TOOL_WRITE_FILE,
18
+ )
12
19
  from tunacode.types import ConfigFile, ConfigPath, ToolName
13
20
 
14
21
 
tunacode/constants.py CHANGED
@@ -7,7 +7,7 @@ Centralizes all magic strings, UI text, error messages, and application constant
7
7
 
8
8
  # Application info
9
9
  APP_NAME = "TunaCode"
10
- APP_VERSION = "0.0.34"
10
+ APP_VERSION = "0.0.36"
11
11
 
12
12
  # File patterns
13
13
  GUIDE_FILE_PATTERN = "{name}.md"
@@ -23,8 +23,18 @@ from tunacode.tools.read_file import read_file
23
23
  from tunacode.tools.run_command import run_command
24
24
  from tunacode.tools.update_file import update_file
25
25
  from tunacode.tools.write_file import write_file
26
- from tunacode.types import (AgentRun, ErrorMessage, FallbackResponse, ModelName, PydanticAgent,
27
- ResponseState, SimpleResult, ToolCallback, ToolCallId, ToolName)
26
+ from tunacode.types import (
27
+ AgentRun,
28
+ ErrorMessage,
29
+ FallbackResponse,
30
+ ModelName,
31
+ PydanticAgent,
32
+ ResponseState,
33
+ SimpleResult,
34
+ ToolCallback,
35
+ ToolCallId,
36
+ ToolName,
37
+ )
28
38
 
29
39
 
30
40
  class ToolBuffer:
tunacode/core/state.py CHANGED
@@ -8,8 +8,15 @@ import uuid
8
8
  from dataclasses import dataclass, field
9
9
  from typing import Any, Optional
10
10
 
11
- from tunacode.types import (DeviceId, InputSessions, MessageHistory, ModelName, SessionId, ToolName,
12
- UserConfig)
11
+ from tunacode.types import (
12
+ DeviceId,
13
+ InputSessions,
14
+ MessageHistory,
15
+ ModelName,
16
+ SessionId,
17
+ ToolName,
18
+ UserConfig,
19
+ )
13
20
 
14
21
 
15
22
  @dataclass
tunacode/setup.py CHANGED
@@ -7,8 +7,13 @@ Provides high-level setup functions for initializing the application and its age
7
7
 
8
8
  from typing import Any, Optional
9
9
 
10
- from tunacode.core.setup import (AgentSetup, ConfigSetup, EnvironmentSetup, GitSafetySetup,
11
- SetupCoordinator)
10
+ from tunacode.core.setup import (
11
+ AgentSetup,
12
+ ConfigSetup,
13
+ EnvironmentSetup,
14
+ GitSafetySetup,
15
+ SetupCoordinator,
16
+ )
12
17
  from tunacode.core.state import StateManager
13
18
 
14
19
 
@@ -8,8 +8,14 @@ Provides safe file reading with size limits and proper error handling.
8
8
  import asyncio
9
9
  import os
10
10
 
11
- from tunacode.constants import (ERROR_FILE_DECODE, ERROR_FILE_DECODE_DETAILS, ERROR_FILE_NOT_FOUND,
12
- ERROR_FILE_TOO_LARGE, MAX_FILE_SIZE, MSG_FILE_SIZE_LIMIT)
11
+ from tunacode.constants import (
12
+ ERROR_FILE_DECODE,
13
+ ERROR_FILE_DECODE_DETAILS,
14
+ ERROR_FILE_NOT_FOUND,
15
+ ERROR_FILE_TOO_LARGE,
16
+ MAX_FILE_SIZE,
17
+ MSG_FILE_SIZE_LIMIT,
18
+ )
13
19
  from tunacode.exceptions import ToolExecutionError
14
20
  from tunacode.tools.base import FileBasedTool
15
21
  from tunacode.types import ToolResult
@@ -11,8 +11,14 @@ import sys
11
11
  from concurrent.futures import ThreadPoolExecutor
12
12
  from typing import Optional
13
13
 
14
- from tunacode.constants import (ERROR_FILE_DECODE, ERROR_FILE_DECODE_DETAILS, ERROR_FILE_NOT_FOUND,
15
- ERROR_FILE_TOO_LARGE, MAX_FILE_SIZE, MSG_FILE_SIZE_LIMIT)
14
+ from tunacode.constants import (
15
+ ERROR_FILE_DECODE,
16
+ ERROR_FILE_DECODE_DETAILS,
17
+ ERROR_FILE_NOT_FOUND,
18
+ ERROR_FILE_TOO_LARGE,
19
+ MAX_FILE_SIZE,
20
+ MSG_FILE_SIZE_LIMIT,
21
+ )
16
22
  from tunacode.exceptions import ToolExecutionError
17
23
  from tunacode.tools.base import FileBasedTool
18
24
  from tunacode.types import ToolResult
@@ -150,17 +156,18 @@ async def read_file_async(filepath: str) -> str:
150
156
  # Benchmarking utilities for testing
151
157
  async def benchmark_read_performance():
152
158
  """Benchmark the performance difference between sync and async reads."""
159
+ import contextlib
160
+ import tempfile
153
161
  import time
154
162
 
155
163
  from tunacode.tools.read_file import read_file as read_file_sync
156
164
 
157
- # Create some test files
165
+ # Create some test files using tempfile for secure temporary file creation
158
166
  test_files = []
159
- for i in range(10):
160
- filepath = f"/tmp/test_file_{i}.txt"
161
- with open(filepath, "w") as f:
162
- f.write("x" * 10000) # 10KB file
163
- test_files.append(filepath)
167
+ for _ in range(10):
168
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as temp_file:
169
+ temp_file.write("x" * 10000) # 10KB file
170
+ test_files.append(temp_file.name)
164
171
 
165
172
  # Test synchronous reads (sequential)
166
173
  start_time = time.time()
@@ -174,9 +181,10 @@ async def benchmark_read_performance():
174
181
  await asyncio.gather(*tasks)
175
182
  async_time = time.time() - start_time
176
183
 
177
- # Cleanup
184
+ # Cleanup using safe file removal
178
185
  for filepath in test_files:
179
- os.unlink(filepath)
186
+ with contextlib.suppress(OSError):
187
+ os.unlink(filepath)
180
188
 
181
189
  print(f"Synchronous reads: {sync_time:.3f}s")
182
190
  print(f"Async reads: {async_time:.3f}s")
@@ -7,13 +7,21 @@ Provides controlled shell command execution with output capture and truncation.
7
7
 
8
8
  import subprocess
9
9
 
10
- from tunacode.constants import (CMD_OUTPUT_FORMAT, CMD_OUTPUT_NO_ERRORS, CMD_OUTPUT_NO_OUTPUT,
11
- CMD_OUTPUT_TRUNCATED, COMMAND_OUTPUT_END_SIZE,
12
- COMMAND_OUTPUT_START_INDEX, COMMAND_OUTPUT_THRESHOLD,
13
- ERROR_COMMAND_EXECUTION, MAX_COMMAND_OUTPUT)
10
+ from tunacode.constants import (
11
+ CMD_OUTPUT_FORMAT,
12
+ CMD_OUTPUT_NO_ERRORS,
13
+ CMD_OUTPUT_NO_OUTPUT,
14
+ CMD_OUTPUT_TRUNCATED,
15
+ COMMAND_OUTPUT_END_SIZE,
16
+ COMMAND_OUTPUT_START_INDEX,
17
+ COMMAND_OUTPUT_THRESHOLD,
18
+ ERROR_COMMAND_EXECUTION,
19
+ MAX_COMMAND_OUTPUT,
20
+ )
14
21
  from tunacode.exceptions import ToolExecutionError
15
22
  from tunacode.tools.base import BaseTool
16
23
  from tunacode.types import ToolResult
24
+ from tunacode.utils.security import CommandSecurityError, safe_subprocess_popen
17
25
 
18
26
 
19
27
  class RunCommandTool(BaseTool):
@@ -34,16 +42,23 @@ class RunCommandTool(BaseTool):
34
42
 
35
43
  Raises:
36
44
  FileNotFoundError: If command not found
45
+ CommandSecurityError: If command fails security validation
37
46
  Exception: Any command execution errors
38
47
  """
39
- process = subprocess.Popen(
40
- command,
41
- shell=True,
42
- stdout=subprocess.PIPE,
43
- stderr=subprocess.PIPE,
44
- text=True,
45
- )
46
- stdout, stderr = process.communicate()
48
+ try:
49
+ # Use secure subprocess execution with validation
50
+ process = safe_subprocess_popen(
51
+ command,
52
+ shell=True, # CLI tool requires shell features
53
+ validate=True, # Enable security validation
54
+ stdout=subprocess.PIPE,
55
+ stderr=subprocess.PIPE,
56
+ text=True,
57
+ )
58
+ stdout, stderr = process.communicate()
59
+ except CommandSecurityError as e:
60
+ # Security validation failed - return error without execution
61
+ return f"Security validation failed: {str(e)}"
47
62
  output = stdout.strip() or CMD_OUTPUT_NO_OUTPUT
48
63
  error = stderr.strip() or CMD_OUTPUT_NO_ERRORS
49
64
  resp = CMD_OUTPUT_FORMAT.format(output=output, error=error).strip()
@@ -70,6 +85,8 @@ class RunCommandTool(BaseTool):
70
85
  """
71
86
  if isinstance(error, FileNotFoundError):
72
87
  err_msg = ERROR_COMMAND_EXECUTION.format(command=command, error=error)
88
+ elif isinstance(error, CommandSecurityError):
89
+ err_msg = f"Command blocked for security: {str(error)}"
73
90
  else:
74
91
  # Use parent class handling for other errors
75
92
  await super()._handle_error(error, command)