tunacode-cli 0.1.21__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.

Files changed (174) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/textual_repl.tcss +283 -0
  3. tunacode/configuration/__init__.py +1 -0
  4. tunacode/configuration/defaults.py +45 -0
  5. tunacode/configuration/models.py +147 -0
  6. tunacode/configuration/models_registry.json +1 -0
  7. tunacode/configuration/pricing.py +74 -0
  8. tunacode/configuration/settings.py +35 -0
  9. tunacode/constants.py +227 -0
  10. tunacode/core/__init__.py +6 -0
  11. tunacode/core/agents/__init__.py +39 -0
  12. tunacode/core/agents/agent_components/__init__.py +48 -0
  13. tunacode/core/agents/agent_components/agent_config.py +441 -0
  14. tunacode/core/agents/agent_components/agent_helpers.py +290 -0
  15. tunacode/core/agents/agent_components/message_handler.py +99 -0
  16. tunacode/core/agents/agent_components/node_processor.py +477 -0
  17. tunacode/core/agents/agent_components/response_state.py +129 -0
  18. tunacode/core/agents/agent_components/result_wrapper.py +51 -0
  19. tunacode/core/agents/agent_components/state_transition.py +112 -0
  20. tunacode/core/agents/agent_components/streaming.py +271 -0
  21. tunacode/core/agents/agent_components/task_completion.py +40 -0
  22. tunacode/core/agents/agent_components/tool_buffer.py +44 -0
  23. tunacode/core/agents/agent_components/tool_executor.py +101 -0
  24. tunacode/core/agents/agent_components/truncation_checker.py +37 -0
  25. tunacode/core/agents/delegation_tools.py +109 -0
  26. tunacode/core/agents/main.py +545 -0
  27. tunacode/core/agents/prompts.py +66 -0
  28. tunacode/core/agents/research_agent.py +231 -0
  29. tunacode/core/compaction.py +218 -0
  30. tunacode/core/prompting/__init__.py +27 -0
  31. tunacode/core/prompting/loader.py +66 -0
  32. tunacode/core/prompting/prompting_engine.py +98 -0
  33. tunacode/core/prompting/sections.py +50 -0
  34. tunacode/core/prompting/templates.py +69 -0
  35. tunacode/core/state.py +409 -0
  36. tunacode/exceptions.py +313 -0
  37. tunacode/indexing/__init__.py +5 -0
  38. tunacode/indexing/code_index.py +432 -0
  39. tunacode/indexing/constants.py +86 -0
  40. tunacode/lsp/__init__.py +112 -0
  41. tunacode/lsp/client.py +351 -0
  42. tunacode/lsp/diagnostics.py +19 -0
  43. tunacode/lsp/servers.py +101 -0
  44. tunacode/prompts/default_prompt.md +952 -0
  45. tunacode/prompts/research/sections/agent_role.xml +5 -0
  46. tunacode/prompts/research/sections/constraints.xml +14 -0
  47. tunacode/prompts/research/sections/output_format.xml +57 -0
  48. tunacode/prompts/research/sections/tool_use.xml +23 -0
  49. tunacode/prompts/sections/advanced_patterns.xml +255 -0
  50. tunacode/prompts/sections/agent_role.xml +8 -0
  51. tunacode/prompts/sections/completion.xml +10 -0
  52. tunacode/prompts/sections/critical_rules.xml +37 -0
  53. tunacode/prompts/sections/examples.xml +220 -0
  54. tunacode/prompts/sections/output_style.xml +94 -0
  55. tunacode/prompts/sections/parallel_exec.xml +105 -0
  56. tunacode/prompts/sections/search_pattern.xml +100 -0
  57. tunacode/prompts/sections/system_info.xml +6 -0
  58. tunacode/prompts/sections/tool_use.xml +84 -0
  59. tunacode/prompts/sections/user_instructions.xml +3 -0
  60. tunacode/py.typed +0 -0
  61. tunacode/templates/__init__.py +5 -0
  62. tunacode/templates/loader.py +15 -0
  63. tunacode/tools/__init__.py +10 -0
  64. tunacode/tools/authorization/__init__.py +29 -0
  65. tunacode/tools/authorization/context.py +32 -0
  66. tunacode/tools/authorization/factory.py +20 -0
  67. tunacode/tools/authorization/handler.py +58 -0
  68. tunacode/tools/authorization/notifier.py +35 -0
  69. tunacode/tools/authorization/policy.py +19 -0
  70. tunacode/tools/authorization/requests.py +119 -0
  71. tunacode/tools/authorization/rules.py +72 -0
  72. tunacode/tools/bash.py +222 -0
  73. tunacode/tools/decorators.py +213 -0
  74. tunacode/tools/glob.py +353 -0
  75. tunacode/tools/grep.py +468 -0
  76. tunacode/tools/grep_components/__init__.py +9 -0
  77. tunacode/tools/grep_components/file_filter.py +93 -0
  78. tunacode/tools/grep_components/pattern_matcher.py +158 -0
  79. tunacode/tools/grep_components/result_formatter.py +87 -0
  80. tunacode/tools/grep_components/search_result.py +34 -0
  81. tunacode/tools/list_dir.py +205 -0
  82. tunacode/tools/prompts/bash_prompt.xml +10 -0
  83. tunacode/tools/prompts/glob_prompt.xml +7 -0
  84. tunacode/tools/prompts/grep_prompt.xml +10 -0
  85. tunacode/tools/prompts/list_dir_prompt.xml +7 -0
  86. tunacode/tools/prompts/read_file_prompt.xml +9 -0
  87. tunacode/tools/prompts/todoclear_prompt.xml +12 -0
  88. tunacode/tools/prompts/todoread_prompt.xml +16 -0
  89. tunacode/tools/prompts/todowrite_prompt.xml +28 -0
  90. tunacode/tools/prompts/update_file_prompt.xml +9 -0
  91. tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
  92. tunacode/tools/prompts/write_file_prompt.xml +7 -0
  93. tunacode/tools/react.py +111 -0
  94. tunacode/tools/read_file.py +68 -0
  95. tunacode/tools/todo.py +222 -0
  96. tunacode/tools/update_file.py +62 -0
  97. tunacode/tools/utils/__init__.py +1 -0
  98. tunacode/tools/utils/ripgrep.py +311 -0
  99. tunacode/tools/utils/text_match.py +352 -0
  100. tunacode/tools/web_fetch.py +245 -0
  101. tunacode/tools/write_file.py +34 -0
  102. tunacode/tools/xml_helper.py +34 -0
  103. tunacode/types/__init__.py +166 -0
  104. tunacode/types/base.py +94 -0
  105. tunacode/types/callbacks.py +53 -0
  106. tunacode/types/dataclasses.py +121 -0
  107. tunacode/types/pydantic_ai.py +31 -0
  108. tunacode/types/state.py +122 -0
  109. tunacode/ui/__init__.py +6 -0
  110. tunacode/ui/app.py +542 -0
  111. tunacode/ui/commands/__init__.py +430 -0
  112. tunacode/ui/components/__init__.py +1 -0
  113. tunacode/ui/headless/__init__.py +5 -0
  114. tunacode/ui/headless/output.py +72 -0
  115. tunacode/ui/main.py +252 -0
  116. tunacode/ui/renderers/__init__.py +41 -0
  117. tunacode/ui/renderers/errors.py +197 -0
  118. tunacode/ui/renderers/panels.py +550 -0
  119. tunacode/ui/renderers/search.py +314 -0
  120. tunacode/ui/renderers/tools/__init__.py +21 -0
  121. tunacode/ui/renderers/tools/bash.py +247 -0
  122. tunacode/ui/renderers/tools/diagnostics.py +186 -0
  123. tunacode/ui/renderers/tools/glob.py +226 -0
  124. tunacode/ui/renderers/tools/grep.py +228 -0
  125. tunacode/ui/renderers/tools/list_dir.py +198 -0
  126. tunacode/ui/renderers/tools/read_file.py +226 -0
  127. tunacode/ui/renderers/tools/research.py +294 -0
  128. tunacode/ui/renderers/tools/update_file.py +237 -0
  129. tunacode/ui/renderers/tools/web_fetch.py +182 -0
  130. tunacode/ui/repl_support.py +226 -0
  131. tunacode/ui/screens/__init__.py +16 -0
  132. tunacode/ui/screens/model_picker.py +303 -0
  133. tunacode/ui/screens/session_picker.py +181 -0
  134. tunacode/ui/screens/setup.py +218 -0
  135. tunacode/ui/screens/theme_picker.py +90 -0
  136. tunacode/ui/screens/update_confirm.py +69 -0
  137. tunacode/ui/shell_runner.py +129 -0
  138. tunacode/ui/styles/layout.tcss +98 -0
  139. tunacode/ui/styles/modals.tcss +38 -0
  140. tunacode/ui/styles/panels.tcss +81 -0
  141. tunacode/ui/styles/theme-nextstep.tcss +303 -0
  142. tunacode/ui/styles/widgets.tcss +33 -0
  143. tunacode/ui/styles.py +18 -0
  144. tunacode/ui/widgets/__init__.py +23 -0
  145. tunacode/ui/widgets/command_autocomplete.py +62 -0
  146. tunacode/ui/widgets/editor.py +402 -0
  147. tunacode/ui/widgets/file_autocomplete.py +47 -0
  148. tunacode/ui/widgets/messages.py +46 -0
  149. tunacode/ui/widgets/resource_bar.py +182 -0
  150. tunacode/ui/widgets/status_bar.py +98 -0
  151. tunacode/utils/__init__.py +0 -0
  152. tunacode/utils/config/__init__.py +13 -0
  153. tunacode/utils/config/user_configuration.py +91 -0
  154. tunacode/utils/messaging/__init__.py +10 -0
  155. tunacode/utils/messaging/message_utils.py +34 -0
  156. tunacode/utils/messaging/token_counter.py +77 -0
  157. tunacode/utils/parsing/__init__.py +13 -0
  158. tunacode/utils/parsing/command_parser.py +55 -0
  159. tunacode/utils/parsing/json_utils.py +188 -0
  160. tunacode/utils/parsing/retry.py +146 -0
  161. tunacode/utils/parsing/tool_parser.py +267 -0
  162. tunacode/utils/security/__init__.py +15 -0
  163. tunacode/utils/security/command.py +106 -0
  164. tunacode/utils/system/__init__.py +25 -0
  165. tunacode/utils/system/gitignore.py +155 -0
  166. tunacode/utils/system/paths.py +190 -0
  167. tunacode/utils/ui/__init__.py +9 -0
  168. tunacode/utils/ui/file_filter.py +135 -0
  169. tunacode/utils/ui/helpers.py +24 -0
  170. tunacode_cli-0.1.21.dist-info/METADATA +170 -0
  171. tunacode_cli-0.1.21.dist-info/RECORD +174 -0
  172. tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
  173. tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
  174. tunacode_cli-0.1.21.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,146 @@
1
+ """Retry utilities for handling transient failures."""
2
+
3
+ import asyncio
4
+ import functools
5
+ import json
6
+ import time
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+
11
+ def retry_on_json_error(
12
+ max_retries: int = 10,
13
+ base_delay: float = 0.1,
14
+ max_delay: float = 5.0,
15
+ ) -> Callable:
16
+ """Decorator to retry function calls that fail with JSON parsing errors.
17
+
18
+ Implements exponential backoff with configurable parameters.
19
+
20
+ Args:
21
+ max_retries: Maximum number of retry attempts (default: 10)
22
+ base_delay: Initial delay between retries in seconds (default: 0.1)
23
+ max_delay: Maximum delay between retries in seconds (default: 5.0)
24
+
25
+ Returns:
26
+ Decorated function that retries on JSONDecodeError
27
+ """
28
+
29
+ def decorator(func: Callable) -> Callable:
30
+ @functools.wraps(func)
31
+ async def async_wrapper(*args, **kwargs) -> Any:
32
+ last_exception = None
33
+
34
+ for attempt in range(max_retries + 1):
35
+ try:
36
+ return await func(*args, **kwargs)
37
+ except json.JSONDecodeError as e:
38
+ last_exception = e
39
+
40
+ if attempt == max_retries:
41
+ # Final attempt failed
42
+ raise
43
+
44
+ # Calculate delay with exponential backoff
45
+ delay = min(base_delay * (2**attempt), max_delay)
46
+
47
+ await asyncio.sleep(delay)
48
+
49
+ # Should never reach here, but just in case
50
+ if last_exception:
51
+ raise last_exception
52
+
53
+ @functools.wraps(func)
54
+ def sync_wrapper(*args, **kwargs) -> Any:
55
+ last_exception = None
56
+
57
+ for attempt in range(max_retries + 1):
58
+ try:
59
+ return func(*args, **kwargs)
60
+ except json.JSONDecodeError as e:
61
+ last_exception = e
62
+
63
+ if attempt == max_retries:
64
+ # Final attempt failed
65
+ raise
66
+
67
+ # Calculate delay with exponential backoff
68
+ delay = min(base_delay * (2**attempt), max_delay)
69
+
70
+ time.sleep(delay)
71
+
72
+ # Should never reach here, but just in case
73
+ if last_exception:
74
+ raise last_exception
75
+
76
+ # Return appropriate wrapper based on function type
77
+ if asyncio.iscoroutinefunction(func):
78
+ return async_wrapper
79
+ else:
80
+ return sync_wrapper
81
+
82
+ return decorator
83
+
84
+
85
+ def retry_json_parse(
86
+ json_string: str,
87
+ max_retries: int = 10,
88
+ base_delay: float = 0.1,
89
+ max_delay: float = 5.0,
90
+ ) -> Any:
91
+ """Parse JSON with automatic retry on failure.
92
+
93
+ Args:
94
+ json_string: JSON string to parse
95
+ max_retries: Maximum number of retry attempts
96
+ base_delay: Initial delay between retries in seconds
97
+ max_delay: Maximum delay between retries in seconds
98
+
99
+ Returns:
100
+ Parsed JSON object
101
+
102
+ Raises:
103
+ json.JSONDecodeError: If parsing fails after all retries
104
+ """
105
+
106
+ @retry_on_json_error(
107
+ max_retries=max_retries,
108
+ base_delay=base_delay,
109
+ max_delay=max_delay,
110
+ )
111
+ def _parse():
112
+ return json.loads(json_string)
113
+
114
+ return _parse()
115
+
116
+
117
+ async def retry_json_parse_async(
118
+ json_string: str,
119
+ max_retries: int = 10,
120
+ base_delay: float = 0.1,
121
+ max_delay: float = 5.0,
122
+ ) -> Any:
123
+ """Asynchronously parse JSON with automatic retry on failure.
124
+
125
+ Args:
126
+ json_string: JSON string to parse
127
+ max_retries: Maximum number of retry attempts
128
+ base_delay: Initial delay between retries in seconds
129
+ max_delay: Maximum delay between retries in seconds
130
+
131
+ Returns:
132
+ Parsed JSON object
133
+
134
+ Raises:
135
+ json.JSONDecodeError: If parsing fails after all retries
136
+ """
137
+
138
+ @retry_on_json_error(
139
+ max_retries=max_retries,
140
+ base_delay=base_delay,
141
+ max_delay=max_delay,
142
+ )
143
+ async def _parse():
144
+ return json.loads(json_string)
145
+
146
+ return await _parse()
@@ -0,0 +1,267 @@
1
+ """
2
+ Module: tunacode.utils.parsing.tool_parser
3
+
4
+ Multi-strategy fallback parser for extracting tool calls from text responses.
5
+ Handles non-standard tool calling formats from models like Qwen2.5-Coder.
6
+
7
+ Supported formats:
8
+ - Qwen2-style XML: <tool_call>{"name": "...", "arguments": {...}}</tool_call>
9
+ - Hermes-style: <function=name>{...}</function>
10
+ - Code fences: ```json {"name": "...", ...} ```
11
+ - Raw JSON: {"name": "...", "arguments": {...}}
12
+ """
13
+
14
+ import json
15
+ import re
16
+ import uuid
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+
20
+ from tunacode.utils.parsing.json_utils import split_concatenated_json
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class ParsedToolCall:
25
+ """Immutable representation of a parsed tool call.
26
+
27
+ Attributes:
28
+ tool_name: Name of the tool to call
29
+ args: Arguments dictionary for the tool
30
+ tool_call_id: Unique identifier for this tool call
31
+ """
32
+
33
+ tool_name: str
34
+ args: dict[str, Any]
35
+ tool_call_id: str
36
+
37
+
38
+ def _generate_tool_call_id() -> str:
39
+ """Generate a unique tool call ID consistent with pydantic_ai format."""
40
+ return str(uuid.uuid4())
41
+
42
+
43
+ QWEN2_PATTERN = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
44
+
45
+
46
+ def parse_qwen2_xml(text: str) -> list[ParsedToolCall] | None:
47
+ """Parse Qwen2-style XML tool calls.
48
+
49
+ Format: <tool_call>{"name": "read_file", "arguments": {"filepath": "..."}}</tool_call>
50
+
51
+ Args:
52
+ text: Raw text potentially containing tool calls
53
+
54
+ Returns:
55
+ List of ParsedToolCall if matches found, None otherwise
56
+ """
57
+ matches = QWEN2_PATTERN.findall(text)
58
+ if not matches:
59
+ return None
60
+
61
+ results: list[ParsedToolCall] = []
62
+ for json_str in matches:
63
+ parsed = _parse_tool_json(json_str)
64
+ if parsed:
65
+ results.append(parsed)
66
+
67
+ return results if results else None
68
+
69
+
70
+ HERMES_PATTERN = re.compile(r"<function=(\w+)>\s*(\{.*?\})\s*</function>", re.DOTALL)
71
+
72
+
73
+ def parse_hermes_style(text: str) -> list[ParsedToolCall] | None:
74
+ """Parse Hermes-style function call format.
75
+
76
+ Format: <function=read_file>{"filepath": "/path/to/file"}</function>
77
+
78
+ Args:
79
+ text: Raw text potentially containing tool calls
80
+
81
+ Returns:
82
+ List of ParsedToolCall if matches found, None otherwise
83
+ """
84
+ matches = HERMES_PATTERN.findall(text)
85
+ if not matches:
86
+ return None
87
+
88
+ results: list[ParsedToolCall] = []
89
+ for tool_name, args_json in matches:
90
+ try:
91
+ args = json.loads(args_json)
92
+ if isinstance(args, dict):
93
+ results.append(
94
+ ParsedToolCall(
95
+ tool_name=tool_name,
96
+ args=args,
97
+ tool_call_id=_generate_tool_call_id(),
98
+ )
99
+ )
100
+ except json.JSONDecodeError:
101
+ continue
102
+
103
+ return results if results else None
104
+
105
+
106
+ CODE_FENCE_PATTERN = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
107
+
108
+
109
+ def parse_code_fence(text: str) -> list[ParsedToolCall] | None:
110
+ """Parse JSON tool calls inside code fences.
111
+
112
+ Format: ```json {"name": "read_file", "arguments": {...}} ```
113
+
114
+ Args:
115
+ text: Raw text potentially containing tool calls
116
+
117
+ Returns:
118
+ List of ParsedToolCall if matches found, None otherwise
119
+ """
120
+ matches = CODE_FENCE_PATTERN.findall(text)
121
+ if not matches:
122
+ return None
123
+
124
+ results: list[ParsedToolCall] = []
125
+ for json_str in matches:
126
+ parsed = _parse_tool_json(json_str)
127
+ if parsed:
128
+ results.append(parsed)
129
+
130
+ return results if results else None
131
+
132
+
133
+ def parse_raw_json(text: str) -> list[ParsedToolCall] | None:
134
+ """Parse raw JSON tool calls embedded in text.
135
+
136
+ Formats supported:
137
+ - {"name": "tool_name", "arguments": {...}}
138
+ - {"tool": "tool_name", "args": {...}}
139
+
140
+ Args:
141
+ text: Raw text potentially containing tool calls
142
+
143
+ Returns:
144
+ List of ParsedToolCall if matches found, None otherwise
145
+ """
146
+ try:
147
+ objects = split_concatenated_json(text)
148
+ except (json.JSONDecodeError, ValueError):
149
+ return None
150
+
151
+ results: list[ParsedToolCall] = []
152
+ for obj in objects:
153
+ parsed = _normalize_tool_object(obj)
154
+ if parsed:
155
+ results.append(parsed)
156
+
157
+ return results if results else None
158
+
159
+
160
+ def _parse_tool_json(json_str: str) -> ParsedToolCall | None:
161
+ """Parse a JSON string into a ParsedToolCall.
162
+
163
+ Handles both {"name": ..., "arguments": ...} and {"tool": ..., "args": ...} formats.
164
+ """
165
+ try:
166
+ obj = json.loads(json_str)
167
+ except json.JSONDecodeError:
168
+ return None
169
+
170
+ return _normalize_tool_object(obj)
171
+
172
+
173
+ def _normalize_tool_object(obj: Any) -> ParsedToolCall | None:
174
+ """Normalize various tool call object formats into ParsedToolCall.
175
+
176
+ Supported formats:
177
+ - {"name": "...", "arguments": {...}}
178
+ - {"tool": "...", "args": {...}}
179
+ - {"function": "...", "parameters": {...}}
180
+ """
181
+ if not isinstance(obj, dict):
182
+ return None
183
+
184
+ # Extract tool name
185
+ tool_name = obj.get("name") or obj.get("tool") or obj.get("function")
186
+ if not tool_name or not isinstance(tool_name, str):
187
+ return None
188
+
189
+ # Extract arguments
190
+ args = obj.get("arguments") or obj.get("args") or obj.get("parameters") or {}
191
+ if not isinstance(args, dict):
192
+ # Try to parse if string
193
+ if isinstance(args, str):
194
+ try:
195
+ args = json.loads(args)
196
+ except json.JSONDecodeError:
197
+ args = {}
198
+ else:
199
+ args = {}
200
+
201
+ return ParsedToolCall(
202
+ tool_name=tool_name,
203
+ args=args,
204
+ tool_call_id=_generate_tool_call_id(),
205
+ )
206
+
207
+
208
+ PARSING_STRATEGIES: list[tuple[str, callable]] = [
209
+ ("qwen2_xml", parse_qwen2_xml),
210
+ ("hermes_style", parse_hermes_style),
211
+ ("code_fence", parse_code_fence),
212
+ ("raw_json", parse_raw_json),
213
+ ]
214
+
215
+
216
+ def parse_tool_calls_from_text(text: str) -> list[ParsedToolCall]:
217
+ """Parse tool calls from text using multi-strategy fallback.
218
+
219
+ Tries each parsing strategy in order until one succeeds.
220
+ Strategies are ordered from most specific to most general.
221
+
222
+ Args:
223
+ text: Raw text potentially containing embedded tool calls
224
+
225
+ Returns:
226
+ List of ParsedToolCall objects. Empty list if no tool calls found.
227
+
228
+ Note:
229
+ Does NOT raise on failure - returns empty list per fail-fast-but-graceful design.
230
+ The caller should decide how to handle empty results.
231
+ """
232
+ if not text or not text.strip():
233
+ return []
234
+
235
+ for _strategy_name, strategy_func in PARSING_STRATEGIES:
236
+ result = strategy_func(text)
237
+ if result:
238
+ return result
239
+
240
+ return []
241
+
242
+
243
+ def has_potential_tool_call(text: str) -> bool:
244
+ """Quick check if text might contain a tool call.
245
+
246
+ This is a fast pre-filter before expensive parsing.
247
+
248
+ Args:
249
+ text: Text to check
250
+
251
+ Returns:
252
+ True if text appears to contain tool call patterns
253
+ """
254
+ if not text:
255
+ return False
256
+
257
+ # Quick pattern indicators
258
+ indicators = [
259
+ "<tool_call>",
260
+ "<function=",
261
+ '"name":',
262
+ '"tool":',
263
+ "```json",
264
+ ]
265
+
266
+ text_lower = text.lower()
267
+ return any(ind.lower() in text_lower for ind in indicators)
@@ -0,0 +1,15 @@
1
+ """Security utilities: command validation and safe execution."""
2
+
3
+ from tunacode.utils.security.command import (
4
+ CommandSecurityError,
5
+ safe_subprocess_popen,
6
+ sanitize_command_args,
7
+ validate_command_safety,
8
+ )
9
+
10
+ __all__ = [
11
+ "CommandSecurityError",
12
+ "safe_subprocess_popen",
13
+ "sanitize_command_args",
14
+ "validate_command_safety",
15
+ ]
@@ -0,0 +1,106 @@
1
+ """
2
+ Security utilities for safe command execution and input validation.
3
+ Provides defensive measures against command injection attacks.
4
+ """
5
+
6
+ import re
7
+ import shlex
8
+ import subprocess
9
+
10
+
11
+ class CommandSecurityError(Exception):
12
+ """Raised when a command fails security validation."""
13
+
14
+ pass
15
+
16
+
17
+ def validate_command_safety(command: str, allow_shell_features: bool = False) -> None:
18
+ """
19
+ Validate that a command is safe to execute.
20
+
21
+ Args:
22
+ command: The command string to validate
23
+ allow_shell_features: If True, allows some shell features like pipes
24
+
25
+ Raises:
26
+ CommandSecurityError: If the command contains potentially dangerous patterns
27
+ """
28
+ if not command or not command.strip():
29
+ raise CommandSecurityError("Empty command not allowed")
30
+
31
+ # Always check for the most dangerous patterns regardless of shell features
32
+ dangerous_patterns = [
33
+ r"rm\s+-rf\s+/", # Dangerous rm commands
34
+ r"sudo\s+rm", # Sudo rm commands
35
+ r">\s*/dev/sd[a-z]", # Writing to disk devices
36
+ r"dd\s+.*of=/dev/", # DD to devices
37
+ r"mkfs\.", # Format filesystem
38
+ r"fdisk", # Partition manipulation
39
+ r":\(\)\{.*\}\;", # Fork bomb pattern
40
+ ]
41
+
42
+ for pattern in dangerous_patterns:
43
+ if re.search(pattern, command, re.IGNORECASE):
44
+ raise CommandSecurityError("Command contains dangerous pattern and is blocked")
45
+
46
+ if not allow_shell_features:
47
+ # Check for dangerous characters (but allow some for CLI tools)
48
+ restricted_chars = [";", "&", "`", "$", "{", "}"] # More permissive for CLI
49
+ for char in restricted_chars:
50
+ if char in command:
51
+ raise CommandSecurityError(f"Potentially unsafe character '{char}' in command")
52
+
53
+ # Check for injection patterns (more selective)
54
+ strict_patterns = [
55
+ r";\s*rm\s+", # Command chaining to rm
56
+ r"&&\s*rm\s+", # Command chaining to rm
57
+ r"`[^`]*rm[^`]*`", # Command substitution with rm
58
+ r"\$\([^)]*rm[^)]*\)", # Command substitution with rm
59
+ ]
60
+
61
+ for pattern in strict_patterns:
62
+ if re.search(pattern, command):
63
+ raise CommandSecurityError("Potentially unsafe pattern detected in command")
64
+
65
+
66
+ def sanitize_command_args(args: list[str]) -> list[str]:
67
+ """
68
+ Sanitize command arguments by shell-quoting them.
69
+
70
+ Args:
71
+ args: List of command arguments
72
+
73
+ Returns:
74
+ List of sanitized arguments
75
+ """
76
+ return [shlex.quote(arg) for arg in args]
77
+
78
+
79
+ def safe_subprocess_popen(
80
+ command: str, shell: bool = False, validate: bool = True, **kwargs
81
+ ) -> subprocess.Popen:
82
+ """
83
+ Safely create a subprocess.Popen with security validation.
84
+
85
+ Args:
86
+ command: Command to execute
87
+ shell: Whether to use shell execution (discouraged)
88
+ validate: Whether to validate command safety
89
+ **kwargs: Additional Popen arguments
90
+
91
+ Returns:
92
+ Popen process object
93
+
94
+ Raises:
95
+ CommandSecurityError: If command fails security validation
96
+ """
97
+ if validate and shell and isinstance(command, str):
98
+ validate_command_safety(command, allow_shell_features=shell)
99
+
100
+ if shell:
101
+ # When using shell=True, command should be a string
102
+ return subprocess.Popen(command, shell=True, **kwargs)
103
+ else:
104
+ # When shell=False, command should be a list
105
+ command_list = shlex.split(command) if isinstance(command, str) else command
106
+ return subprocess.Popen(command_list, shell=False, **kwargs)
@@ -0,0 +1,25 @@
1
+ """System utilities: paths, sessions, and file listing."""
2
+
3
+ from tunacode.utils.system.gitignore import (
4
+ DEFAULT_IGNORE_PATTERNS,
5
+ list_cwd,
6
+ )
7
+ from tunacode.utils.system.paths import (
8
+ check_for_updates,
9
+ cleanup_session,
10
+ get_cwd,
11
+ get_device_id,
12
+ get_session_dir,
13
+ get_tunacode_home,
14
+ )
15
+
16
+ __all__ = [
17
+ "DEFAULT_IGNORE_PATTERNS",
18
+ "list_cwd",
19
+ "check_for_updates",
20
+ "cleanup_session",
21
+ "get_cwd",
22
+ "get_device_id",
23
+ "get_session_dir",
24
+ "get_tunacode_home",
25
+ ]