tunacode-cli 0.0.33__py3-none-any.whl → 0.0.35__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.
- tunacode/cli/commands.py +75 -25
- tunacode/cli/repl.py +17 -5
- tunacode/constants.py +1 -1
- tunacode/core/agents/main.py +28 -0
- tunacode/core/setup/config_setup.py +15 -9
- tunacode/tools/run_command.py +18 -8
- tunacode/utils/security.py +208 -0
- tunacode/utils/user_configuration.py +20 -3
- {tunacode_cli-0.0.33.dist-info → tunacode_cli-0.0.35.dist-info}/METADATA +20 -1
- {tunacode_cli-0.0.33.dist-info → tunacode_cli-0.0.35.dist-info}/RECORD +14 -13
- {tunacode_cli-0.0.33.dist-info → tunacode_cli-0.0.35.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.33.dist-info → tunacode_cli-0.0.35.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.33.dist-info → tunacode_cli-0.0.35.dist-info}/licenses/LICENSE +0 -0
- {tunacode_cli-0.0.33.dist-info → tunacode_cli-0.0.35.dist-info}/top_level.txt +0 -0
tunacode/cli/commands.py
CHANGED
|
@@ -6,7 +6,7 @@ from enum import Enum
|
|
|
6
6
|
from typing import Any, Dict, List, Optional, Type
|
|
7
7
|
|
|
8
8
|
from .. import utils
|
|
9
|
-
from ..exceptions import ValidationError
|
|
9
|
+
from ..exceptions import ConfigurationError, ValidationError
|
|
10
10
|
from ..types import CommandArgs, CommandContext, CommandResult, ProcessRequestCallback
|
|
11
11
|
from ..ui import console as ui
|
|
12
12
|
|
|
@@ -181,26 +181,29 @@ class IterationsCommand(SimpleCommand):
|
|
|
181
181
|
|
|
182
182
|
async def execute(self, args: List[str], context: CommandContext) -> None:
|
|
183
183
|
state = context.state_manager.session
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if new_limit < 1 or new_limit > 100:
|
|
188
|
-
await ui.error("Iterations must be between 1 and 100")
|
|
189
|
-
return
|
|
190
|
-
|
|
191
|
-
# Update the user config
|
|
192
|
-
if "settings" not in state.user_config:
|
|
193
|
-
state.user_config["settings"] = {}
|
|
194
|
-
state.user_config["settings"]["max_iterations"] = new_limit
|
|
195
|
-
|
|
196
|
-
await ui.success(f"Maximum iterations set to {new_limit}")
|
|
197
|
-
await ui.muted("Higher values allow more complex reasoning but may be slower")
|
|
198
|
-
except ValueError:
|
|
199
|
-
await ui.error("Please provide a valid number")
|
|
200
|
-
else:
|
|
184
|
+
|
|
185
|
+
# Guard clause - handle "no args" case first and return early
|
|
186
|
+
if not args:
|
|
201
187
|
current = state.user_config.get("settings", {}).get("max_iterations", 40)
|
|
202
188
|
await ui.info(f"Current maximum iterations: {current}")
|
|
203
189
|
await ui.muted("Usage: /iterations <number> (1-100)")
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
# update the logic to not be as nested messely, the above guars needing to get as messy
|
|
193
|
+
try:
|
|
194
|
+
new_limit = int(args[0])
|
|
195
|
+
if new_limit < 1 or new_limit > 100:
|
|
196
|
+
await ui.error("Iterations must be between 1 and 100")
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
# Update the user config
|
|
200
|
+
if "settings" not in state.user_config:
|
|
201
|
+
state.user_config["settings"] = {}
|
|
202
|
+
state.user_config["settings"]["max_iterations"] = new_limit
|
|
203
|
+
|
|
204
|
+
await ui.success(f"Maximum iterations set to {new_limit}")
|
|
205
|
+
except ValueError:
|
|
206
|
+
await ui.error("Please provide a valid number")
|
|
204
207
|
|
|
205
208
|
|
|
206
209
|
class ClearCommand(SimpleCommand):
|
|
@@ -288,7 +291,9 @@ class ParseToolsCommand(SimpleCommand):
|
|
|
288
291
|
|
|
289
292
|
try:
|
|
290
293
|
await extract_and_execute_tool_calls(
|
|
291
|
-
part.content,
|
|
294
|
+
part.content,
|
|
295
|
+
tool_callback_with_state,
|
|
296
|
+
context.state_manager,
|
|
292
297
|
)
|
|
293
298
|
await ui.success("JSON tool parsing completed")
|
|
294
299
|
found_content = True
|
|
@@ -524,7 +529,8 @@ class UpdateCommand(SimpleCommand):
|
|
|
524
529
|
result = subprocess.run(
|
|
525
530
|
["pipx", "list"], capture_output=True, text=True, timeout=10
|
|
526
531
|
)
|
|
527
|
-
|
|
532
|
+
pipx_installed = "tunacode" in result.stdout.lower()
|
|
533
|
+
if pipx_installed:
|
|
528
534
|
installation_method = "pipx"
|
|
529
535
|
except (subprocess.TimeoutExpired, subprocess.CalledProcessError):
|
|
530
536
|
pass
|
|
@@ -555,12 +561,22 @@ class UpdateCommand(SimpleCommand):
|
|
|
555
561
|
if installation_method == "pipx":
|
|
556
562
|
await ui.info("Updating via pipx...")
|
|
557
563
|
result = subprocess.run(
|
|
558
|
-
["pipx", "upgrade", "tunacode"],
|
|
564
|
+
["pipx", "upgrade", "tunacode"],
|
|
565
|
+
capture_output=True,
|
|
566
|
+
text=True,
|
|
567
|
+
timeout=60,
|
|
559
568
|
)
|
|
560
569
|
else: # pip
|
|
561
570
|
await ui.info("Updating via pip...")
|
|
562
571
|
result = subprocess.run(
|
|
563
|
-
[
|
|
572
|
+
[
|
|
573
|
+
sys.executable,
|
|
574
|
+
"-m",
|
|
575
|
+
"pip",
|
|
576
|
+
"install",
|
|
577
|
+
"--upgrade",
|
|
578
|
+
"tunacode-cli",
|
|
579
|
+
],
|
|
564
580
|
capture_output=True,
|
|
565
581
|
text=True,
|
|
566
582
|
timeout=60,
|
|
@@ -628,9 +644,13 @@ class ModelCommand(SimpleCommand):
|
|
|
628
644
|
|
|
629
645
|
# Check if setting as default
|
|
630
646
|
if len(args) > 1 and args[1] == "default":
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
647
|
+
try:
|
|
648
|
+
utils.user_configuration.set_default_model(model_name, context.state_manager)
|
|
649
|
+
await ui.muted("Updating default model")
|
|
650
|
+
return "restart"
|
|
651
|
+
except ConfigurationError as e:
|
|
652
|
+
await ui.error(str(e))
|
|
653
|
+
return None
|
|
634
654
|
|
|
635
655
|
# Show success message with the new model
|
|
636
656
|
await ui.success(f"Switched to model: {model_name}")
|
|
@@ -669,6 +689,35 @@ class CommandFactory:
|
|
|
669
689
|
setattr(self.dependencies, key, value)
|
|
670
690
|
|
|
671
691
|
|
|
692
|
+
class InitCommand(SimpleCommand):
|
|
693
|
+
"""Creates or updates TUNACODE.md with project-specific context."""
|
|
694
|
+
|
|
695
|
+
spec = CommandSpec(
|
|
696
|
+
name="/init",
|
|
697
|
+
aliases=[],
|
|
698
|
+
description="Analyze codebase and create/update TUNACODE.md file",
|
|
699
|
+
category=CommandCategory.DEVELOPMENT,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
async def execute(self, args, context: CommandContext) -> CommandResult:
|
|
703
|
+
"""Execute the init command."""
|
|
704
|
+
# Minimal implementation to make test pass
|
|
705
|
+
prompt = """Please analyze this codebase and create a TUNACODE.md file containing:
|
|
706
|
+
1. Build/lint/test commands - especially for running a single test
|
|
707
|
+
2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
|
|
708
|
+
|
|
709
|
+
The file you create will be given to agentic coding agents (such as yourself) that operate in this repository.
|
|
710
|
+
Make it about 20 lines long.
|
|
711
|
+
If there's already a TUNACODE.md, improve it.
|
|
712
|
+
If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md),
|
|
713
|
+
make sure to include them."""
|
|
714
|
+
|
|
715
|
+
# Call the agent to analyze and create/update the file
|
|
716
|
+
await context.process_request(prompt, context.state_manager)
|
|
717
|
+
|
|
718
|
+
return None
|
|
719
|
+
|
|
720
|
+
|
|
672
721
|
class CommandRegistry:
|
|
673
722
|
"""Registry for managing commands with auto-discovery and categories."""
|
|
674
723
|
|
|
@@ -726,6 +775,7 @@ class CommandRegistry:
|
|
|
726
775
|
BranchCommand,
|
|
727
776
|
CompactCommand,
|
|
728
777
|
ModelCommand,
|
|
778
|
+
InitCommand,
|
|
729
779
|
]
|
|
730
780
|
|
|
731
781
|
# Register all discovered commands
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
tunacode/constants.py
CHANGED
tunacode/core/agents/main.py
CHANGED
|
@@ -457,6 +457,26 @@ def get_or_create_agent(model: ModelName, state_manager: StateManager) -> Pydant
|
|
|
457
457
|
# Use a default system prompt if neither file exists
|
|
458
458
|
system_prompt = "You are a helpful AI assistant for software development tasks."
|
|
459
459
|
|
|
460
|
+
# Load TUNACODE.md context
|
|
461
|
+
# Use sync version of get_code_style to avoid nested event loop issues
|
|
462
|
+
try:
|
|
463
|
+
from pathlib import Path as PathlibPath
|
|
464
|
+
|
|
465
|
+
tunacode_path = PathlibPath.cwd() / "TUNACODE.md"
|
|
466
|
+
if tunacode_path.exists():
|
|
467
|
+
tunacode_content = tunacode_path.read_text(encoding="utf-8")
|
|
468
|
+
if tunacode_content.strip():
|
|
469
|
+
# Log that we found TUNACODE.md
|
|
470
|
+
print("📄 TUNACODE.md located: Loading context...")
|
|
471
|
+
|
|
472
|
+
system_prompt += "\n\n# Project Context from TUNACODE.md\n" + tunacode_content
|
|
473
|
+
else:
|
|
474
|
+
# Log that TUNACODE.md was not found
|
|
475
|
+
print("📄 TUNACODE.md not found: Using default context")
|
|
476
|
+
except Exception:
|
|
477
|
+
# Ignore errors loading TUNACODE.md
|
|
478
|
+
pass
|
|
479
|
+
|
|
460
480
|
state_manager.session.agents[model] = Agent(
|
|
461
481
|
model=model,
|
|
462
482
|
system_prompt=system_prompt,
|
|
@@ -670,6 +690,14 @@ async def process_request(
|
|
|
670
690
|
# Create a request-level buffer for batching read-only tools across nodes
|
|
671
691
|
tool_buffer = ToolBuffer()
|
|
672
692
|
|
|
693
|
+
# Show TUNACODE.md preview if it was loaded and thoughts are enabled
|
|
694
|
+
if state_manager.session.show_thoughts and hasattr(state_manager, "tunacode_preview"):
|
|
695
|
+
from tunacode.ui import console as ui
|
|
696
|
+
|
|
697
|
+
await ui.muted(state_manager.tunacode_preview)
|
|
698
|
+
# Clear the preview after displaying it once
|
|
699
|
+
delattr(state_manager, "tunacode_preview")
|
|
700
|
+
|
|
673
701
|
# Show what we're sending to the API when thoughts are enabled
|
|
674
702
|
if state_manager.session.show_thoughts:
|
|
675
703
|
from tunacode.ui import console as ui
|
|
@@ -82,9 +82,13 @@ class ConfigSetup(BaseSetup):
|
|
|
82
82
|
):
|
|
83
83
|
self.state_manager.session.user_config = {}
|
|
84
84
|
self.state_manager.session.user_config = DEFAULT_USER_CONFIG.copy()
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
try:
|
|
86
|
+
user_configuration.save_config(
|
|
87
|
+
self.state_manager
|
|
88
|
+
) # Save the default config initially
|
|
89
|
+
except ConfigurationError as e:
|
|
90
|
+
await ui.error(str(e))
|
|
91
|
+
raise
|
|
88
92
|
await self._onboarding()
|
|
89
93
|
else:
|
|
90
94
|
# No config found - show CLI usage instead of onboarding
|
|
@@ -172,11 +176,12 @@ class ConfigSetup(BaseSetup):
|
|
|
172
176
|
# Compare configs to see if anything changed
|
|
173
177
|
current_config = json.dumps(self.state_manager.session.user_config, sort_keys=True)
|
|
174
178
|
if initial_config != current_config:
|
|
175
|
-
|
|
179
|
+
try:
|
|
180
|
+
user_configuration.save_config(self.state_manager)
|
|
176
181
|
message = f"Config saved to: [bold]{self.config_file}[/bold]"
|
|
177
182
|
await ui.panel("Finished", message, top=0, border_style=UI_COLORS["success"])
|
|
178
|
-
|
|
179
|
-
await ui.error(
|
|
183
|
+
except ConfigurationError as e:
|
|
184
|
+
await ui.error(str(e))
|
|
180
185
|
else:
|
|
181
186
|
await ui.panel(
|
|
182
187
|
"Setup canceled",
|
|
@@ -320,8 +325,9 @@ class ConfigSetup(BaseSetup):
|
|
|
320
325
|
]
|
|
321
326
|
|
|
322
327
|
# Save the configuration
|
|
323
|
-
|
|
328
|
+
try:
|
|
329
|
+
user_configuration.save_config(self.state_manager)
|
|
324
330
|
await ui.warning("Model set without validation - verify the model name is correct")
|
|
325
331
|
await ui.success(f"Configuration saved to: {self.config_file}")
|
|
326
|
-
|
|
327
|
-
await ui.error(
|
|
332
|
+
except ConfigurationError as e:
|
|
333
|
+
await ui.error(str(e))
|
tunacode/tools/run_command.py
CHANGED
|
@@ -14,6 +14,7 @@ from tunacode.constants import (CMD_OUTPUT_FORMAT, CMD_OUTPUT_NO_ERRORS, CMD_OUT
|
|
|
14
14
|
from tunacode.exceptions import ToolExecutionError
|
|
15
15
|
from tunacode.tools.base import BaseTool
|
|
16
16
|
from tunacode.types import ToolResult
|
|
17
|
+
from tunacode.utils.security import CommandSecurityError, safe_subprocess_popen
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
class RunCommandTool(BaseTool):
|
|
@@ -34,16 +35,23 @@ class RunCommandTool(BaseTool):
|
|
|
34
35
|
|
|
35
36
|
Raises:
|
|
36
37
|
FileNotFoundError: If command not found
|
|
38
|
+
CommandSecurityError: If command fails security validation
|
|
37
39
|
Exception: Any command execution errors
|
|
38
40
|
"""
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
try:
|
|
42
|
+
# Use secure subprocess execution with validation
|
|
43
|
+
process = safe_subprocess_popen(
|
|
44
|
+
command,
|
|
45
|
+
shell=True, # CLI tool requires shell features
|
|
46
|
+
validate=True, # Enable security validation
|
|
47
|
+
stdout=subprocess.PIPE,
|
|
48
|
+
stderr=subprocess.PIPE,
|
|
49
|
+
text=True,
|
|
50
|
+
)
|
|
51
|
+
stdout, stderr = process.communicate()
|
|
52
|
+
except CommandSecurityError as e:
|
|
53
|
+
# Security validation failed - return error without execution
|
|
54
|
+
return f"Security validation failed: {str(e)}"
|
|
47
55
|
output = stdout.strip() or CMD_OUTPUT_NO_OUTPUT
|
|
48
56
|
error = stderr.strip() or CMD_OUTPUT_NO_ERRORS
|
|
49
57
|
resp = CMD_OUTPUT_FORMAT.format(output=output, error=error).strip()
|
|
@@ -70,6 +78,8 @@ class RunCommandTool(BaseTool):
|
|
|
70
78
|
"""
|
|
71
79
|
if isinstance(error, FileNotFoundError):
|
|
72
80
|
err_msg = ERROR_COMMAND_EXECUTION.format(command=command, error=error)
|
|
81
|
+
elif isinstance(error, CommandSecurityError):
|
|
82
|
+
err_msg = f"Command blocked for security: {str(error)}"
|
|
73
83
|
else:
|
|
74
84
|
# Use parent class handling for other errors
|
|
75
85
|
await super()._handle_error(error, command)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Security utilities for safe command execution and input validation.
|
|
3
|
+
Provides defensive measures against command injection attacks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Dangerous shell metacharacters that indicate potential injection
|
|
15
|
+
DANGEROUS_CHARS = [
|
|
16
|
+
";",
|
|
17
|
+
"&",
|
|
18
|
+
"|",
|
|
19
|
+
"`",
|
|
20
|
+
"$",
|
|
21
|
+
"(",
|
|
22
|
+
")",
|
|
23
|
+
"{",
|
|
24
|
+
"}",
|
|
25
|
+
"<",
|
|
26
|
+
">",
|
|
27
|
+
"\n",
|
|
28
|
+
"\r",
|
|
29
|
+
"\\",
|
|
30
|
+
'"',
|
|
31
|
+
"'",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# Common injection patterns
|
|
35
|
+
INJECTION_PATTERNS = [
|
|
36
|
+
r";\s*\w+", # Command chaining with semicolon
|
|
37
|
+
r"&&\s*\w+", # Command chaining with &&
|
|
38
|
+
r"\|\s*\w+", # Piping to another command
|
|
39
|
+
r"`[^`]+`", # Command substitution with backticks
|
|
40
|
+
r"\$\([^)]+\)", # Command substitution with $()
|
|
41
|
+
r">\s*[/\w]", # Output redirection
|
|
42
|
+
r"<\s*[/\w]", # Input redirection
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CommandSecurityError(Exception):
|
|
47
|
+
"""Raised when a command fails security validation."""
|
|
48
|
+
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_command_safety(command: str, allow_shell_features: bool = False) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Validate that a command is safe to execute.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
command: The command string to validate
|
|
58
|
+
allow_shell_features: If True, allows some shell features like pipes
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
CommandSecurityError: If the command contains potentially dangerous patterns
|
|
62
|
+
"""
|
|
63
|
+
if not command or not command.strip():
|
|
64
|
+
raise CommandSecurityError("Empty command not allowed")
|
|
65
|
+
|
|
66
|
+
# Log the command being validated
|
|
67
|
+
logger.info(f"Validating command: {command[:100]}...")
|
|
68
|
+
|
|
69
|
+
# Always check for the most dangerous patterns regardless of shell features
|
|
70
|
+
dangerous_patterns = [
|
|
71
|
+
r"rm\s+-rf\s+/", # Dangerous rm commands
|
|
72
|
+
r"sudo\s+rm", # Sudo rm commands
|
|
73
|
+
r">\s*/dev/sd[a-z]", # Writing to disk devices
|
|
74
|
+
r"dd\s+.*of=/dev/", # DD to devices
|
|
75
|
+
r"mkfs\.", # Format filesystem
|
|
76
|
+
r"fdisk", # Partition manipulation
|
|
77
|
+
r":\(\)\{.*\}\;", # Fork bomb pattern
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
for pattern in dangerous_patterns:
|
|
81
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
82
|
+
logger.error(f"Highly dangerous pattern '{pattern}' detected in command")
|
|
83
|
+
raise CommandSecurityError("Command contains dangerous pattern and is blocked")
|
|
84
|
+
|
|
85
|
+
if not allow_shell_features:
|
|
86
|
+
# Check for dangerous characters (but allow some for CLI tools)
|
|
87
|
+
restricted_chars = [";", "&", "`", "$", "{", "}"] # More permissive for CLI
|
|
88
|
+
for char in restricted_chars:
|
|
89
|
+
if char in command:
|
|
90
|
+
logger.warning(f"Potentially dangerous character '{char}' detected in command")
|
|
91
|
+
raise CommandSecurityError(f"Potentially unsafe character '{char}' in command")
|
|
92
|
+
|
|
93
|
+
# Check for injection patterns (more selective)
|
|
94
|
+
strict_patterns = [
|
|
95
|
+
r";\s*rm\s+", # Command chaining to rm
|
|
96
|
+
r"&&\s*rm\s+", # Command chaining to rm
|
|
97
|
+
r"`[^`]*rm[^`]*`", # Command substitution with rm
|
|
98
|
+
r"\$\([^)]*rm[^)]*\)", # Command substitution with rm
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
for pattern in strict_patterns:
|
|
102
|
+
if re.search(pattern, command):
|
|
103
|
+
logger.warning(f"Dangerous injection pattern '{pattern}' detected in command")
|
|
104
|
+
raise CommandSecurityError("Potentially unsafe pattern detected in command")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def sanitize_command_args(args: List[str]) -> List[str]:
|
|
108
|
+
"""
|
|
109
|
+
Sanitize command arguments by shell-quoting them.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
args: List of command arguments
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
List of sanitized arguments
|
|
116
|
+
"""
|
|
117
|
+
return [shlex.quote(arg) for arg in args]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def safe_subprocess_run(
|
|
121
|
+
command: str,
|
|
122
|
+
shell: bool = False,
|
|
123
|
+
validate: bool = True,
|
|
124
|
+
timeout: Optional[int] = None,
|
|
125
|
+
**kwargs,
|
|
126
|
+
) -> subprocess.CompletedProcess:
|
|
127
|
+
"""
|
|
128
|
+
Safely execute a subprocess with security validation.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
command: Command to execute (string if shell=True, list if shell=False)
|
|
132
|
+
shell: Whether to use shell execution (discouraged)
|
|
133
|
+
validate: Whether to validate command safety
|
|
134
|
+
timeout: Timeout in seconds
|
|
135
|
+
**kwargs: Additional subprocess arguments
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
CompletedProcess result
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
CommandSecurityError: If command fails security validation
|
|
142
|
+
"""
|
|
143
|
+
if validate and shell and isinstance(command, str):
|
|
144
|
+
validate_command_safety(command, allow_shell_features=shell)
|
|
145
|
+
|
|
146
|
+
# Log the command execution
|
|
147
|
+
logger.info(f"Executing command: {str(command)[:100]}...")
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
if shell:
|
|
151
|
+
# When using shell=True, command should be a string
|
|
152
|
+
result = subprocess.run(command, shell=True, timeout=timeout, **kwargs)
|
|
153
|
+
else:
|
|
154
|
+
# When shell=False, command should be a list
|
|
155
|
+
if isinstance(command, str):
|
|
156
|
+
# Parse the string into a list
|
|
157
|
+
command_list = shlex.split(command)
|
|
158
|
+
else:
|
|
159
|
+
command_list = command
|
|
160
|
+
|
|
161
|
+
result = subprocess.run(command_list, shell=False, timeout=timeout, **kwargs)
|
|
162
|
+
|
|
163
|
+
logger.info(f"Command completed with return code: {result.returncode}")
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
except subprocess.TimeoutExpired:
|
|
167
|
+
logger.error(f"Command timed out after {timeout} seconds")
|
|
168
|
+
raise
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logger.error(f"Command execution failed: {str(e)}")
|
|
171
|
+
raise
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def safe_subprocess_popen(
|
|
175
|
+
command: str, shell: bool = False, validate: bool = True, **kwargs
|
|
176
|
+
) -> subprocess.Popen:
|
|
177
|
+
"""
|
|
178
|
+
Safely create a subprocess.Popen with security validation.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
command: Command to execute
|
|
182
|
+
shell: Whether to use shell execution (discouraged)
|
|
183
|
+
validate: Whether to validate command safety
|
|
184
|
+
**kwargs: Additional Popen arguments
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Popen process object
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
CommandSecurityError: If command fails security validation
|
|
191
|
+
"""
|
|
192
|
+
if validate and shell and isinstance(command, str):
|
|
193
|
+
validate_command_safety(command, allow_shell_features=shell)
|
|
194
|
+
|
|
195
|
+
# Log the command execution
|
|
196
|
+
logger.info(f"Creating Popen for command: {str(command)[:100]}...")
|
|
197
|
+
|
|
198
|
+
if shell:
|
|
199
|
+
# When using shell=True, command should be a string
|
|
200
|
+
return subprocess.Popen(command, shell=True, **kwargs)
|
|
201
|
+
else:
|
|
202
|
+
# When shell=False, command should be a list
|
|
203
|
+
if isinstance(command, str):
|
|
204
|
+
command_list = shlex.split(command)
|
|
205
|
+
else:
|
|
206
|
+
command_list = command
|
|
207
|
+
|
|
208
|
+
return subprocess.Popen(command_list, shell=False, **kwargs)
|
|
@@ -58,11 +58,23 @@ def save_config(state_manager: "StateManager") -> bool:
|
|
|
58
58
|
"""Save user config to file"""
|
|
59
59
|
app_settings = ApplicationSettings()
|
|
60
60
|
try:
|
|
61
|
+
# Ensure config directory exists
|
|
62
|
+
app_settings.paths.config_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
# Write config file
|
|
61
65
|
with open(app_settings.paths.config_file, "w") as f:
|
|
62
66
|
json.dump(state_manager.session.user_config, f, indent=4)
|
|
63
67
|
return True
|
|
64
|
-
except
|
|
65
|
-
|
|
68
|
+
except PermissionError as e:
|
|
69
|
+
raise ConfigurationError(
|
|
70
|
+
f"Permission denied writing to {app_settings.paths.config_file}: {e}"
|
|
71
|
+
)
|
|
72
|
+
except OSError as e:
|
|
73
|
+
raise ConfigurationError(
|
|
74
|
+
f"Failed to save configuration to {app_settings.paths.config_file}: {e}"
|
|
75
|
+
)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
raise ConfigurationError(f"Unexpected error saving configuration: {e}")
|
|
66
78
|
|
|
67
79
|
|
|
68
80
|
def get_mcp_servers(state_manager: "StateManager") -> MCPServers:
|
|
@@ -73,4 +85,9 @@ def get_mcp_servers(state_manager: "StateManager") -> MCPServers:
|
|
|
73
85
|
def set_default_model(model_name: ModelName, state_manager: "StateManager") -> bool:
|
|
74
86
|
"""Set the default model in the user config and save"""
|
|
75
87
|
state_manager.session.user_config["default_model"] = model_name
|
|
76
|
-
|
|
88
|
+
try:
|
|
89
|
+
save_config(state_manager)
|
|
90
|
+
return True
|
|
91
|
+
except ConfigurationError:
|
|
92
|
+
# Re-raise ConfigurationError to be handled by caller
|
|
93
|
+
raise
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tunacode-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.35
|
|
4
4
|
Summary: Your agentic CLI developer.
|
|
5
5
|
Author-email: larock22 <noreply@github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -40,6 +40,7 @@ Dynamic: license-file
|
|
|
40
40
|
<div align="center">
|
|
41
41
|
|
|
42
42
|
[](https://badge.fury.io/py/tunacode-cli)
|
|
43
|
+
[](https://pepy.tech/project/tunacode-cli)
|
|
43
44
|
[](https://www.python.org/downloads/)
|
|
44
45
|
[](https://opensource.org/licenses/MIT)
|
|
45
46
|
|
|
@@ -78,6 +79,17 @@ tunacode --model "openrouter:openai/gpt-4o" --key "sk-or-your-openrouter-key"
|
|
|
78
79
|
|
|
79
80
|
Your config is saved to `~/.config/tunacode.json` (edit directly with `nvim ~/.config/tunacode.json`)
|
|
80
81
|
|
|
82
|
+
### Recommended Models
|
|
83
|
+
|
|
84
|
+
Based on extensive testing, these models provide the best performance:
|
|
85
|
+
- `google/gemini-2.5-pro` - Excellent for complex reasoning
|
|
86
|
+
- `openai/gpt-4.1` - Strong general-purpose model
|
|
87
|
+
- `deepseek/deepseek-r1-0528` - Great for code generation
|
|
88
|
+
- `openai/gpt-4.1-mini` - Fast and cost-effective
|
|
89
|
+
- `anthropic/claude-4-sonnet-20250522` - Superior context handling
|
|
90
|
+
|
|
91
|
+
*Note: Formal evaluations coming soon. Any model can work, but these have shown the best results in practice.*
|
|
92
|
+
|
|
81
93
|
## Start Coding
|
|
82
94
|
|
|
83
95
|
```bash
|
|
@@ -105,6 +117,13 @@ TunaCode leverages parallel execution for read-only operations, achieving **3x f
|
|
|
105
117
|
|
|
106
118
|
Multiple file reads, directory listings, and searches execute concurrently using async I/O, making code exploration significantly faster.
|
|
107
119
|
|
|
120
|
+
## Features in Development
|
|
121
|
+
|
|
122
|
+
- **Streaming UI**: Currently working on implementing streaming responses for better user experience
|
|
123
|
+
- **Bug Fixes**: Actively addressing issues - please report any bugs you encounter!
|
|
124
|
+
|
|
125
|
+
*Note: While the tool is fully functional, we're focusing on stability and core features before optimizing for speed.*
|
|
126
|
+
|
|
108
127
|
## Safety First
|
|
109
128
|
|
|
110
129
|
⚠️ **Important**: TunaCode can modify your codebase. Always:
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
tunacode/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
tunacode/constants.py,sha256=
|
|
2
|
+
tunacode/constants.py,sha256=OnQYL4TeNFuMCo_7x9FGWmjQCSDOB544wPPs9oOKk-8,4074
|
|
3
3
|
tunacode/context.py,sha256=6sterdRvPOyG3LU0nEAXpBsEPZbO3qtPyTlJBi-_VXE,2612
|
|
4
4
|
tunacode/exceptions.py,sha256=mTWXuWyr1k16CGLWN2tsthDGi7lbx1JK0ekIqogYDP8,3105
|
|
5
5
|
tunacode/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
6
|
tunacode/setup.py,sha256=dYn0NeAxtNIDSogWEmGSyjb9wsr8AonZ8vAo5sw9NIw,1909
|
|
7
7
|
tunacode/types.py,sha256=BciT-uxnQ44iC-4QiDY72OD23LOtqSyMOuK_N0ttlaA,7676
|
|
8
8
|
tunacode/cli/__init__.py,sha256=zgs0UbAck8hfvhYsWhWOfBe5oK09ug2De1r4RuQZREA,55
|
|
9
|
-
tunacode/cli/commands.py,sha256=
|
|
9
|
+
tunacode/cli/commands.py,sha256=r-WYp5ajzkZfFjiLXuK9pfB5ugq3HWQyhRB8Usr567k,31668
|
|
10
10
|
tunacode/cli/main.py,sha256=PIcFnfmIoI_pmK2y-zB_ouJbzR5fbSI7zsKQNPB_J8o,2406
|
|
11
|
-
tunacode/cli/repl.py,sha256=
|
|
11
|
+
tunacode/cli/repl.py,sha256=ELnJBk3Vn2almXmmCIjGfgi7J5kNNVnO0o4KNYGXF9Q,14556
|
|
12
12
|
tunacode/cli/textual_app.py,sha256=14-Nt0IIETmyHBrNn9uwSF3EwCcutwTp6gdoKgNm0sY,12593
|
|
13
13
|
tunacode/cli/textual_bridge.py,sha256=LvqiTtF0hu3gNujzpKaW9h-m6xzEP3OH2M8KL2pCwRc,6333
|
|
14
14
|
tunacode/configuration/__init__.py,sha256=MbVXy8bGu0yKehzgdgZ_mfWlYGvIdb1dY2Ly75nfuPE,17
|
|
@@ -20,14 +20,14 @@ tunacode/core/code_index.py,sha256=jgAx3lSWP_DwnyiP5Jkm1YvX4JJyI4teMzlNrJSpEOA,1
|
|
|
20
20
|
tunacode/core/state.py,sha256=PHGCGjx_X03I5jO-T1JkREQm4cwYEXQty59JJlnk24c,1608
|
|
21
21
|
tunacode/core/tool_handler.py,sha256=BPjR013OOO0cLXPdLeL2FDK0ixUwOYu59FfHdcdFhp4,2277
|
|
22
22
|
tunacode/core/agents/__init__.py,sha256=UUJiPYb91arwziSpjd7vIk7XNGA_4HQbsOIbskSqevA,149
|
|
23
|
-
tunacode/core/agents/main.py,sha256
|
|
23
|
+
tunacode/core/agents/main.py,sha256=-QwKSKoPLdD-JlKPjwMUSxNu_TSrj-pdUleWR2FN-A0,39441
|
|
24
24
|
tunacode/core/background/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
25
25
|
tunacode/core/background/manager.py,sha256=rJdl3eDLTQwjbT7VhxXcJbZopCNR3M8ZGMbmeVnwwMc,1126
|
|
26
26
|
tunacode/core/llm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
27
27
|
tunacode/core/setup/__init__.py,sha256=lzdpY6rIGf9DDlDBDGFvQZaSOQeFsNglHbkpq1-GtU8,376
|
|
28
28
|
tunacode/core/setup/agent_setup.py,sha256=trELO8cPnWo36BBnYmXDEnDPdhBg0p-VLnx9A8hSSSQ,1401
|
|
29
29
|
tunacode/core/setup/base.py,sha256=cbyT2-xK2mWgH4EO17VfM_OM2bj0kT895NW2jSXbe3c,968
|
|
30
|
-
tunacode/core/setup/config_setup.py,sha256=
|
|
30
|
+
tunacode/core/setup/config_setup.py,sha256=LnIGZrqDCWUhiMhJMnKX1gyaZhy6pKM8xf_fy_t9B3U,14516
|
|
31
31
|
tunacode/core/setup/coordinator.py,sha256=oVTN2xIeJERXitVJpkIk9tDGLs1D1bxIRmaogJwZJFI,2049
|
|
32
32
|
tunacode/core/setup/environment_setup.py,sha256=n3IrObKEynHZSwtUJ1FddMg2C4sHz7ca42awemImV8s,2225
|
|
33
33
|
tunacode/core/setup/git_safety_setup.py,sha256=CRIqrQt0QUJQRS344njty_iCqTorrDhHlXRuET7w0Tk,6714
|
|
@@ -42,7 +42,7 @@ tunacode/tools/grep.py,sha256=jboEVA2ATv0YI8zg9dF89emZ_HWy2vVtsQ_-hDhlr7g,26337
|
|
|
42
42
|
tunacode/tools/list_dir.py,sha256=1kNqzYCNlcA5rqXIEVqcjQy6QxlLZLj5AG6YIECfwio,7217
|
|
43
43
|
tunacode/tools/read_file.py,sha256=BqHxPspZBYotz5wtjuP-zve61obsx98z5TU-aw5BJHg,3273
|
|
44
44
|
tunacode/tools/read_file_async_poc.py,sha256=0rSfYCmoNcvWk8hB1z86l32-tomSc9yOM4tR4nrty_o,6267
|
|
45
|
-
tunacode/tools/run_command.py,sha256=
|
|
45
|
+
tunacode/tools/run_command.py,sha256=2TtndMIeOWHQRC2XwkxUDVb06Ob-RHKSheBdluH3QgQ,4433
|
|
46
46
|
tunacode/tools/update_file.py,sha256=bW1MhTzRjBDjJzqQ6A1yCVEbkr1oIqtEC8uqcg_rfY4,3957
|
|
47
47
|
tunacode/tools/write_file.py,sha256=prL6u8XOi9ZyPU-YNlG9YMLbSLrDJXDRuDX73ncXh-k,2699
|
|
48
48
|
tunacode/ui/__init__.py,sha256=aRNE2pS50nFAX6y--rSGMNYwhz905g14gRd6g4BolYU,13
|
|
@@ -64,13 +64,14 @@ tunacode/utils/diff_utils.py,sha256=V9QqQ0q4MfabVTnWptF3IXDp3estnfOKcJtDe_Sj14I,
|
|
|
64
64
|
tunacode/utils/file_utils.py,sha256=AXiAJ_idtlmXEi9pMvwtfPy9Ys3yK-F4K7qb_NpwonU,923
|
|
65
65
|
tunacode/utils/import_cache.py,sha256=q_xjJbtju05YbFopLDSkIo1hOtCx3DOTl3GQE5FFDgs,295
|
|
66
66
|
tunacode/utils/ripgrep.py,sha256=AXUs2FFt0A7KBV996deS8wreIlUzKOlAHJmwrcAr4No,583
|
|
67
|
+
tunacode/utils/security.py,sha256=e_zo9VmcOKFjgFMr9GOBIFhAmND4PBlJZgY7zqnsGjI,6548
|
|
67
68
|
tunacode/utils/system.py,sha256=FSoibTIH0eybs4oNzbYyufIiV6gb77QaeY2yGqW39AY,11381
|
|
68
69
|
tunacode/utils/text_utils.py,sha256=zRBaorvtyd7HBEWtIfCH1Wce1L6rhsQwpORUEGBFMjA,2981
|
|
69
70
|
tunacode/utils/token_counter.py,sha256=nGCWwrHHFbKywqeDCEuJnADCkfJuzysWiB6cCltJOKI,648
|
|
70
|
-
tunacode/utils/user_configuration.py,sha256=
|
|
71
|
-
tunacode_cli-0.0.
|
|
72
|
-
tunacode_cli-0.0.
|
|
73
|
-
tunacode_cli-0.0.
|
|
74
|
-
tunacode_cli-0.0.
|
|
75
|
-
tunacode_cli-0.0.
|
|
76
|
-
tunacode_cli-0.0.
|
|
71
|
+
tunacode/utils/user_configuration.py,sha256=Ilz8dpGVJDBE2iLWHAPT0xR8D51VRKV3kIbsAz8Bboc,3275
|
|
72
|
+
tunacode_cli-0.0.35.dist-info/licenses/LICENSE,sha256=Btzdu2kIoMbdSp6OyCLupB1aRgpTCJ_szMimgEnpkkE,1056
|
|
73
|
+
tunacode_cli-0.0.35.dist-info/METADATA,sha256=4ck_-g8eF10l6md95_EaEK1Sd4UmqmRAUEJacINtDA8,4943
|
|
74
|
+
tunacode_cli-0.0.35.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
75
|
+
tunacode_cli-0.0.35.dist-info/entry_points.txt,sha256=hbkytikj4dGu6rizPuAd_DGUPBGF191RTnhr9wdhORY,51
|
|
76
|
+
tunacode_cli-0.0.35.dist-info/top_level.txt,sha256=lKy2P6BWNi5XSA4DHFvyjQ14V26lDZctwdmhEJrxQbU,9
|
|
77
|
+
tunacode_cli-0.0.35.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|