OverviewFilesCommitsIssuesDocsReleasesPeers

ollama_semantic_function_calling commits / 52ed2b8

adding in a much more useful transparent_ollama_agent.py
52ed2b8c31b3ce4ade2134b6ad5ccc8ddacc7fe4
by fleeky.ashatron <fleeky@7rnx.net> · 2025-04-13T02:31:16.000Z

Changed files (6) +7649 -619

ollamatools-project/command_validator.py +326 -0
……@@ -0,0 +1,326 @@
1"""
2Command validation module for OllamaTools.
3
4This module provides functions for validating and sanitizing shell commands
5to prevent command injection and other security vulnerabilities.
6"""
7
8import os
9import re
10import shlex
11import logging
12import pathlib
13from typing import List, Optional, Dict, Set, Tuple, Union
14
15# Set up logging
16logger = logging.getLogger(__name__)
17
18# Default allowlist of commands
19DEFAULT_ALLOWLIST = {
20 # File operations
21 'ls', 'dir', 'find', 'grep', 'cat', 'less', 'head', 'tail',
22 # Directory operations
23 'pwd', 'cd', 'mkdir', 'rmdir',
24 # System information
25 'uname', 'hostname', 'whoami', 'id', 'ps', 'top', 'df', 'du', 'free',
26 # Network utilities
27 'ping', 'traceroute', 'curl', 'wget', 'netstat', 'dig', 'nslookup',
28 # Text processing
29 'echo', 'printf', 'sed', 'awk', 'cut', 'sort', 'uniq', 'wc',
30 # File manipulation
31 'cp', 'mv', 'rm', 'touch', 'chmod', 'chown', 'chgrp', 'ln',
32 # Compression
33 'tar', 'gzip', 'gunzip', 'zip', 'unzip',
34 # Package management
35 'apt-get', 'apt', 'yum', 'dnf', 'brew',
36 # Process control
37 'kill', 'pkill', 'pgrep',
38 # Admin
39 'sudo',
40}
41
42# Commands that require special validation
43HIGH_RISK_COMMANDS = {
44 'rm', 'mv', 'cp', # File operations that can cause data loss
45 'chmod', 'chown', 'chgrp', # Permission changes
46 'sudo', # Privilege escalation
47 'wget', 'curl', # Network operations
48}
49
50# Paths that should be protected (not allowed to be modified)
51PROTECTED_PATHS = {
52 '/etc', '/bin', '/sbin', '/usr/bin', '/usr/sbin',
53 '/boot', '/lib', '/lib64', '/usr/lib', '/usr/lib64',
54 '/sys', '/proc', '/dev'
55}
56
57# Regular expression patterns for dangerous argument patterns
58DANGEROUS_ARG_PATTERNS = [
59 r'[|;&`$()]', # Shell metacharacters
60 r'>(>)?[^>]*', # Redirections
61 r'<(<)?[^<]*', # Input redirections
62 r'\$\([^)]*\)', # Command substitution
63 r'`[^`]*`', # Backtick command substitution
64 r'\$\{[^}]*\}', # Variable expansion
65 r'\.\.', # Path traversal
66 r'~[^/]*', # Home directory reference
67 r'\\\\', # Double backslash
68]
69
70class CommandValidationError(Exception):
71 """Exception raised for command validation errors."""
72 pass
73
74
75def is_command_in_allowlist(command: str, custom_allowlist: Optional[Set[str]] = None) -> bool:
76 """
77 Check if a command is in the allowlist.
78
79 Args:
80 command: Command to check
81 custom_allowlist: Optional custom allowlist to use instead of the default
82
83 Returns:
84 bool: True if command is in allowlist, False otherwise
85 """
86 allowlist = custom_allowlist if custom_allowlist is not None else DEFAULT_ALLOWLIST
87
88 # Strip path information for the check
89 cmd_name = os.path.basename(command)
90
91 return cmd_name in allowlist
92
93
94def validate_command_path(command: str) -> str:
95 """
96 Validate that a command's path is safe.
97
98 Args:
99 command: Command to validate
100
101 Returns:
102 str: Validated command path
103
104 Raises:
105 CommandValidationError: If the command path is not valid
106 """
107 # Check for absolute path
108 if os.path.isabs(command):
109 cmd_path = pathlib.Path(command).resolve()
110 cmd_name = os.path.basename(command)
111
112 # Ensure the command exists
113 if not cmd_path.exists():
114 raise CommandValidationError(f"Command '{command}' not found")
115
116 # Ensure the path is to a file not a directory
117 if not cmd_path.is_file():
118 raise CommandValidationError(f"Command path '{command}' is not a file")
119
120 # Check if command in a standard location
121 std_paths = ['/bin', '/usr/bin', '/usr/local/bin', '/sbin', '/usr/sbin']
122 parent_path = str(cmd_path.parent)
123 if not any(parent_path.startswith(p) for p in std_paths):
124 logger.warning(f"Command '{command}' is outside standard paths: {parent_path}")
125
126 # Additional permission check
127 if not os.access(cmd_path, os.X_OK):
128 raise CommandValidationError(f"Command '{command}' is not executable")
129
130 return str(cmd_path)
131 else:
132 # For non-absolute paths, we'll rely on the allowlist check
133 return command
134
135
136def validate_command_args(args: List[str], command: str) -> List[str]:
137 """
138 Validate command arguments for security issues.
139
140 Args:
141 args: Command arguments to validate
142 command: The command these args apply to (for context-specific checks)
143
144 Returns:
145 List[str]: Validated arguments
146
147 Raises:
148 CommandValidationError: If any arguments are not valid
149 """
150 validated_args = []
151
152 # Check if command is high risk
153 is_high_risk = os.path.basename(command) in HIGH_RISK_COMMANDS
154
155 for arg in args:
156 # Skip empty args
157 if not arg:
158 continue
159
160 # Check for dangerous patterns
161 for pattern in DANGEROUS_ARG_PATTERNS:
162 if re.search(pattern, arg):
163 raise CommandValidationError(
164 f"Argument '{arg}' contains dangerous pattern matching '{pattern}'"
165 )
166
167 # Special checks for specific commands
168 cmd_name = os.path.basename(command)
169
170 # Special checks for rm command
171 if cmd_name == 'rm' and (arg == '-rf' or arg == '-fr' or arg == '-r' or arg == '-f'):
172 # These arguments need special attention with protected paths
173 if '-r' in arg or '-f' in arg:
174 logger.warning(f"High-risk argument '{arg}' detected for '{command}'")
175
176 # Check file paths in arguments for protected paths
177 if is_high_risk and arg.startswith('/'):
178 arg_path = pathlib.Path(arg).resolve()
179 arg_path_str = str(arg_path)
180
181 # Check against protected paths
182 for protected in PROTECTED_PATHS:
183 if arg_path_str.startswith(protected):
184 raise CommandValidationError(
185 f"Argument '{arg}' references protected path '{protected}'"
186 )
187
188 validated_args.append(arg)
189
190 return validated_args
191
192
193def sanitize_command(command: str) -> str:
194 """
195 Sanitize a command string to prevent command injection.
196
197 Args:
198 command: Command string to sanitize
199
200 Returns:
201 str: Sanitized command string
202 """
203 # Convert to string and trim
204 if not isinstance(command, str):
205 command = str(command)
206
207 sanitized = command.strip()
208
209 # Remove any null bytes or control characters
210 sanitized = re.sub(r'[\x00-\x1F\x7F]', '', sanitized)
211
212 # Remove shell metacharacters
213 dangerous_chars = r'[;&|`$()"\'\\]'
214 sanitized = re.sub(dangerous_chars, '', sanitized)
215
216 return sanitized
217
218
219def validate_and_sanitize_command(
220 command: str,
221 args: Optional[List[str]] = None,
222 custom_allowlist: Optional[Set[str]] = None
223) -> Tuple[str, List[str]]:
224 """
225 Validate and sanitize a command and its arguments.
226
227 Args:
228 command: Command to validate and sanitize
229 args: Optional command arguments
230 custom_allowlist: Optional custom allowlist
231
232 Returns:
233 tuple: (sanitized_command, sanitized_args)
234
235 Raises:
236 CommandValidationError: If the command or arguments are not valid
237 """
238 # Handle None args
239 if args is None:
240 args = []
241
242 # Handle case where command contains both command and arguments as a string
243 if isinstance(command, str) and ' ' in command and args is None:
244 try:
245 # Split using shlex to handle quotes properly
246 command_parts = shlex.split(command)
247 command = command_parts[0]
248 args = command_parts[1:] if len(command_parts) > 1 else []
249 logger.debug(f"Split command into cmd='{command}', args={args}")
250 except ValueError as e:
251 raise CommandValidationError(f"Invalid command syntax: {str(e)}")
252
253 # Check if command is in allowlist
254 if not is_command_in_allowlist(command, custom_allowlist):
255 raise CommandValidationError(
256 f"Command '{command}' not in allowed command list"
257 )
258
259 # Validate command path
260 validated_command = validate_command_path(command)
261
262 # Validate and sanitize args
263 validated_args = validate_command_args(args, validated_command)
264
265 return validated_command, validated_args
266
267
268def is_sudo_command(command: str, args: Optional[List[str]] = None) -> bool:
269 """
270 Check if a command uses sudo.
271
272 Args:
273 command: Command to check
274 args: Optional command arguments
275
276 Returns:
277 bool: True if the command uses sudo, False otherwise
278 """
279 if args is None:
280 args = []
281
282 # Check if command is sudo directly
283 if os.path.basename(command) == 'sudo':
284 return True
285
286 # Check if command path contains sudo
287 if 'sudo' in command.split(os.path.sep):
288 return True
289
290 # Check if sudo appears in the full command string
291 full_command = f"{command} {' '.join(args)}"
292 if full_command.lstrip().startswith('sudo '):
293 return True
294
295 return False
296
297
298def get_secure_env() -> Dict[str, str]:
299 """
300 Get a sanitized environment dictionary for command execution.
301
302 Returns:
303 dict: Sanitized environment variables
304 """
305 # Start with a minimal environment
306 secure_env = {
307 'PATH': '/usr/local/bin:/usr/bin:/bin',
308 'LANG': os.environ.get('LANG', 'C.UTF-8'),
309 'LC_ALL': os.environ.get('LC_ALL', 'C.UTF-8'),
310 'TERM': os.environ.get('TERM', 'xterm'),
311 'HOME': os.environ.get('HOME', '/tmp'),
312 'USER': os.environ.get('USER', 'nobody'),
313 }
314
315 # Copy allowed environment variables
316 allowed_vars = {
317 'DISPLAY', 'EDITOR', 'PAGER', 'TZ',
318 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
319 'http_proxy', 'https_proxy', 'no_proxy'
320 }
321
322 for var in allowed_vars:
323 if var in os.environ:
324 secure_env[var] = os.environ[var]
325
326 return secure_env
ollamatools-project/examples/transparent_ollama_agent.py +697 -0
……@@ -0,0 +1,697 @@
1#!/usr/bin/env python3
2"""
3Transparent Ollama Agent with OpenSearchNG Integration.
4
5This script demonstrates an improved OllamaAgent that:
61. Shows transparent execution of commands and functions
72. Integrates with a locally running OpenSearchNG instance
83. Provides detailed feedback on what's happening
94. Handles errors gracefully with suggestions for fixes
10
11Usage:
12 python transparent_ollama_agent.py
13"""
14
15import os
16import sys
17import json
18import logging
19import time
20from typing import Dict, Any, List, Optional, Set, Union, Tuple
21
22# Add the parent directory to the Python path to import ollamatools
23sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
24
25from ollamatools import OllamaAgent, Config
26from ollamatools.command_validator import DEFAULT_ALLOWLIST
27from ollamatools.utils.logger import setup_logger
28from ollamatools.errors import (
29 OllamaToolsError,
30 CommandError,
31 FileError,
32 WebError,
33 handle_exception
34)
35
36# Import the OpenSearchNG integration
37from ollamatools.opensearch_web_tools import (
38 opensearch_query,
39 opensearch_fetch_url,
40 get_opensearch_info,
41 DEFAULT_OPENSEARCH_CONFIG
42)
43
44# Set up logging
45logger = setup_logger("transparent_ollama_agent", level="INFO")
46
47# Extend the default command allowlist with useful commands
48EXTENDED_ALLOWLIST = DEFAULT_ALLOWLIST.union({
49 "python", "python3", "calc", "bc", "find", "grep", "awk", "sed",
50 "head", "tail", "more", "less", "cat", "echo", "cd", "pwd"
51})
52
53# Configuration for OpenSearchNG
54OPENSEARCH_CONFIG = DEFAULT_OPENSEARCH_CONFIG.copy()
55OPENSEARCH_CONFIG.update({
56 "base_url": "http://localhost:8081", # Updated to your container's port
57 "engines": ["google", "bing", "duckduckgo", "brave"],
58 "num_results": 5
59})
60
61class TransparentOllamaAgent:
62 """
63 An enhanced OllamaAgent that provides transparent execution and better feedback.
64 """
65
66 def __init__(
67 self,
68 model: str = "llama3.1:latest",
69 permission_prompts: bool = True,
70 opensearch_config: Optional[Dict[str, Any]] = None,
71 command_allowlist: Optional[Set[str]] = None
72 ):
73 """
74 Initialize the agent.
75
76 Args:
77 model: Ollama model to use
78 permission_prompts: Whether to display permission prompts
79 opensearch_config: OpenSearchNG configuration
80 command_allowlist: Custom command allowlist
81 """
82 # Initialize OllamaAgent
83 self.agent = OllamaAgent(
84 model=model,
85 permission_prompts=permission_prompts
86 )
87
88 # Store configuration
89 self.opensearch_config = opensearch_config or OPENSEARCH_CONFIG
90 self.command_allowlist = command_allowlist or EXTENDED_ALLOWLIST
91
92 # Initialize state
93 self.context = []
94 self.execution_history = []
95
96 # Check if OpenSearchNG is available
97 try:
98 self._print_status("Checking OpenSearchNG availability...")
99 info = get_opensearch_info(config=self.opensearch_config)
100 self._print_success(f"OpenSearchNG running at {self.opensearch_config['base_url']}")
101
102 # Log available engines
103 if 'engines' in info and info['engines']:
104 self._print_info(f"Available search engines: {', '.join(info['engines'])}")
105 except Exception as e:
106 self._print_error(f"OpenSearchNG not available: {str(e)}")
107 self._print_warning("Falling back to regular search methods")
108 self._suggest_fix("Make sure your OpenSearchNG instance is running at " +
109 f"{self.opensearch_config['base_url']}")
110
111 def chat(self, message: str) -> str:
112 """
113 Process a chat message, showing the execution steps transparently.
114
115 Args:
116 message: User message
117
118 Returns:
119 str: Response message
120 """
121 # Create a strong system prompt that directs the model's behavior
122 system_prompt = f"""
123 You are an AI assistant with transparent access to various tools.
124 You always explain what you're doing step by step and show your work.
125
126 When a user asks you a question, you should:
127 1. Identify the most appropriate tool for the task
128 2. Describe what you're going to do before you do it
129 3. Show the results of any commands or functions you execute
130 4. Explain how you interpret these results
131
132 MOST IMPORTANTLY: When calculating math problems, ALWAYS use the 'calc' command rather than
133 doing the calculation yourself or using python -c. For example: calc 2+2
134
135 For searching the web:
136 - Use the 'opensearch_query' function to perform web searches
137 - Use the 'opensearch_fetch_url' function to fetch web page content safely
138 - Specify search engines if needed (available: {', '.join(self.opensearch_config['engines'])})
139
140 For file and system operations:
141 - Use 'find_files' to locate files
142 - Use 'read_file' to view file content
143 - Use 'execute_command' for running shell commands like 'ls', 'pwd', etc.
144
145 Always check command permissions carefully and explain what each command does.
146 For directory structure, use 'find .' or 'ls -la' commands.
147 """
148
149 try:
150 # Print what we're doing
151 self._print_status(f"Processing message: {message}")
152
153 # Start timer for perf measurement
154 start_time = time.time()
155
156 # Send message to the agent
157 response = self.agent.chat(
158 message=message,
159 system_prompt=system_prompt,
160 context=self.context
161 )
162
163 # Update context
164 self.context.append({"role": "user", "content": message})
165 self.context.append({"role": "assistant", "content": response})
166
167 # Limit context size
168 if len(self.context) > 10:
169 self.context = self.context[-10:]
170
171 # Calculate execution time
172 execution_time = time.time() - start_time
173 self._print_success(f"Response generated in {execution_time:.2f} seconds")
174
175 return response
176
177 except OllamaToolsError as e:
178 error_details = handle_exception(e, log_error=True)
179
180 self._print_error(f"Error: {error_details['error_message']}")
181 self._suggest_fix(self._generate_error_fix_suggestion(e))
182
183 return f"I encountered an error: {error_details['error_message']}\n\n" + \
184 f"Suggested fix: {self._generate_error_fix_suggestion(e)}"
185
186 except Exception as e:
187 error_details = handle_exception(e, log_error=True)
188
189 self._print_error(f"Unexpected error: {str(e)}")
190
191 return f"I encountered an unexpected error: {str(e)}"
192
193 def execute_search(self, query: str) -> Dict[str, Any]:
194 """
195 Demonstrate direct search execution using OpenSearchNG.
196
197 Args:
198 query: Search query
199
200 Returns:
201 dict: Search results
202 """
203 try:
204 self._print_status(f"Searching for: {query}")
205
206 # Execute the search
207 results = opensearch_query(
208 query=query,
209 config=self.opensearch_config,
210 permission_manager=self.agent.permission_manager
211 )
212
213 # Show the number of results
214 num_results = len(results.get("results", []))
215 self._print_success(f"Found {num_results} results")
216
217 # Show suggested answer if available
218 if results.get("answer"):
219 self._print_info(f"Direct answer: {results['answer']}")
220
221 # Register in execution history
222 self.execution_history.append({
223 "function": "opensearch_query",
224 "args": {"query": query},
225 "timestamp": time.time(),
226 "success": True,
227 "num_results": num_results
228 })
229
230 return results
231
232 except Exception as e:
233 self._print_error(f"Search failed: {str(e)}")
234
235 # Register in execution history
236 self.execution_history.append({
237 "function": "opensearch_query",
238 "args": {"query": query},
239 "timestamp": time.time(),
240 "success": False,
241 "error": str(e)
242 })
243
244 self._suggest_fix(self._generate_error_fix_suggestion(e))
245 raise
246
247 def execute_command(self, command: str, args: Optional[List[str]] = None) -> Dict[str, Any]:
248 """
249 Execute a system command with transparency.
250
251 Args:
252 command: Command to execute
253 args: Command arguments
254
255 Returns:
256 dict: Command result
257 """
258 try:
259 # Show what we're doing
260 cmd_str = f"{command} {' '.join(args) if args else ''}"
261 self._print_status(f"Executing command: {cmd_str}")
262
263 # Execute the command
264 from ollamatools.tools.system_tools import execute_command
265 result = execute_command(
266 command=command,
267 args=args,
268 permission_manager=self.agent.permission_manager,
269 custom_allowlist=self.command_allowlist
270 )
271
272 # Check for errors
273 if "error" in result:
274 self._print_error(f"Command error: {result['error']}")
275
276 # Register in execution history
277 self.execution_history.append({
278 "function": "execute_command",
279 "args": {"command": command, "args": args},
280 "timestamp": time.time(),
281 "success": False,
282 "error": result["error"]
283 })
284
285 self._suggest_fix(self._generate_command_fix_suggestion(result))
286 return result
287
288 # Show success
289 self._print_success(f"Command executed with exit code: {result['returncode']}")
290
291 # Format the output for better readability
292 if result.get('stdout'):
293 output_lines = result['stdout'].strip().split('\n')
294 if len(output_lines) > 10:
295 # Truncate long output
296 preview = '\n'.join(output_lines[:10])
297 self._print_info(f"Command output (first 10 lines):\n{preview}\n[...more output truncated...]")
298 else:
299 self._print_info(f"Command output:\n{result['stdout'].strip()}")
300
301 # Show errors if any
302 if result.get('stderr'):
303 self._print_warning(f"Command stderr:\n{result['stderr'].strip()}")
304
305 # Register in execution history
306 self.execution_history.append({
307 "function": "execute_command",
308 "args": {"command": command, "args": args},
309 "timestamp": time.time(),
310 "success": True,
311 "returncode": result["returncode"]
312 })
313
314 return result
315
316 except Exception as e:
317 self._print_error(f"Command execution failed: {str(e)}")
318
319 # Register in execution history
320 self.execution_history.append({
321 "function": "execute_command",
322 "args": {"command": command, "args": args},
323 "timestamp": time.time(),
324 "success": False,
325 "error": str(e)
326 })
327
328 self._suggest_fix(self._generate_error_fix_suggestion(e))
329 raise
330
331 def get_directory_structure(self, path: str = '.', max_depth: int = 2) -> Dict[str, Any]:
332 """
333 Get a formatted directory structure.
334
335 Args:
336 path: Starting directory path
337 max_depth: Maximum directory depth to show
338
339 Returns:
340 dict: Directory structure information
341 """
342 try:
343 self._print_status(f"Getting directory structure for: {path} (depth: {max_depth})")
344
345 # Execute find command to get the structure
346 result = self.execute_command(
347 command="find",
348 args=[path, "-maxdepth", str(max_depth), "-type", "f", "-o", "-type", "d"]
349 )
350
351 if "error" in result:
352 return result
353
354 # Process the output into a more structured format
355 lines = result.get("stdout", "").strip().split('\n')
356
357 structure = {
358 "path": path,
359 "depth": max_depth,
360 "entries": lines,
361 "files": [line for line in lines if os.path.isfile(line)],
362 "directories": [line for line in lines if os.path.isdir(line)],
363 "count": {
364 "total": len(lines),
365 "files": len([line for line in lines if os.path.isfile(line)]),
366 "directories": len([line for line in lines if os.path.isdir(line)])
367 }
368 }
369
370 self._print_success(f"Found {structure['count']['total']} entries "
371 f"({structure['count']['files']} files, "
372 f"{structure['count']['directories']} directories)")
373
374 return structure
375
376 except Exception as e:
377 self._print_error(f"Failed to get directory structure: {str(e)}")
378 self._suggest_fix(self._generate_error_fix_suggestion(e))
379
380 return {
381 "error": str(e),
382 "path": path,
383 "depth": max_depth
384 }
385
386 def show_execution_history(self, max_entries: int = 10) -> None:
387 """
388 Display the execution history.
389
390 Args:
391 max_entries: Maximum number of history entries to show
392 """
393 if not self.execution_history:
394 print("\n📜 Execution history is empty.")
395 return
396
397 # Get the most recent entries
398 recent_entries = self.execution_history[-max_entries:]
399
400 print("\n📜 Execution History:")
401 print("=" * 60)
402
403 for i, entry in enumerate(recent_entries, 1):
404 # Format timestamp
405 timestamp = time.strftime("%H:%M:%S", time.localtime(entry["timestamp"]))
406
407 # Format function and args
408 function_name = entry["function"]
409 args_str = ", ".join(f"{k}={v!r}" for k, v in entry["args"].items())
410
411 # Format status
412 status = "✅ Success" if entry.get("success", False) else "❌ Failed"
413
414 # Print the entry
415 print(f"{i}. [{timestamp}] {function_name}({args_str}) -> {status}")
416
417 # Show additional details for errors
418 if not entry.get("success", False) and "error" in entry:
419 print(f" Error: {entry['error']}")
420
421 # Show additional details for commands
422 if function_name == "execute_command" and "returncode" in entry:
423 print(f" Exit code: {entry['returncode']}")
424
425 # Show additional details for search
426 if function_name == "opensearch_query" and "num_results" in entry:
427 print(f" Results: {entry['num_results']}")
428
429 print("=" * 60)
430
431 def _generate_error_fix_suggestion(self, error: Exception) -> str:
432 """
433 Generate a fix suggestion based on the error type.
434
435 Args:
436 error: The exception
437
438 Returns:
439 str: Suggested fix
440 """
441 if isinstance(error, CommandError):
442 return self._generate_command_fix_suggestion(error)
443 elif isinstance(error, FileError):
444 return self._generate_file_fix_suggestion(error)
445 elif isinstance(error, WebError):
446 return self._generate_web_fix_suggestion(error)
447 else:
448 # Generic suggestion
449 return f"Try a different approach or check if the required resources are available."
450
451 def _generate_command_fix_suggestion(self, error: Union[CommandError, Dict[str, Any]]) -> str:
452 """
453 Generate a fix suggestion for command errors.
454
455 Args:
456 error: Error object or dictionary
457
458 Returns:
459 str: Suggested fix
460 """
461 # Extract error details
462 if isinstance(error, CommandError):
463 error_msg = str(error)
464 details = error.details or {}
465 command = details.get('command', '')
466 stderr = details.get('stderr', '')
467 else:
468 error_msg = error.get('error', '')
469 command = error.get('command', '')
470 stderr = error.get('stderr', '')
471
472 # Check for common error patterns
473 if "not in allowed command list" in error_msg:
474 return (f"The command '{command}' is not in the allowed command list. "
475 f"Try using one of these commands instead: {', '.join(sorted(list(self.command_allowlist)[:10]))}... "
476 f"or add this command to the allowlist when initializing the agent.")
477
478 elif "dangerous pattern" in error_msg:
479 pattern = error_msg.split("pattern matching '")[1].split("'")[0] if "pattern matching '" in error_msg else ""
480 return (f"The command contains potentially dangerous pattern: {pattern}. "
481 f"Try reformulating the command to avoid shell metacharacters.")
482
483 elif "not found" in error_msg or "command not found" in stderr:
484 return f"The command '{command}' does not exist on this system. Check the spelling or install the command."
485
486 elif "permission denied" in stderr.lower():
487 return f"Permission denied when executing '{command}'. Try running with appropriate permissions."
488
489 else:
490 return f"Try a different command syntax or check command documentation."
491
492 def _generate_file_fix_suggestion(self, error: FileError) -> str:
493 """
494 Generate a fix suggestion for file errors.
495
496 Args:
497 error: File error
498
499 Returns:
500 str: Suggested fix
501 """
502 error_msg = str(error)
503 details = error.details or {}
504 path = details.get('path', '')
505
506 if "not found" in error_msg:
507 return f"The file '{path}' does not exist. Check the path or create the file."
508
509 elif "is a directory" in error_msg:
510 return f"'{path}' is a directory, not a file. Try specifying a file path instead."
511
512 elif "permission denied" in error_msg.lower():
513 return f"Permission denied when accessing '{path}'. Check file permissions."
514
515 elif "exceeds maximum size" in error_msg:
516 return f"The file '{path}' is too large. Try reading a smaller file or increasing max_size."
517
518 elif "outside the allowed directories" in error_msg:
519 return f"The path '{path}' is outside the allowed directories. Try a path within the sandbox."
520
521 else:
522 return f"Check if the file exists and you have appropriate permissions."
523
524 def _generate_web_fix_suggestion(self, error: WebError) -> str:
525 """
526 Generate a fix suggestion for web errors.
527
528 Args:
529 error: Web error
530
531 Returns:
532 str: Suggested fix
533 """
534 error_msg = str(error)
535 details = error.details or {}
536 url = details.get('url', '')
537
538 if "domain" in error_msg and "blocked" in error_msg:
539 return f"The domain in '{url}' is blocked for security reasons. Try a different domain."
540
541 elif "scheme" in error_msg and "not allowed" in error_msg:
542 return f"The URL scheme is not allowed. Use http:// or https:// instead."
543
544 elif "Connection refused" in error_msg:
545 return f"Could not connect to '{url}'. Check if the service is running and accessible."
546
547 elif "timeout" in error_msg.lower():
548 return f"The request to '{url}' timed out. Try again later or check your internet connection."
549
550 elif "Status code:" in error_msg and "404" in error_msg:
551 return f"The resource at '{url}' was not found (404). Check the URL for typos."
552
553 elif "Status code:" in error_msg and "403" in error_msg:
554 return f"Access to '{url}' is forbidden (403). The site may require authentication."
555
556 else:
557 return f"Try a different URL or check your internet connection."
558
559 def _print_status(self, message: str) -> None:
560 """Print a status message."""
561 print(f"\n🔄 {message}")
562
563 def _print_success(self, message: str) -> None:
564 """Print a success message."""
565 print(f"✅ {message}")
566
567 def _print_error(self, message: str) -> None:
568 """Print an error message."""
569 print(f"❌ {message}")
570
571 def _print_warning(self, message: str) -> None:
572 """Print a warning message."""
573 print(f"⚠️ {message}")
574
575 def _print_info(self, message: str) -> None:
576 """Print an information message."""
577 print(f"ℹ️ {message}")
578
579 def _suggest_fix(self, suggestion: str) -> None:
580 """Print a fix suggestion."""
581 print(f"💡 Suggested fix: {suggestion}")
582
583
584def main():
585 """Run the transparent Ollama agent."""
586 try:
587 # Initialize the agent
588 logger.info("Initializing TransparentOllamaAgent...")
589 agent = TransparentOllamaAgent(
590 model="llama3.1:latest", # Adjust according to your available models
591 permission_prompts=True
592 )
593
594 logger.info("Agent initialized. Enter 'exit' to quit.")
595 print("\nTransparent Ollama Agent")
596 print("========================")
597 print("Type 'exit' to quit")
598 print("Type 'commands' to see available commands")
599 print("Type 'search <query>' to search directly")
600 print("Type 'run <command>' to execute a command directly")
601 print("Type 'dir' to show current directory structure")
602 print("Type 'history' to see execution history")
603 print("\nOtherwise, just chat naturally with the agent!")
604
605 # Simple chat loop
606 while True:
607 # Get user input
608 user_input = input("\nYou: ").strip()
609
610 # Check for exit command
611 if user_input.lower() in ['exit', 'quit', 'bye']:
612 print("\nExiting. Goodbye!")
613 break
614
615 # Check for special commands
616 if user_input.lower() == 'commands':
617 print("\nAvailable Commands:")
618 print("=" * 60)
619 print("General Commands:")
620 print(" exit, quit, bye - Exit the program")
621 print(" commands - Show this help")
622 print(" history - Show execution history")
623 print("\nDirect Operations:")
624 print(" search <query> - Search directly using OpenSearchNG")
625 print(" run <command> - Execute a command directly")
626 print(" dir [path] [depth] - Show directory structure")
627 print("=" * 60)
628 continue
629
630 if user_input.lower() == 'history':
631 agent.show_execution_history()
632 continue
633
634 if user_input.lower().startswith('search '):
635 query = user_input[7:].strip()
636 if query:
637 try:
638 results = agent.execute_search(query)
639 # Show the top 3 results
640 if results.get("results"):
641 print("\nTop Results:")
642 for i, result in enumerate(results["results"][:3], 1):
643 print(f"{i}. {result['title']}")
644 print(f" URL: {result['url']}")
645 print(f" {result['content'][:100]}...")
646 print()
647 except Exception as e:
648 print(f"Search failed: {str(e)}")
649 else:
650 print("Please provide a search query.")
651 continue
652
653 if user_input.lower().startswith('run '):
654 cmd = user_input[4:].strip()
655 if cmd:
656 try:
657 # Split the command into parts
658 parts = cmd.split()
659 if parts:
660 main_cmd = parts[0]
661 args = parts[1:] if len(parts) > 1 else []
662 result = agent.execute_command(main_cmd, args)
663 if "stdout" in result:
664 print("\nCommand Output:")
665 print(result["stdout"])
666 else:
667 print("Please provide a command to run.")
668 except Exception as e:
669 print(f"Command execution failed: {str(e)}")
670 continue
671
672 if user_input.lower().startswith('dir'):
673 parts = user_input.split()
674 path = parts[1] if len(parts) > 1 else '.'
675 depth = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 2
676
677 try:
678 agent.get_directory_structure(path, depth)
679 except Exception as e:
680 print(f"Failed to get directory structure: {str(e)}")
681 continue
682
683 # Process as normal chat message
684 response = agent.chat(user_input)
685 print(f"\nAssistant: {response}")
686
687 except KeyboardInterrupt:
688 print("\n\nExiting due to keyboard interrupt.")
689 except Exception as e:
690 logger.error(f"Unexpected error in main function: {str(e)}")
691 print(f"\nUnexpected error: {str(e)}")
692 print("Check the logs for details.")
693
694 return 0
695
696if __name__ == "__main__":
697 sys.exit(main())
ollamatools-project/ollamatools/opensearch_web_tools.py +401 -0
……@@ -0,0 +1,401 @@
1"""
2OpenSearchNG integration for OllamaTools.
3
4This module extends the web_tools capabilities by adding integration with
5a locally running OpenSearchNG instance for improved search functionality.
6"""
7
8import requests
9import json
10import logging
11from typing import Dict, Any, Optional, List, Union, Set
12from urllib.parse import quote_plus
13
14# Import URL validation utilities
15from ollamatools.url_validator import (
16 validate_url,
17 URLValidationError,
18 configure_domain_lists,
19)
20
21# Import error handling utilities
22from ollamatools.errors import (
23 WebError,
24 ERROR_CODES,
25 handle_exception
26)
27
28# Set up logging
29logger = logging.getLogger(__name__)
30
31# Default OpenSearchNG configuration
32DEFAULT_OPENSEARCH_CONFIG = {
33 "base_url": "http://localhost:8081", # Default local OpenSearchNG URL
34 "search_path": "/search", # Path for search endpoint
35 "preferences_path": "/preferences", # Path for preferences endpoint
36 "categories": ["general"], # Default search categories
37 "engines": ["google", "bing", "duckduckgo"], # Default search engines
38 "language": "en-US", # Default language
39 "format": "json", # Response format (html, json, csv, rss)
40 "timeout": 10, # Default timeout in seconds
41 "num_results": 10, # Default number of results
42 "safe_search": 0 # 0=off, 1=moderate, 2=strict
43}
44
45def opensearch_query(
46 query: str,
47 config: Optional[Dict[str, Any]] = None,
48 permission_manager = None,
49 domain_allowlist: Optional[Set[str]] = None,
50 domain_blocklist: Optional[Set[str]] = None
51) -> Dict[str, Any]:
52 """
53 Perform a search query using a local OpenSearchNG instance.
54
55 Args:
56 query: Search query string
57 config: OpenSearchNG configuration (defaults used if None)
58 permission_manager: Optional permission manager to request permission
59 domain_allowlist: Optional set of allowed domains for results
60 domain_blocklist: Optional set of blocked domains for results
61
62 Returns:
63 dict: Search results with metadata
64
65 Raises:
66 ValueError: If parameters are invalid
67 WebError: If the search fails
68 PermissionError: If permission is denied
69 """
70 # Validate input
71 if not query or not isinstance(query, str):
72 raise ValueError("Query must be a non-empty string")
73
74 # Use default config if none provided
75 search_config = DEFAULT_OPENSEARCH_CONFIG.copy()
76 if config:
77 search_config.update(config)
78
79 # Request permission if permission manager is provided
80 if permission_manager:
81 args = {
82 "query": query,
83 "config": search_config,
84 }
85
86 if not permission_manager.request_permission("opensearch_query", args):
87 logger.warning(f"Permission denied for OpenSearchNG query: {query}")
88 raise PermissionError(f"Permission denied for OpenSearchNG query: {query}")
89
90 # Construct the search URL
91 base_url = search_config["base_url"]
92 search_path = search_config["search_path"]
93 search_url = f"{base_url}{search_path}"
94
95 # Validate the OpenSearchNG instance URL
96 try:
97 # Allow localhost for OpenSearchNG access
98 local_allowlist = {"localhost", "127.0.0.1"}
99 if domain_allowlist:
100 local_allowlist.update(domain_allowlist)
101
102 # Extract the host from the base URL for validation
103 from urllib.parse import urlparse
104 parsed_url = urlparse(base_url)
105 hostname = parsed_url.netloc.split(':')[0]
106
107 if hostname not in local_allowlist:
108 validate_url(
109 base_url,
110 domain_allowlist=local_allowlist,
111 domain_blocklist=domain_blocklist
112 )
113 except URLValidationError as e:
114 # Make a special exception for localhost
115 if "localhost" not in base_url and "127.0.0.1" not in base_url:
116 raise ValueError(f"Invalid OpenSearchNG URL: {str(e)}")
117
118 # Prepare query parameters
119 params = {
120 "q": query,
121 "format": search_config["format"],
122 "categories": ",".join(search_config["categories"]),
123 "engines": ",".join(search_config["engines"]),
124 "language": search_config["language"],
125 "safesearch": search_config["safe_search"],
126 "time_range": "", # Optional time range
127 "pageno": 1, # First page of results
128 }
129
130 # Create session with appropriate headers
131 with requests.Session() as session:
132 headers = {
133 "User-Agent": "OllamaTools/0.2.0",
134 "Accept": "application/json"
135 }
136 session.headers.update(headers)
137
138 try:
139 logger.info(f"Making OpenSearchNG query: {query}")
140 response = session.get(
141 search_url,
142 params=params,
143 timeout=search_config["timeout"]
144 )
145
146 # Check for successful response
147 if response.status_code != 200:
148 raise WebError(
149 f"OpenSearchNG query failed with status code: {response.status_code}",
150 url=search_url,
151 status_code=response.status_code,
152 code=ERROR_CODES["URL_RESPONSE_ERROR"]
153 )
154
155 # Parse the response based on format
156 if search_config["format"] == "json":
157 try:
158 result_data = response.json()
159 except json.JSONDecodeError:
160 raise WebError(
161 "Failed to parse JSON response from OpenSearchNG",
162 url=search_url,
163 code=ERROR_CODES["URL_RESPONSE_ERROR"]
164 )
165 else:
166 # For other formats, return the raw content
167 result_data = {"raw_content": response.text}
168
169 # Validate and process the results
170 processed_results = process_opensearch_results(result_data, query)
171
172 return processed_results
173
174 except requests.RequestException as e:
175 raise WebError(
176 f"OpenSearchNG request failed: {str(e)}",
177 url=search_url,
178 code=ERROR_CODES["URL_CONNECTION_ERROR"]
179 )
180
181def process_opensearch_results(
182 result_data: Dict[str, Any],
183 query: str
184) -> Dict[str, Any]:
185 """
186 Process and format the OpenSearchNG results.
187
188 Args:
189 result_data: Raw result data from OpenSearchNG
190 query: Original search query
191
192 Returns:
193 dict: Processed search results with metadata
194 """
195 processed_results = {
196 "query": query,
197 "results": [],
198 "suggestion": None,
199 "answer": None,
200 "infobox": None,
201 "engines": [],
202 "timing": {}
203 }
204
205 # Extract search results
206 if "results" in result_data:
207 for result in result_data["results"]:
208 processed_result = {
209 "title": result.get("title", ""),
210 "url": result.get("url", ""),
211 "content": result.get("content", ""),
212 "engine": result.get("engine", ""),
213 "score": result.get("score", 0),
214 "category": result.get("category", ""),
215 "pretty_url": result.get("pretty_url", "")
216 }
217 processed_results["results"].append(processed_result)
218
219 # Extract suggestions
220 if "suggestions" in result_data and result_data["suggestions"]:
221 processed_results["suggestion"] = result_data["suggestions"][0]
222
223 # Extract answer if available
224 if "answers" in result_data and result_data["answers"]:
225 processed_results["answer"] = result_data["answers"][0]
226
227 # Extract infobox if available
228 if "infoboxes" in result_data and result_data["infoboxes"]:
229 processed_results["infobox"] = result_data["infoboxes"][0]
230
231 # Extract engines used
232 if "engines" in result_data:
233 processed_results["engines"] = result_data["engines"]
234
235 # Extract timing information
236 if "timing" in result_data:
237 processed_results["timing"] = result_data["timing"]
238
239 return processed_results
240
241def get_opensearch_info(
242 config: Optional[Dict[str, Any]] = None,
243 permission_manager = None
244) -> Dict[str, Any]:
245 """
246 Get information about the OpenSearchNG instance.
247
248 Args:
249 config: OpenSearchNG configuration (defaults used if None)
250 permission_manager: Optional permission manager to request permission
251
252 Returns:
253 dict: Information about the OpenSearchNG instance
254
255 Raises:
256 WebError: If the request fails
257 PermissionError: If permission is denied
258 """
259 # Use default config if none provided
260 search_config = DEFAULT_OPENSEARCH_CONFIG.copy()
261 if config:
262 search_config.update(config)
263
264 # Request permission if permission manager is provided
265 if permission_manager:
266 if not permission_manager.request_permission("get_opensearch_info", {"config": search_config}):
267 logger.warning("Permission denied for getting OpenSearchNG info")
268 raise PermissionError("Permission denied for getting OpenSearchNG info")
269
270 # Construct the URL
271 base_url = search_config["base_url"]
272 config_url = f"{base_url}/config"
273
274 try:
275 logger.info("Getting OpenSearchNG instance information")
276 response = requests.get(
277 config_url,
278 timeout=search_config["timeout"]
279 )
280
281 # Check for successful response
282 if response.status_code != 200:
283 raise WebError(
284 f"OpenSearchNG info request failed with status code: {response.status_code}",
285 url=config_url,
286 status_code=response.status_code,
287 code=ERROR_CODES["URL_RESPONSE_ERROR"]
288 )
289
290 # Parse the response
291 try:
292 config_data = response.json()
293 return config_data
294 except json.JSONDecodeError:
295 raise WebError(
296 "Failed to parse JSON response from OpenSearchNG",
297 url=config_url,
298 code=ERROR_CODES["URL_RESPONSE_ERROR"]
299 )
300
301 except requests.RequestException as e:
302 raise WebError(
303 f"OpenSearchNG info request failed: {str(e)}",
304 url=config_url,
305 code=ERROR_CODES["URL_CONNECTION_ERROR"]
306 )
307
308def opensearch_fetch_url(
309 url: str,
310 config: Optional[Dict[str, Any]] = None,
311 permission_manager = None,
312 domain_allowlist: Optional[Set[str]] = None,
313 domain_blocklist: Optional[Set[str]] = None
314) -> Dict[str, Any]:
315 """
316 Fetch a URL through the OpenSearchNG proxy to avoid direct connections.
317 This provides an additional security layer for web requests.
318
319 Args:
320 url: URL to fetch
321 config: OpenSearchNG configuration (defaults used if None)
322 permission_manager: Optional permission manager to request permission
323 domain_allowlist: Optional set of allowed domains
324 domain_blocklist: Optional set of blocked domains
325
326 Returns:
327 dict: Response content and metadata
328
329 Raises:
330 ValueError: If URL is invalid
331 WebError: If the fetch fails
332 PermissionError: If permission is denied
333 """
334 # Validate URL
335 try:
336 validated_url = validate_url(
337 url,
338 domain_allowlist=domain_allowlist,
339 domain_blocklist=domain_blocklist
340 )
341 except URLValidationError as e:
342 raise ValueError(f"URL validation failed: {str(e)}")
343
344 # Use default config if none provided
345 search_config = DEFAULT_OPENSEARCH_CONFIG.copy()
346 if config:
347 search_config.update(config)
348
349 # Request permission if permission manager is provided
350 if permission_manager:
351 args = {
352 "url": url,
353 "config": search_config
354 }
355
356 if not permission_manager.request_permission("opensearch_fetch_url", args):
357 logger.warning(f"Permission denied for fetching URL via OpenSearchNG: {url}")
358 raise PermissionError(f"Permission denied for fetching URL via OpenSearchNG: {url}")
359
360 # Construct the URL for the OpenSearchNG proxy
361 base_url = search_config["base_url"]
362 proxy_url = f"{base_url}/url_proxy"
363
364 # Prepare query parameters
365 params = {
366 "url": validated_url
367 }
368
369 try:
370 logger.info(f"Fetching URL via OpenSearchNG proxy: {validated_url}")
371 response = requests.get(
372 proxy_url,
373 params=params,
374 timeout=search_config["timeout"]
375 )
376
377 # Check for successful response
378 if response.status_code != 200:
379 raise WebError(
380 f"URL fetch via OpenSearchNG failed with status code: {response.status_code}",
381 url=proxy_url,
382 status_code=response.status_code,
383 code=ERROR_CODES["URL_RESPONSE_ERROR"]
384 )
385
386 # Create result with metadata
387 result = {
388 "status": response.status_code,
389 "headers": dict(response.headers),
390 "content": response.text,
391 "url": validated_url
392 }
393
394 return result
395
396 except requests.RequestException as e:
397 raise WebError(
398 f"URL fetch via OpenSearchNG failed: {str(e)}",
399 url=proxy_url,
400 code=ERROR_CODES["URL_CONNECTION_ERROR"]
401 )
ollamatools-project/path_validator.py +419 -0
……@@ -0,0 +1,419 @@
1"""
2Path validation module for OllamaTools.
3
4This module provides functions for validating and sanitizing file paths
5to prevent path traversal attacks and other file path-related security issues.
6"""
7
8import os
9import re
10import pathlib
11from typing import Optional, List, Set, Dict, Any, Union, Tuple
12import logging
13
14# Set up logging
15logger = logging.getLogger(__name__)
16
17# Default protected paths - should not be modified
18DEFAULT_PROTECTED_PATHS = {
19 '/etc',
20 '/bin',
21 '/sbin',
22 '/usr/bin',
23 '/usr/sbin',
24 '/usr/lib',
25 '/usr/lib64',
26 '/boot',
27 '/dev',
28 '/proc',
29 '/sys',
30 '/var/lib',
31 '/var/log',
32 '/var/run',
33 '/var/lock',
34}
35
36# Default paths to allow even if they're in protected paths
37DEFAULT_ALLOWED_PATHS = {
38 '/etc/hosts', # commonly read for network configuration
39 '/etc/hostname', # commonly read for system identification
40 '/etc/passwd', # commonly read for user information (non-sensitive part)
41}
42
43# Default sandbox path - where operations are restricted to
44DEFAULT_SANDBOX_PATH = os.path.expanduser('~')
45
46class PathValidationError(Exception):
47 """Exception raised for path validation errors."""
48 pass
49
50
51def normalize_path(path: str) -> str:
52 """
53 Normalize a file path to resolve symlinks, relative paths, etc.
54
55 Args:
56 path: File path to normalize
57
58 Returns:
59 str: Normalized absolute path
60 """
61 # Convert to string if it's not already
62 if not isinstance(path, str):
63 path = str(path)
64
65 # Remove any null bytes
66 path = path.replace('\0', '')
67
68 # Trim whitespace
69 path = path.strip()
70
71 # Create an absolute path
72 abs_path = os.path.abspath(path)
73
74 # Normalize the path (resolves ., .., symlinks)
75 try:
76 norm_path = os.path.normpath(abs_path)
77 return norm_path
78 except Exception as e:
79 raise PathValidationError(f"Path normalization error: {str(e)}")
80
81
82def resolve_symlinks(path: str) -> str:
83 """
84 Resolve symlinks in a path.
85
86 Args:
87 path: File path that might contain symlinks
88
89 Returns:
90 str: Path with all symlinks resolved
91 """
92 try:
93 # Use pathlib to resolve symlinks
94 resolved_path = str(pathlib.Path(path).resolve())
95 return resolved_path
96 except Exception as e:
97 raise PathValidationError(f"Failed to resolve symlinks in path '{path}': {str(e)}")
98
99
100def is_path_traversal_attempt(path: str) -> bool:
101 """
102 Check if a path contains traversal patterns that might be used in attacks.
103
104 Args:
105 path: File path to check
106
107 Returns:
108 bool: True if the path contains suspicious patterns, False otherwise
109 """
110 # Normalize for easy pattern matching
111 normalized = os.path.normpath(path)
112
113 # Check for common traversal patterns
114 suspicious_patterns = [
115 r'\.\./', # Parent directory references
116 r'/\.\./', # Parent directory references
117 r'/\.\.$', # Path ending with parent reference
118 r'^\.\./.*', # Path starting with parent reference
119 r'\\\\', # Double backslash (Windows)
120 r'\.\\', # Current directory backslash (Windows)
121 r'//', # Double forward slash
122 r'\0', # Null byte
123 r'%00', # URL-encoded null byte
124 r'%2e%2e%2f', # URL-encoded ../
125 r'%2e%2e/', # URL-encoded ../
126 r'\$\{.*?\}', # Shell variable expansion
127 r'`.*?`', # Command substitution
128 ]
129
130 for pattern in suspicious_patterns:
131 if re.search(pattern, path):
132 return True
133
134 return False
135
136
137def is_path_in_sandbox(
138 path: str,
139 sandbox_path: Optional[str] = None,
140 additional_allowed_paths: Optional[Set[str]] = None
141) -> bool:
142 """
143 Check if a path is within the sandbox path or explicitly allowed.
144
145 Args:
146 path: File path to check
147 sandbox_path: Base path that defines the sandbox (default is user's home)
148 additional_allowed_paths: Additional paths to allow
149
150 Returns:
151 bool: True if the path is in the sandbox or allowed, False otherwise
152 """
153 # Resolve the sandbox path
154 if sandbox_path is None:
155 sandbox_path = DEFAULT_SANDBOX_PATH
156
157 # Normalize all paths for comparison
158 normalized_path = normalize_path(path)
159 normalized_sandbox = normalize_path(sandbox_path)
160
161 # Check if the path is explicitly allowed
162 allowed_paths = DEFAULT_ALLOWED_PATHS.copy()
163 if additional_allowed_paths:
164 allowed_paths.update(additional_allowed_paths)
165
166 if normalized_path in allowed_paths:
167 return True
168
169 # Check if the path is a subdirectory of the sandbox
170 return normalized_path.startswith(normalized_sandbox)
171
172
173def is_path_protected(
174 path: str,
175 additional_protected_paths: Optional[Set[str]] = None,
176 allowed_paths: Optional[Set[str]] = None
177) -> bool:
178 """
179 Check if a path is in a protected area that should not be modified.
180
181 Args:
182 path: File path to check
183 additional_protected_paths: Additional paths to protect
184 allowed_paths: Paths explicitly allowed even if in protected areas
185
186 Returns:
187 bool: True if the path is protected, False otherwise
188 """
189 # Normalize the path
190 normalized_path = normalize_path(path)
191
192 # Combine default and additional protected paths
193 protected_paths = DEFAULT_PROTECTED_PATHS.copy()
194 if additional_protected_paths:
195 protected_paths.update(additional_protected_paths)
196
197 # Combine default and additional allowed paths
198 all_allowed_paths = DEFAULT_ALLOWED_PATHS.copy()
199 if allowed_paths:
200 all_allowed_paths.update(allowed_paths)
201
202 # First check if the path is explicitly allowed
203 if normalized_path in all_allowed_paths:
204 return False
205
206 # Then check if the path is in a protected area
207 for protected in protected_paths:
208 if normalized_path == protected or normalized_path.startswith(protected + os.path.sep):
209 return True
210
211 return False
212
213
214def validate_path_for_reading(
215 path: str,
216 sandbox_path: Optional[str] = None,
217 additional_allowed_paths: Optional[Set[str]] = None,
218 resolve_links: bool = True
219) -> str:
220 """
221 Validate a path for reading operations (e.g., read_file).
222
223 Args:
224 path: File path to validate
225 sandbox_path: Base path that defines the sandbox
226 additional_allowed_paths: Additional paths to allow
227 resolve_links: Whether to resolve symlinks
228
229 Returns:
230 str: Validated path
231
232 Raises:
233 PathValidationError: If the path does not pass validation
234 """
235 # Check for empty path
236 if not path:
237 raise PathValidationError("Path cannot be empty")
238
239 # Normalize path
240 normalized_path = normalize_path(path)
241
242 # Check for path traversal attempts
243 if is_path_traversal_attempt(path):
244 raise PathValidationError(f"Path '{path}' contains suspicious traversal patterns")
245
246 # Resolve symlinks if requested
247 if resolve_links:
248 normalized_path = resolve_symlinks(normalized_path)
249
250 # Check if path exists (for reading, the file should exist)
251 if not os.path.exists(normalized_path):
252 raise PathValidationError(f"Path '{path}' does not exist")
253
254 # Check if path is a file (for reading, it should be a file not a directory)
255 if os.path.isdir(normalized_path):
256 raise PathValidationError(f"Path '{path}' is a directory, not a file")
257
258 # Check if path is in sandbox or explicitly allowed
259 if not is_path_in_sandbox(normalized_path, sandbox_path, additional_allowed_paths):
260 raise PathValidationError(f"Path '{path}' is outside the allowed directories")
261
262 return normalized_path
263
264
265def validate_path_for_writing(
266 path: str,
267 sandbox_path: Optional[str] = None,
268 additional_allowed_paths: Optional[Set[str]] = None,
269 additional_protected_paths: Optional[Set[str]] = None,
270 resolve_links: bool = True
271) -> str:
272 """
273 Validate a path for writing operations (e.g., write_file).
274
275 Args:
276 path: File path to validate
277 sandbox_path: Base path that defines the sandbox
278 additional_allowed_paths: Additional paths to allow
279 additional_protected_paths: Additional paths to protect
280 resolve_links: Whether to resolve symlinks
281
282 Returns:
283 str: Validated path
284
285 Raises:
286 PathValidationError: If the path does not pass validation
287 """
288 # Check for empty path
289 if not path:
290 raise PathValidationError("Path cannot be empty")
291
292 # Normalize path
293 normalized_path = normalize_path(path)
294
295 # Check for path traversal attempts
296 if is_path_traversal_attempt(path):
297 raise PathValidationError(f"Path '{path}' contains suspicious traversal patterns")
298
299 # Resolve symlinks if requested
300 if resolve_links:
301 normalized_path = resolve_symlinks(normalized_path)
302
303 # Check if path is protected (for writing, protected paths are off-limits)
304 if is_path_protected(normalized_path, additional_protected_paths, additional_allowed_paths):
305 raise PathValidationError(f"Path '{path}' is in a protected area")
306
307 # Check if path is in sandbox or explicitly allowed
308 if not is_path_in_sandbox(normalized_path, sandbox_path, additional_allowed_paths):
309 raise PathValidationError(f"Path '{path}' is outside the allowed directories")
310
311 # Ensure the directory exists
312 directory = os.path.dirname(normalized_path)
313 if not os.path.exists(directory):
314 try:
315 # Create the directory if it doesn't exist
316 os.makedirs(directory, exist_ok=True)
317 except Exception as e:
318 raise PathValidationError(f"Cannot create directory for path '{path}': {str(e)}")
319
320 return normalized_path
321
322
323def validate_path_for_searching(
324 directory: str,
325 pattern: str,
326 sandbox_path: Optional[str] = None,
327 additional_allowed_paths: Optional[Set[str]] = None,
328 resolve_links: bool = True
329) -> Tuple[str, str]:
330 """
331 Validate a directory and pattern for file searching operations (e.g., find_files).
332
333 Args:
334 directory: Directory to search in
335 pattern: File pattern to search for
336 sandbox_path: Base path that defines the sandbox
337 additional_allowed_paths: Additional paths to allow
338 resolve_links: Whether to resolve symlinks
339
340 Returns:
341 tuple: (validated_directory, validated_pattern)
342
343 Raises:
344 PathValidationError: If the directory or pattern does not pass validation
345 """
346 # Check for empty directory or pattern
347 if not directory:
348 raise PathValidationError("Directory cannot be empty")
349
350 if not pattern:
351 raise PathValidationError("Pattern cannot be empty")
352
353 # Normalize directory path
354 normalized_dir = normalize_path(directory)
355
356 # Check for path traversal attempts in directory
357 if is_path_traversal_attempt(directory):
358 raise PathValidationError(f"Directory '{directory}' contains suspicious traversal patterns")
359
360 # Resolve symlinks if requested
361 if resolve_links:
362 normalized_dir = resolve_symlinks(normalized_dir)
363
364 # Check if directory exists
365 if not os.path.exists(normalized_dir):
366 raise PathValidationError(f"Directory '{directory}' does not exist")
367
368 # Check if path is a directory
369 if not os.path.isdir(normalized_dir):
370 raise PathValidationError(f"Path '{directory}' is not a directory")
371
372 # Check if directory is in sandbox or explicitly allowed
373 if not is_path_in_sandbox(normalized_dir, sandbox_path, additional_allowed_paths):
374 raise PathValidationError(f"Directory '{directory}' is outside the allowed directories")
375
376 # Validate and sanitize the pattern
377 # Remove any path components from the pattern
378 sanitized_pattern = os.path.basename(pattern)
379
380 # Check for suspicious patterns in the pattern
381 suspicious_pattern_chars = r'[&|;`$()<>]'
382 if re.search(suspicious_pattern_chars, sanitized_pattern):
383 raise PathValidationError(f"Pattern '{pattern}' contains suspicious characters")
384
385 return normalized_dir, sanitized_pattern
386
387
388def configure_path_sandbox(
389 sandbox_path: Optional[str] = None,
390 additional_allowed_paths: Optional[Set[str]] = None,
391 additional_protected_paths: Optional[Set[str]] = None
392) -> Dict[str, Union[str, Set[str]]]:
393 """
394 Configure the path sandbox with custom settings.
395
396 Args:
397 sandbox_path: Base path that defines the sandbox
398 additional_allowed_paths: Additional paths to allow
399 additional_protected_paths: Additional paths to protect
400
401 Returns:
402 dict: Configuration dictionary with sandbox settings
403 """
404 # Start with default values
405 config = {
406 'sandbox_path': sandbox_path or DEFAULT_SANDBOX_PATH,
407 'allowed_paths': DEFAULT_ALLOWED_PATHS.copy(),
408 'protected_paths': DEFAULT_PROTECTED_PATHS.copy()
409 }
410
411 # Add additional allowed paths
412 if additional_allowed_paths:
413 config['allowed_paths'].update(additional_allowed_paths)
414
415 # Add additional protected paths
416 if additional_protected_paths:
417 config['protected_paths'].update(additional_protected_paths)
418
419 return config
ollamatools-project/repomix-output.xml +5485 -619
……@@ -64,9 +64,11 @@ examples/
6464 permission_example.py
6565 secure_doc_tools.py
6666 secure_web_tools.py
67 security_example.py
6768 test_commands.py
6869 test_ollama_integration.py
6970 test_performance.py
71 transparent_ollama_agent.py
7072 web_requests.py
7173 web_tools_examples.py
7274ollamatools/
……@@ -80,10 +82,15 @@ ollamatools/
8082 logger.py
8183 __init__.py
8284 agent.py
85 command_validator.py
8386 config.py
87 errors.py
8488 logger.py
89 opensearch_web_tools.py
90 path_validator.py
8591 security.py
8692 security1.py
93 url_validator.py
8794ollamatools.egg-info/
8895 dependency_links.txt
8996 PKG-INFO
……@@ -107,6 +114,7 @@ tests/
107114 test_system_tools.py
108115 test_web_tools.py
109116CHANGELOG.md
117command_validator.py
110118COMPLETION_REPORT.md
111119CONTRIBUTING.md
112120example_doc.html
……@@ -114,13 +122,16 @@ example_doc.md
114122LICENSE
115123make_executable.sh
116124mkdir_command.sh
125path_validator.py
117126pytest.ini
118127README.md
119128requirements.txt
120129secure_doc.md
130SECURITY.md
121131setup.py
122132simple_test_web_tools.py
123133tree-struct
134url_validator.py
124135</directory_structure>
125136
126137<files>
……@@ -2825,6 +2836,563 @@ if __name__ == "__main__":
28252836 main()
28262837</file>
28272838
2839<file path="examples/security_example.py">
2840#!/usr/bin/env python3
2841"""
2842Example demonstrating the enhanced security features in OllamaTools.
2843
2844This script shows how to use the command validation, path validation,
2845URL validation, and error handling features safely.
2846"""
2847
2848import os
2849import sys
2850import logging
2851from typing import Set, Dict, Any
2852
2853# Add the parent directory to the path so we can import the package
2854sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
2855
2856from ollamatools.security import PermissionManager
2857from ollamatools.command_validator import (
2858 validate_and_sanitize_command,
2859 CommandValidationError,
2860 DEFAULT_ALLOWLIST
2861)
2862from ollamatools.path_validator import (
2863 validate_path_for_reading,
2864 validate_path_for_writing,
2865 validate_path_for_searching,
2866 PathValidationError,
2867 configure_path_sandbox
2868)
2869from ollamatools.url_validator import (
2870 validate_url,
2871 URLValidationError,
2872 configure_domain_lists
2873)
2874from ollamatools.errors import (
2875 OllamaToolsError,
2876 handle_exception,
2877 format_error_response
2878)
2879from ollamatools.tools.system_tools import (
2880 execute_command,
2881 read_file,
2882 write_file,
2883 find_files
2884)
2885from ollamatools.tools.web_tools import fetch_url
2886from ollamatools.utils.logger import setup_logger
2887
2888# Set up logging
2889logger = setup_logger("security_example", level="INFO")
2890
2891def demonstrate_command_validation():
2892 """Demonstrate the command validation features."""
2893 print("\n=== Command Validation Demo ===")
2894
2895 try:
2896 # Example 1: Basic validation and execution
2897 print("\n--- Example 1: Valid Command ---")
2898 command = "ls"
2899 args = ["-la"]
2900
2901 print(f"Validating command: {command} {' '.join(args)}")
2902 validated_command, validated_args = validate_and_sanitize_command(command, args)
2903 print(f"Validated command: {validated_command} {' '.join(validated_args)}")
2904
2905 result = execute_command(command, args)
2906 print(f"Command executed with result code: {result.get('returncode')}")
2907 print(f"First few lines of output: {result.get('stdout', '')[:200]}...")
2908
2909 # Example 2: Command not in allowlist
2910 print("\n--- Example 2: Command Not in Allowlist ---")
2911 command = "malicious_command"
2912 args = []
2913
2914 print(f"Validating command: {command}")
2915 try:
2916 validated_command, validated_args = validate_and_sanitize_command(command, args)
2917 print("This should not be reached")
2918 except CommandValidationError as e:
2919 print(f"Validation error as expected: {e}")
2920
2921 # Example 3: Custom allowlist
2922 print("\n--- Example 3: Custom Allowlist ---")
2923 custom_allowlist = DEFAULT_ALLOWLIST.copy()
2924 custom_allowlist.add("custom_command")
2925
2926 command = "custom_command"
2927 args = ["--option", "value"]
2928
2929 print(f"Validating command with custom allowlist: {command}")
2930 try:
2931 validated_command, validated_args = validate_and_sanitize_command(
2932 command, args, custom_allowlist=custom_allowlist
2933 )
2934 print(f"Command validated successfully: {validated_command} {' '.join(validated_args)}")
2935 except CommandValidationError as e:
2936 print(f"Validation error: {e}")
2937
2938 # Example 4: Command with dangerous arguments
2939 print("\n--- Example 4: Command with Dangerous Arguments ---")
2940 command = "ls"
2941 args = ["|", "rm", "-rf", "/"] # Dangerous pipe to rm
2942
2943 print(f"Validating command with dangerous args: {command} {' '.join(args)}")
2944 try:
2945 validated_command, validated_args = validate_and_sanitize_command(command, args)
2946 print("This should not be reached")
2947 except CommandValidationError as e:
2948 print(f"Validation error as expected: {e}")
2949
2950 except Exception as e:
2951 error_details = handle_exception(e)
2952 print(f"Unexpected error: {error_details['error_message']}")
2953
2954def demonstrate_path_validation():
2955 """Demonstrate the path validation features."""
2956 print("\n=== Path Validation Demo ===")
2957
2958 try:
2959 # Example 1: Reading a valid file
2960 print("\n--- Example 1: Reading a Valid File ---")
2961 # Use README.md which should exist in the project
2962 file_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "README.md")
2963
2964 print(f"Validating path for reading: {file_path}")
2965 try:
2966 validated_path = validate_path_for_reading(file_path)
2967 print(f"Path validated successfully: {validated_path}")
2968
2969 content = read_file(file_path)
2970 print(f"First 100 characters of file content: {content[:100]}...")
2971 except PathValidationError as e:
2972 print(f"Path validation error: {e}")
2973
2974 # Example 2: Writing to a safe path
2975 print("\n--- Example 2: Writing to a Safe Path ---")
2976 # Use a temporary file in the current directory
2977 write_path = os.path.join(os.getcwd(), "temp_test_file.txt")
2978
2979 print(f"Validating path for writing: {write_path}")
2980 try:
2981 validated_path = validate_path_for_writing(write_path)
2982 print(f"Path validated successfully: {validated_path}")
2983
2984 content = "This is a test file created by the security example."
2985 success = write_file(write_path, content)
2986 print(f"Write success: {success}")
2987
2988 # Clean up
2989 if os.path.exists(write_path):
2990 os.remove(write_path)
2991 print(f"Cleaned up test file: {write_path}")
2992 except PathValidationError as e:
2993 print(f"Path validation error: {e}")
2994
2995 # Example 3: Path traversal attempt
2996 print("\n--- Example 3: Path Traversal Attempt ---")
2997 traversal_path = "../../../etc/passwd"
2998
2999 print(f"Validating path with traversal: {traversal_path}")
3000 try:
3001 validated_path = validate_path_for_reading(traversal_path)
3002 print("This should not be reached")
3003 except PathValidationError as e:
3004 print(f"Path validation error as expected: {e}")
3005
3006 # Example 4: Protected path write attempt
3007 print("\n--- Example 4: Protected Path Write Attempt ---")
3008 protected_path = "/etc/hosts"
3009
3010 print(f"Validating protected path for writing: {protected_path}")
3011 try:
3012 validated_path = validate_path_for_writing(protected_path)
3013 print("This should not be reached")
3014 except PathValidationError as e:
3015 print(f"Path validation error as expected: {e}")
3016
3017 # Example 5: Custom sandbox configuration
3018 print("\n--- Example 5: Custom Sandbox Configuration ---")
3019 # Create a temporary directory for the sandbox
3020 sandbox_dir = os.path.join(os.getcwd(), "sandbox_test")
3021 os.makedirs(sandbox_dir, exist_ok=True)
3022
3023 # Configure a custom sandbox
3024 sandbox_config = configure_path_sandbox(
3025 sandbox_path=sandbox_dir,
3026 additional_allowed_paths={os.path.join(os.getcwd(), "README.md")},
3027 additional_protected_paths=set()
3028 )
3029
3030 print(f"Configured custom sandbox at: {sandbox_dir}")
3031
3032 # Try to write to the sandbox
3033 sandbox_file = os.path.join(sandbox_dir, "test.txt")
3034 print(f"Writing to file in custom sandbox: {sandbox_file}")
3035
3036 try:
3037 validated_path = validate_path_for_writing(
3038 sandbox_file,
3039 sandbox_path=sandbox_config['sandbox_path']
3040 )
3041 print(f"Path validated successfully: {validated_path}")
3042
3043 content = "This is a test file in the custom sandbox."
3044 success = write_file(
3045 sandbox_file,
3046 content,
3047 sandbox_path=sandbox_config['sandbox_path']
3048 )
3049 print(f"Write success: {success}")
3050 except PathValidationError as e:
3051 print(f"Path validation error: {e}")
3052
3053 # Clean up the sandbox
3054 if os.path.exists(sandbox_file):
3055 os.remove(sandbox_file)
3056 if os.path.exists(sandbox_dir):
3057 os.rmdir(sandbox_dir)
3058 print(f"Cleaned up sandbox directory: {sandbox_dir}")
3059
3060 except Exception as e:
3061 error_details = handle_exception(e)
3062 print(f"Unexpected error: {error_details['error_message']}")
3063
3064def demonstrate_url_validation():
3065 """Demonstrate the URL validation features."""
3066 print("\n=== URL Validation Demo ===")
3067
3068 try:
3069 # Example 1: Valid URL
3070 print("\n--- Example 1: Valid URL ---")
3071 url = "https://www.example.com/path?param=value"
3072
3073 print(f"Validating URL: {url}")
3074 try:
3075 validated_url = validate_url(url)
3076 print(f"URL validated successfully: {validated_url}")
3077 except URLValidationError as e:
3078 print(f"URL validation error: {e}")
3079
3080 # Example 2: Blocked domain
3081 print("\n--- Example 2: Blocked Domain ---")
3082 blocked_url = "http://localhost:8080/path"
3083
3084 print(f"Validating blocked URL: {blocked_url}")
3085 try:
3086 validated_url = validate_url(blocked_url)
3087 print("This should not be reached")
3088 except URLValidationError as e:
3089 print(f"URL validation error as expected: {e}")
3090
3091 # Example 3: Disallowed scheme
3092 print("\n--- Example 3: Disallowed Scheme ---")
3093 bad_scheme_url = "file:///etc/passwd"
3094
3095 print(f"Validating URL with disallowed scheme: {bad_scheme_url}")
3096 try:
3097 validated_url = validate_url(bad_scheme_url)
3098 print("This should not be reached")
3099 except URLValidationError as e:
3100 print(f"URL validation error as expected: {e}")
3101
3102 # Example 4: Custom domain configuration
3103 print("\n--- Example 4: Custom Domain Configuration ---")
3104
3105 # Configure custom domain lists
3106 domain_allowlist, domain_blocklist = configure_domain_lists(
3107 additional_allowlist={"trusted-site.com"},
3108 additional_blocklist={"malicious-site.com"}
3109 )
3110
3111 # Test a custom allowed domain
3112 allowed_url = "https://trusted-site.com/path"
3113 print(f"Validating URL with custom allowlist: {allowed_url}")
3114
3115 try:
3116 validated_url = validate_url(
3117 allowed_url,
3118 domain_allowlist=domain_allowlist,
3119 domain_blocklist=domain_blocklist
3120 )
3121 print(f"URL validated successfully: {validated_url}")
3122 except URLValidationError as e:
3123 print(f"URL validation error: {e}")
3124
3125 # Test a custom blocked domain
3126 blocked_url = "https://malicious-site.com/path"
3127 print(f"Validating URL with custom blocklist: {blocked_url}")
3128
3129 try:
3130 validated_url = validate_url(
3131 blocked_url,
3132 domain_allowlist=domain_allowlist,
3133 domain_blocklist=domain_blocklist
3134 )
3135 print("This should not be reached")
3136 except URLValidationError as e:
3137 print(f"URL validation error as expected: {e}")
3138
3139 # Example 5: URL normalization
3140 print("\n--- Example 5: URL Normalization ---")
3141 unnormalized_url = "HTTP://Example.COM/path/../other/./file.html?param=value#fragment"
3142
3143 print(f"Normalizing URL: {unnormalized_url}")
3144 try:
3145 validated_url = validate_url(unnormalized_url)
3146 print(f"URL normalized to: {validated_url}")
3147 except URLValidationError as e:
3148 print(f"URL validation error: {e}")
3149
3150 # Example 6: Real HTTP request
3151 print("\n--- Example 6: Real HTTP Request ---")
3152 request_url = "https://httpbin.org/get"
3153
3154 print(f"Making request to: {request_url}")
3155 try:
3156 result = fetch_url(request_url)
3157 print(f"Request successful with status: {result.get('status')}")
3158 print(f"Response snippet: {str(result.get('content'))[:100]}...")
3159 except Exception as e:
3160 print(f"Request failed: {e}")
3161
3162 except Exception as e:
3163 error_details = handle_exception(e)
3164 print(f"Unexpected error: {error_details['error_message']}")
3165
3166def demonstrate_error_handling():
3167 """Demonstrate the standardized error handling."""
3168 print("\n=== Error Handling Demo ===")
3169
3170 try:
3171 # Example 1: Basic error handling
3172 print("\n--- Example 1: Basic Error Handling ---")
3173 try:
3174 # Force an error
3175 result = 1 / 0
3176 except Exception as e:
3177 # Handle the error
3178 error_response = format_error_response(e)
3179 print(f"Error type: {error_response['error_type']}")
3180 print(f"Error message: {error_response['error_message']}")
3181
3182 # Example 2: OllamaToolsError
3183 print("\n--- Example 2: OllamaToolsError ---")
3184 try:
3185 # Simulate a command error
3186 from ollamatools.errors import CommandError, ERROR_CODES
3187 raise CommandError(
3188 message="Command failed to execute",
3189 command="test_command",
3190 args=["--option", "value"],
3191 exit_code=1,
3192 stderr="Error: invalid option",
3193 code=ERROR_CODES["COMMAND_EXECUTION_ERROR"]
3194 )
3195 except OllamaToolsError as e:
3196 # Handle the error with the utility function
3197 error_details = handle_exception(e, log_error=True)
3198 print(f"Error code: {error_details.get('error_code')}")
3199 print(f"Error message: {error_details['error_message']}")
3200
3201 # Example 3: Nested error handling
3202 print("\n--- Example 3: Nested Error Handling ---")
3203 try:
3204 # Outer try block
3205 try:
3206 # Inner operation that fails
3207 from ollamatools.errors import FileError
3208 raise FileError(
3209 message="File could not be read",
3210 path="/path/to/nonexistent/file.txt",
3211 code=ERROR_CODES["FILE_NOT_FOUND"]
3212 )
3213 except FileError as e:
3214 # Convert to a different error type
3215 from ollamatools.errors import ApiError
3216 raise ApiError(
3217 message=f"API failed due to file error: {e}",
3218 api_name="file_api",
3219 status_code=404,
3220 code=ERROR_CODES["API_ERROR"]
3221 ) from e
3222 except OllamaToolsError as e:
3223 # Handle the final error
3224 error_details = handle_exception(e, include_traceback=True)
3225 print(f"Final error message: {error_details['error_message']}")
3226 if 'traceback' in error_details:
3227 print(f"Error has traceback with {len(error_details['traceback'])} frames")
3228
3229 except Exception as e:
3230 error_details = handle_exception(e)
3231 print(f"Unexpected error: {error_details['error_message']}")
3232
3233def demonstrate_permission_system():
3234 """Demonstrate the enhanced permission system."""
3235 print("\n=== Permission System Demo ===")
3236 print("This demo will prompt for permissions interactively.")
3237 print("You'll need to respond to permission requests.")
3238
3239 # Create a permission manager with prompts enabled
3240 permission_manager = PermissionManager(
3241 prompt_enabled=True,
3242 sudo_always_prompt=True
3243 )
3244
3245 try:
3246 # Example 1: Basic permission request
3247 print("\n--- Example 1: Basic Permission Request ---")
3248 command = "ls"
3249 args = ["-la"]
3250
3251 print(f"Requesting permission to execute: {command} {' '.join(args)}")
3252 permission_granted = permission_manager.request_permission(
3253 function_name="execute_command",
3254 args={"command": command, "args": args}
3255 )
3256
3257 if permission_granted:
3258 print("Permission granted! Executing command...")
3259 result = execute_command(command, args, permission_manager=permission_manager)
3260 print(f"Command executed with result code: {result.get('returncode')}")
3261 else:
3262 print("Permission denied. Command not executed.")
3263
3264 # Example 2: Sudo command
3265 print("\n--- Example 2: Sudo Command ---")
3266 sudo_command = "sudo"
3267 sudo_args = ["ls", "/root"]
3268
3269 print(f"Requesting permission to execute sudo command: {sudo_command} {' '.join(sudo_args)}")
3270 # Note: is_sudo will be auto-detected, but we set it explicitly here for the example
3271 permission_granted = permission_manager.request_permission(
3272 function_name="execute_command",
3273 args={"command": sudo_command, "args": sudo_args},
3274 is_sudo=True
3275 )
3276
3277 if permission_granted:
3278 print("Permission granted! Executing sudo command...")
3279 # Note: This may fail if the user doesn't have sudo privileges
3280 result = execute_command(sudo_command, sudo_args, permission_manager=permission_manager)
3281 print(f"Command executed with result code: {result.get('returncode')}")
3282 else:
3283 print("Permission denied. Sudo command not executed.")
3284
3285 # Example 3: Session permissions
3286 print("\n--- Example 3: Session Permissions ---")
3287 print("When prompted, choose 'S' to allow for the session.")
3288
3289 # First request - will prompt
3290 file_path = "README.md"
3291 print(f"Requesting permission to read file: {file_path}")
3292 permission_granted = permission_manager.request_permission(
3293 function_name="read_file",
3294 args={"path": file_path}
3295 )
3296
3297 if permission_granted:
3298 print("Permission granted! Reading file...")
3299 content = read_file(file_path, permission_manager=permission_manager)
3300 print(f"File read successfully, first 50 chars: {content[:50]}...")
3301 else:
3302 print("Permission denied. File not read.")
3303
3304 # Second request - should not prompt if session permission was granted
3305 print("\nMaking a second request for the same operation...")
3306 print(f"Requesting permission to read file: {file_path}")
3307 permission_granted = permission_manager.request_permission(
3308 function_name="read_file",
3309 args={"path": file_path}
3310 )
3311
3312 if permission_granted:
3313 print("Permission granted without prompting! Reading file...")
3314 content = read_file(file_path, permission_manager=permission_manager)
3315 print(f"File read successfully, first 50 chars: {content[:50]}...")
3316 else:
3317 print("Permission denied. File not read.")
3318
3319 # Example 4: Command modification
3320 print("\n--- Example 4: Command Modification ---")
3321 print("When prompted, choose 'M' to modify the operation.")
3322
3323 command = "cat"
3324 args = ["/etc/passwd"] # This would be a sensitive file
3325
3326 print(f"Requesting permission to execute: {command} {' '.join(args)}")
3327 permission_granted = permission_manager.request_permission(
3328 function_name="execute_command",
3329 args={"command": command, "args": args}
3330 )
3331
3332 if permission_granted:
3333 print("Permission granted (after modification)! Executing command...")
3334 result = execute_command(command, args, permission_manager=permission_manager)
3335 print(f"Command executed with result code: {result.get('returncode')}")
3336 else:
3337 print("Permission denied or modification cancelled. Command not executed.")
3338
3339 except Exception as e:
3340 error_details = handle_exception(e)
3341 print(f"Unexpected error: {error_details['error_message']}")
3342
3343def main():
3344 """Run all the security demonstration examples."""
3345 print("=== OllamaTools Security Features Demonstration ===")
3346 print("This script demonstrates the enhanced security features in OllamaTools.")
3347
3348 try:
3349 # Choose which demos to run
3350 demos = {
3351 "1": ("Command Validation", demonstrate_command_validation),
3352 "2": ("Path Validation", demonstrate_path_validation),
3353 "3": ("URL Validation", demonstrate_url_validation),
3354 "4": ("Error Handling", demonstrate_error_handling),
3355 "5": ("Permission System", demonstrate_permission_system),
3356 "all": ("All Demos", None)
3357 }
3358
3359 print("\nAvailable demos:")
3360 for key, (name, _) in demos.items():
3361 if key != "all":
3362 print(f" {key}: {name}")
3363 print(" all: Run all demos")
3364
3365 choice = input("\nEnter demo number(s) to run (e.g. '1', '1 3 5', or 'all'): ").strip().lower()
3366
3367 if choice == "all":
3368 # Run all demos
3369 demonstrate_command_validation()
3370 demonstrate_path_validation()
3371 demonstrate_url_validation()
3372 demonstrate_error_handling()
3373 demonstrate_permission_system()
3374 else:
3375 # Run selected demos
3376 selected = choice.split()
3377 for demo_id in selected:
3378 if demo_id in demos and demo_id != "all":
3379 print(f"\nRunning demo: {demos[demo_id][0]}")
3380 demos[demo_id][1]()
3381 else:
3382 print(f"Unknown demo ID: {demo_id}")
3383
3384 print("\n=== Security Demonstration Completed ===")
3385
3386 except KeyboardInterrupt:
3387 print("\n\nDemonstration interrupted by user.")
3388 except Exception as e:
3389 error_details = handle_exception(e, log_error=True)
3390 print(f"\nUnexpected error in main function: {error_details['error_message']}")
3391
3392if __name__ == "__main__":
3393 main()
3394</file>
3395
28283396<file path="examples/test_commands.py">
28293397#!/usr/bin/env python3
28303398"""
……@@ -3195,124 +3763,824 @@ if __name__ == "__main__":
31953763 unittest.main()
31963764</file>
31973765
3198<file path="examples/web_requests.py">
3766<file path="examples/transparent_ollama_agent.py">
3767#!/usr/bin/env python3
31993768"""
3200Example demonstrating the use of web tools in OllamaTools.
3769Transparent Ollama Agent with OpenSearchNG Integration.
32013770
3202This script shows how to make web requests using the fetch_url function.
3771This script demonstrates an improved OllamaAgent that:
37721. Shows transparent execution of commands and functions
37732. Integrates with a locally running OpenSearchNG instance
37743. Provides detailed feedback on what's happening
37754. Handles errors gracefully with suggestions for fixes
3776
3777Usage:
3778 python transparent_ollama_agent.py
32033779"""
32043780
3205from ollamatools.tools.web_tools import fetch_url
3206from ollamatools.security import PermissionManager
3781import os
3782import sys
32073783import json
3784import logging
3785import time
3786from typing import Dict, Any, List, Optional, Set, Union, Tuple
32083787
3209def main():
3210 """Run the example."""
3211 print("OllamaTools Web Request Example")
3212 print("===============================")
3213
3214 # Create a permission manager
3215 permission_manager = PermissionManager(prompt_enabled=True)
3216
3217 print("\n1. Simple GET request")
3218 try:
3219 result = fetch_url(
3220 "https://httpbin.org/get",
3221 permission_manager=permission_manager
3222 )
3223 print(f"Status: {result['status']}")
3224 print(f"Response content type: {result['headers'].get('Content-Type', 'Unknown')}")
3225 print(f"Response snippet: {json.dumps(result['content'], indent=2)[:200]}...")
3226 except Exception as e:
3227 print(f"Error: {e}")
3788# Add the parent directory to the Python path to import ollamatools
3789sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
3790
3791from ollamatools import OllamaAgent, Config
3792from ollamatools.command_validator import DEFAULT_ALLOWLIST
3793from ollamatools.utils.logger import setup_logger
3794from ollamatools.errors import (
3795 OllamaToolsError,
3796 CommandError,
3797 FileError,
3798 WebError,
3799 handle_exception
3800)
3801
3802# Import the OpenSearchNG integration
3803from ollamatools.opensearch_web_tools import (
3804 opensearch_query,
3805 opensearch_fetch_url,
3806 get_opensearch_info,
3807 DEFAULT_OPENSEARCH_CONFIG
3808)
3809
3810# Set up logging
3811logger = setup_logger("transparent_ollama_agent", level="INFO")
3812
3813# Extend the default command allowlist with useful commands
3814EXTENDED_ALLOWLIST = DEFAULT_ALLOWLIST.union({
3815 "python", "python3", "calc", "bc", "find", "grep", "awk", "sed",
3816 "head", "tail", "more", "less", "cat", "echo", "cd", "pwd"
3817})
3818
3819# Configuration for OpenSearchNG
3820OPENSEARCH_CONFIG = DEFAULT_OPENSEARCH_CONFIG.copy()
3821OPENSEARCH_CONFIG.update({
3822 "base_url": "http://localhost:8081", # Updated to your container's port
3823 "engines": ["google", "bing", "duckduckgo", "brave"],
3824 "num_results": 5
3825})
3826
3827class TransparentOllamaAgent:
3828 """
3829 An enhanced OllamaAgent that provides transparent execution and better feedback.
3830 """
32283831
3229 print("\n2. POST request with data")
3230 try:
3231 post_data = {"name": "OllamaTools", "version": "0.1.0"}
3232 result = fetch_url(
3233 "https://httpbin.org/post",
3234 method="POST",
3235 data=post_data,
3236 permission_manager=permission_manager
3832 def __init__(
3833 self,
3834 model: str = "llama3.1:latest",
3835 permission_prompts: bool = True,
3836 opensearch_config: Optional[Dict[str, Any]] = None,
3837 command_allowlist: Optional[Set[str]] = None
3838 ):
3839 """
3840 Initialize the agent.
3841
3842 Args:
3843 model: Ollama model to use
3844 permission_prompts: Whether to display permission prompts
3845 opensearch_config: OpenSearchNG configuration
3846 command_allowlist: Custom command allowlist
3847 """
3848 # Initialize OllamaAgent
3849 self.agent = OllamaAgent(
3850 model=model,
3851 permission_prompts=permission_prompts
32373852 )
3238 print(f"Status: {result['status']}")
3239 print(f"Response content type: {result['headers'].get('Content-Type', 'Unknown')}")
3240 print(f"Response snippet: {json.dumps(result['content'], indent=2)[:200]}...")
3241 except Exception as e:
3242 print(f"Error: {e}")
3853
3854 # Store configuration
3855 self.opensearch_config = opensearch_config or OPENSEARCH_CONFIG
3856 self.command_allowlist = command_allowlist or EXTENDED_ALLOWLIST
3857
3858 # Initialize state
3859 self.context = []
3860 self.execution_history = []
3861
3862 # Check if OpenSearchNG is available
3863 try:
3864 self._print_status("Checking OpenSearchNG availability...")
3865 info = get_opensearch_info(config=self.opensearch_config)
3866 self._print_success(f"OpenSearchNG running at {self.opensearch_config['base_url']}")
3867
3868 # Log available engines
3869 if 'engines' in info and info['engines']:
3870 self._print_info(f"Available search engines: {', '.join(info['engines'])}")
3871 except Exception as e:
3872 self._print_error(f"OpenSearchNG not available: {str(e)}")
3873 self._print_warning("Falling back to regular search methods")
3874 self._suggest_fix("Make sure your OpenSearchNG instance is running at " +
3875 f"{self.opensearch_config['base_url']}")
32433876
3244 print("\n3. GET request with parameters")
3245 try:
3246 params = {"query": "ollama", "limit": "5"}
3247 result = fetch_url(
3248 "https://httpbin.org/get",
3249 params=params,
3250 permission_manager=permission_manager
3251 )
3252 print(f"Status: {result['status']}")
3253 print(f"Response content type: {result['headers'].get('Content-Type', 'Unknown')}")
3254 print(f"Response snippet: {json.dumps(result['content'], indent=2)[:200]}...")
3255 except Exception as e:
3256 print(f"Error: {e}")
3877 def chat(self, message: str) -> str:
3878 """
3879 Process a chat message, showing the execution steps transparently.
3880
3881 Args:
3882 message: User message
3883
3884 Returns:
3885 str: Response message
3886 """
3887 # Create a strong system prompt that directs the model's behavior
3888 system_prompt = f"""
3889 You are an AI assistant with transparent access to various tools.
3890 You always explain what you're doing step by step and show your work.
3891
3892 When a user asks you a question, you should:
3893 1. Identify the most appropriate tool for the task
3894 2. Describe what you're going to do before you do it
3895 3. Show the results of any commands or functions you execute
3896 4. Explain how you interpret these results
3897
3898 MOST IMPORTANTLY: When calculating math problems, ALWAYS use the 'calc' command rather than
3899 doing the calculation yourself or using python -c. For example: calc 2+2
3900
3901 For searching the web:
3902 - Use the 'opensearch_query' function to perform web searches
3903 - Use the 'opensearch_fetch_url' function to fetch web page content safely
3904 - Specify search engines if needed (available: {', '.join(self.opensearch_config['engines'])})
3905
3906 For file and system operations:
3907 - Use 'find_files' to locate files
3908 - Use 'read_file' to view file content
3909 - Use 'execute_command' for running shell commands like 'ls', 'pwd', etc.
3910
3911 Always check command permissions carefully and explain what each command does.
3912 For directory structure, use 'find .' or 'ls -la' commands.
3913 """
3914
3915 try:
3916 # Print what we're doing
3917 self._print_status(f"Processing message: {message}")
3918
3919 # Start timer for perf measurement
3920 start_time = time.time()
3921
3922 # Send message to the agent
3923 response = self.agent.chat(
3924 message=message,
3925 system_prompt=system_prompt,
3926 context=self.context
3927 )
3928
3929 # Update context
3930 self.context.append({"role": "user", "content": message})
3931 self.context.append({"role": "assistant", "content": response})
3932
3933 # Limit context size
3934 if len(self.context) > 10:
3935 self.context = self.context[-10:]
3936
3937 # Calculate execution time
3938 execution_time = time.time() - start_time
3939 self._print_success(f"Response generated in {execution_time:.2f} seconds")
3940
3941 return response
3942
3943 except OllamaToolsError as e:
3944 error_details = handle_exception(e, log_error=True)
3945
3946 self._print_error(f"Error: {error_details['error_message']}")
3947 self._suggest_fix(self._generate_error_fix_suggestion(e))
3948
3949 return f"I encountered an error: {error_details['error_message']}\n\n" + \
3950 f"Suggested fix: {self._generate_error_fix_suggestion(e)}"
3951
3952 except Exception as e:
3953 error_details = handle_exception(e, log_error=True)
3954
3955 self._print_error(f"Unexpected error: {str(e)}")
3956
3957 return f"I encountered an unexpected error: {str(e)}"
32573958
3258 print("\n4. Request with custom headers")
3959 def execute_search(self, query: str) -> Dict[str, Any]:
3960 """
3961 Demonstrate direct search execution using OpenSearchNG.
3962
3963 Args:
3964 query: Search query
3965
3966 Returns:
3967 dict: Search results
3968 """
3969 try:
3970 self._print_status(f"Searching for: {query}")
3971
3972 # Execute the search
3973 results = opensearch_query(
3974 query=query,
3975 config=self.opensearch_config,
3976 permission_manager=self.agent.permission_manager
3977 )
3978
3979 # Show the number of results
3980 num_results = len(results.get("results", []))
3981 self._print_success(f"Found {num_results} results")
3982
3983 # Show suggested answer if available
3984 if results.get("answer"):
3985 self._print_info(f"Direct answer: {results['answer']}")
3986
3987 # Register in execution history
3988 self.execution_history.append({
3989 "function": "opensearch_query",
3990 "args": {"query": query},
3991 "timestamp": time.time(),
3992 "success": True,
3993 "num_results": num_results
3994 })
3995
3996 return results
3997
3998 except Exception as e:
3999 self._print_error(f"Search failed: {str(e)}")
4000
4001 # Register in execution history
4002 self.execution_history.append({
4003 "function": "opensearch_query",
4004 "args": {"query": query},
4005 "timestamp": time.time(),
4006 "success": False,
4007 "error": str(e)
4008 })
4009
4010 self._suggest_fix(self._generate_error_fix_suggestion(e))
4011 raise
4012
4013 def execute_command(self, command: str, args: Optional[List[str]] = None) -> Dict[str, Any]:
4014 """
4015 Execute a system command with transparency.
4016
4017 Args:
4018 command: Command to execute
4019 args: Command arguments
4020
4021 Returns:
4022 dict: Command result
4023 """
4024 try:
4025 # Show what we're doing
4026 cmd_str = f"{command} {' '.join(args) if args else ''}"
4027 self._print_status(f"Executing command: {cmd_str}")
4028
4029 # Execute the command
4030 from ollamatools.tools.system_tools import execute_command
4031 result = execute_command(
4032 command=command,
4033 args=args,
4034 permission_manager=self.agent.permission_manager,
4035 custom_allowlist=self.command_allowlist
4036 )
4037
4038 # Check for errors
4039 if "error" in result:
4040 self._print_error(f"Command error: {result['error']}")
4041
4042 # Register in execution history
4043 self.execution_history.append({
4044 "function": "execute_command",
4045 "args": {"command": command, "args": args},
4046 "timestamp": time.time(),
4047 "success": False,
4048 "error": result["error"]
4049 })
4050
4051 self._suggest_fix(self._generate_command_fix_suggestion(result))
4052 return result
4053
4054 # Show success
4055 self._print_success(f"Command executed with exit code: {result['returncode']}")
4056
4057 # Format the output for better readability
4058 if result.get('stdout'):
4059 output_lines = result['stdout'].strip().split('\n')
4060 if len(output_lines) > 10:
4061 # Truncate long output
4062 preview = '\n'.join(output_lines[:10])
4063 self._print_info(f"Command output (first 10 lines):\n{preview}\n[...more output truncated...]")
4064 else:
4065 self._print_info(f"Command output:\n{result['stdout'].strip()}")
4066
4067 # Show errors if any
4068 if result.get('stderr'):
4069 self._print_warning(f"Command stderr:\n{result['stderr'].strip()}")
4070
4071 # Register in execution history
4072 self.execution_history.append({
4073 "function": "execute_command",
4074 "args": {"command": command, "args": args},
4075 "timestamp": time.time(),
4076 "success": True,
4077 "returncode": result["returncode"]
4078 })
4079
4080 return result
4081
4082 except Exception as e:
4083 self._print_error(f"Command execution failed: {str(e)}")
4084
4085 # Register in execution history
4086 self.execution_history.append({
4087 "function": "execute_command",
4088 "args": {"command": command, "args": args},
4089 "timestamp": time.time(),
4090 "success": False,
4091 "error": str(e)
4092 })
4093
4094 self._suggest_fix(self._generate_error_fix_suggestion(e))
4095 raise
4096
4097 def get_directory_structure(self, path: str = '.', max_depth: int = 2) -> Dict[str, Any]:
4098 """
4099 Get a formatted directory structure.
4100
4101 Args:
4102 path: Starting directory path
4103 max_depth: Maximum directory depth to show
4104
4105 Returns:
4106 dict: Directory structure information
4107 """
4108 try:
4109 self._print_status(f"Getting directory structure for: {path} (depth: {max_depth})")
4110
4111 # Execute find command to get the structure
4112 result = self.execute_command(
4113 command="find",
4114 args=[path, "-maxdepth", str(max_depth), "-type", "f", "-o", "-type", "d"]
4115 )
4116
4117 if "error" in result:
4118 return result
4119
4120 # Process the output into a more structured format
4121 lines = result.get("stdout", "").strip().split('\n')
4122
4123 structure = {
4124 "path": path,
4125 "depth": max_depth,
4126 "entries": lines,
4127 "files": [line for line in lines if os.path.isfile(line)],
4128 "directories": [line for line in lines if os.path.isdir(line)],
4129 "count": {
4130 "total": len(lines),
4131 "files": len([line for line in lines if os.path.isfile(line)]),
4132 "directories": len([line for line in lines if os.path.isdir(line)])
4133 }
4134 }
4135
4136 self._print_success(f"Found {structure['count']['total']} entries "
4137 f"({structure['count']['files']} files, "
4138 f"{structure['count']['directories']} directories)")
4139
4140 return structure
4141
4142 except Exception as e:
4143 self._print_error(f"Failed to get directory structure: {str(e)}")
4144 self._suggest_fix(self._generate_error_fix_suggestion(e))
4145
4146 return {
4147 "error": str(e),
4148 "path": path,
4149 "depth": max_depth
4150 }
4151
4152 def show_execution_history(self, max_entries: int = 10) -> None:
4153 """
4154 Display the execution history.
4155
4156 Args:
4157 max_entries: Maximum number of history entries to show
4158 """
4159 if not self.execution_history:
4160 print("\n📜 Execution history is empty.")
4161 return
4162
4163 # Get the most recent entries
4164 recent_entries = self.execution_history[-max_entries:]
4165
4166 print("\n📜 Execution History:")
4167 print("=" * 60)
4168
4169 for i, entry in enumerate(recent_entries, 1):
4170 # Format timestamp
4171 timestamp = time.strftime("%H:%M:%S", time.localtime(entry["timestamp"]))
4172
4173 # Format function and args
4174 function_name = entry["function"]
4175 args_str = ", ".join(f"{k}={v!r}" for k, v in entry["args"].items())
4176
4177 # Format status
4178 status = "✅ Success" if entry.get("success", False) else "❌ Failed"
4179
4180 # Print the entry
4181 print(f"{i}. [{timestamp}] {function_name}({args_str}) -> {status}")
4182
4183 # Show additional details for errors
4184 if not entry.get("success", False) and "error" in entry:
4185 print(f" Error: {entry['error']}")
4186
4187 # Show additional details for commands
4188 if function_name == "execute_command" and "returncode" in entry:
4189 print(f" Exit code: {entry['returncode']}")
4190
4191 # Show additional details for search
4192 if function_name == "opensearch_query" and "num_results" in entry:
4193 print(f" Results: {entry['num_results']}")
4194
4195 print("=" * 60)
4196
4197 def _generate_error_fix_suggestion(self, error: Exception) -> str:
4198 """
4199 Generate a fix suggestion based on the error type.
4200
4201 Args:
4202 error: The exception
4203
4204 Returns:
4205 str: Suggested fix
4206 """
4207 if isinstance(error, CommandError):
4208 return self._generate_command_fix_suggestion(error)
4209 elif isinstance(error, FileError):
4210 return self._generate_file_fix_suggestion(error)
4211 elif isinstance(error, WebError):
4212 return self._generate_web_fix_suggestion(error)
4213 else:
4214 # Generic suggestion
4215 return f"Try a different approach or check if the required resources are available."
4216
4217 def _generate_command_fix_suggestion(self, error: Union[CommandError, Dict[str, Any]]) -> str:
4218 """
4219 Generate a fix suggestion for command errors.
4220
4221 Args:
4222 error: Error object or dictionary
4223
4224 Returns:
4225 str: Suggested fix
4226 """
4227 # Extract error details
4228 if isinstance(error, CommandError):
4229 error_msg = str(error)
4230 details = error.details or {}
4231 command = details.get('command', '')
4232 stderr = details.get('stderr', '')
4233 else:
4234 error_msg = error.get('error', '')
4235 command = error.get('command', '')
4236 stderr = error.get('stderr', '')
4237
4238 # Check for common error patterns
4239 if "not in allowed command list" in error_msg:
4240 return (f"The command '{command}' is not in the allowed command list. "
4241 f"Try using one of these commands instead: {', '.join(sorted(list(self.command_allowlist)[:10]))}... "
4242 f"or add this command to the allowlist when initializing the agent.")
4243
4244 elif "dangerous pattern" in error_msg:
4245 pattern = error_msg.split("pattern matching '")[1].split("'")[0] if "pattern matching '" in error_msg else ""
4246 return (f"The command contains potentially dangerous pattern: {pattern}. "
4247 f"Try reformulating the command to avoid shell metacharacters.")
4248
4249 elif "not found" in error_msg or "command not found" in stderr:
4250 return f"The command '{command}' does not exist on this system. Check the spelling or install the command."
4251
4252 elif "permission denied" in stderr.lower():
4253 return f"Permission denied when executing '{command}'. Try running with appropriate permissions."
4254
4255 else:
4256 return f"Try a different command syntax or check command documentation."
4257
4258 def _generate_file_fix_suggestion(self, error: FileError) -> str:
4259 """
4260 Generate a fix suggestion for file errors.
4261
4262 Args:
4263 error: File error
4264
4265 Returns:
4266 str: Suggested fix
4267 """
4268 error_msg = str(error)
4269 details = error.details or {}
4270 path = details.get('path', '')
4271
4272 if "not found" in error_msg:
4273 return f"The file '{path}' does not exist. Check the path or create the file."
4274
4275 elif "is a directory" in error_msg:
4276 return f"'{path}' is a directory, not a file. Try specifying a file path instead."
4277
4278 elif "permission denied" in error_msg.lower():
4279 return f"Permission denied when accessing '{path}'. Check file permissions."
4280
4281 elif "exceeds maximum size" in error_msg:
4282 return f"The file '{path}' is too large. Try reading a smaller file or increasing max_size."
4283
4284 elif "outside the allowed directories" in error_msg:
4285 return f"The path '{path}' is outside the allowed directories. Try a path within the sandbox."
4286
4287 else:
4288 return f"Check if the file exists and you have appropriate permissions."
4289
4290 def _generate_web_fix_suggestion(self, error: WebError) -> str:
4291 """
4292 Generate a fix suggestion for web errors.
4293
4294 Args:
4295 error: Web error
4296
4297 Returns:
4298 str: Suggested fix
4299 """
4300 error_msg = str(error)
4301 details = error.details or {}
4302 url = details.get('url', '')
4303
4304 if "domain" in error_msg and "blocked" in error_msg:
4305 return f"The domain in '{url}' is blocked for security reasons. Try a different domain."
4306
4307 elif "scheme" in error_msg and "not allowed" in error_msg:
4308 return f"The URL scheme is not allowed. Use http:// or https:// instead."
4309
4310 elif "Connection refused" in error_msg:
4311 return f"Could not connect to '{url}'. Check if the service is running and accessible."
4312
4313 elif "timeout" in error_msg.lower():
4314 return f"The request to '{url}' timed out. Try again later or check your internet connection."
4315
4316 elif "Status code:" in error_msg and "404" in error_msg:
4317 return f"The resource at '{url}' was not found (404). Check the URL for typos."
4318
4319 elif "Status code:" in error_msg and "403" in error_msg:
4320 return f"Access to '{url}' is forbidden (403). The site may require authentication."
4321
4322 else:
4323 return f"Try a different URL or check your internet connection."
4324
4325 def _print_status(self, message: str) -> None:
4326 """Print a status message."""
4327 print(f"\n🔄 {message}")
4328
4329 def _print_success(self, message: str) -> None:
4330 """Print a success message."""
4331 print(f"✅ {message}")
4332
4333 def _print_error(self, message: str) -> None:
4334 """Print an error message."""
4335 print(f"❌ {message}")
4336
4337 def _print_warning(self, message: str) -> None:
4338 """Print a warning message."""
4339 print(f"⚠️ {message}")
4340
4341 def _print_info(self, message: str) -> None:
4342 """Print an information message."""
4343 print(f"ℹ️ {message}")
4344
4345 def _suggest_fix(self, suggestion: str) -> None:
4346 """Print a fix suggestion."""
4347 print(f"💡 Suggested fix: {suggestion}")
4348
4349
4350def main():
4351 """Run the transparent Ollama agent."""
32594352 try:
3260 headers = {"X-API-Key": "demo-key", "User-Agent": "OllamaTools/0.1.0"}
3261 result = fetch_url(
3262 "https://httpbin.org/headers",
3263 headers=headers,
3264 permission_manager=permission_manager
4353 # Initialize the agent
4354 logger.info("Initializing TransparentOllamaAgent...")
4355 agent = TransparentOllamaAgent(
4356 model="llama3.1:latest", # Adjust according to your available models
4357 permission_prompts=True
32654358 )
3266 print(f"Status: {result['status']}")
3267 print(f"Response content type: {result['headers'].get('Content-Type', 'Unknown')}")
3268 print(f"Response snippet: {json.dumps(result['content'], indent=2)[:200]}...")
4359
4360 logger.info("Agent initialized. Enter 'exit' to quit.")
4361 print("\nTransparent Ollama Agent")
4362 print("========================")
4363 print("Type 'exit' to quit")
4364 print("Type 'commands' to see available commands")
4365 print("Type 'search <query>' to search directly")
4366 print("Type 'run <command>' to execute a command directly")
4367 print("Type 'dir' to show current directory structure")
4368 print("Type 'history' to see execution history")
4369 print("\nOtherwise, just chat naturally with the agent!")
4370
4371 # Simple chat loop
4372 while True:
4373 # Get user input
4374 user_input = input("\nYou: ").strip()
4375
4376 # Check for exit command
4377 if user_input.lower() in ['exit', 'quit', 'bye']:
4378 print("\nExiting. Goodbye!")
4379 break
4380
4381 # Check for special commands
4382 if user_input.lower() == 'commands':
4383 print("\nAvailable Commands:")
4384 print("=" * 60)
4385 print("General Commands:")
4386 print(" exit, quit, bye - Exit the program")
4387 print(" commands - Show this help")
4388 print(" history - Show execution history")
4389 print("\nDirect Operations:")
4390 print(" search <query> - Search directly using OpenSearchNG")
4391 print(" run <command> - Execute a command directly")
4392 print(" dir [path] [depth] - Show directory structure")
4393 print("=" * 60)
4394 continue
4395
4396 if user_input.lower() == 'history':
4397 agent.show_execution_history()
4398 continue
4399
4400 if user_input.lower().startswith('search '):
4401 query = user_input[7:].strip()
4402 if query:
4403 try:
4404 results = agent.execute_search(query)
4405 # Show the top 3 results
4406 if results.get("results"):
4407 print("\nTop Results:")
4408 for i, result in enumerate(results["results"][:3], 1):
4409 print(f"{i}. {result['title']}")
4410 print(f" URL: {result['url']}")
4411 print(f" {result['content'][:100]}...")
4412 print()
4413 except Exception as e:
4414 print(f"Search failed: {str(e)}")
4415 else:
4416 print("Please provide a search query.")
4417 continue
4418
4419 if user_input.lower().startswith('run '):
4420 cmd = user_input[4:].strip()
4421 if cmd:
4422 try:
4423 # Split the command into parts
4424 parts = cmd.split()
4425 if parts:
4426 main_cmd = parts[0]
4427 args = parts[1:] if len(parts) > 1 else []
4428 result = agent.execute_command(main_cmd, args)
4429 if "stdout" in result:
4430 print("\nCommand Output:")
4431 print(result["stdout"])
4432 else:
4433 print("Please provide a command to run.")
4434 except Exception as e:
4435 print(f"Command execution failed: {str(e)}")
4436 continue
4437
4438 if user_input.lower().startswith('dir'):
4439 parts = user_input.split()
4440 path = parts[1] if len(parts) > 1 else '.'
4441 depth = int(parts[2]) if len(parts) > 2 and parts[2].isdigit() else 2
4442
4443 try:
4444 agent.get_directory_structure(path, depth)
4445 except Exception as e:
4446 print(f"Failed to get directory structure: {str(e)}")
4447 continue
4448
4449 # Process as normal chat message
4450 response = agent.chat(user_input)
4451 print(f"\nAssistant: {response}")
4452
4453 except KeyboardInterrupt:
4454 print("\n\nExiting due to keyboard interrupt.")
32694455 except Exception as e:
3270 print(f"Error: {e}")
4456 logger.error(f"Unexpected error in main function: {str(e)}")
4457 print(f"\nUnexpected error: {str(e)}")
4458 print("Check the logs for details.")
4459
4460 return 0
32714461
32724462if __name__ == "__main__":
3273 main()
4463 sys.exit(main())
32744464</file>
32754465
3276<file path="examples/web_tools_examples.py">
4466<file path="examples/web_requests.py">
32774467"""
3278Examples demonstrating the use of advanced web tools in OllamaTools.
4468Example demonstrating the use of web tools in OllamaTools.
32794469
3280This script shows how to use the get_weather and search_web functions.
4470This script shows how to make web requests using the fetch_url function.
32814471"""
32824472
3283from ollamatools.tools.web_tools import fetch_url, get_weather, search_web
4473from ollamatools.tools.web_tools import fetch_url
32844474from ollamatools.security import PermissionManager
32854475import json
32864476
32874477def main():
32884478 """Run the example."""
3289 print("OllamaTools Advanced Web Tools Example")
3290 print("=====================================")
4479 print("OllamaTools Web Request Example")
4480 print("===============================")
32914481
32924482 # Create a permission manager
32934483 permission_manager = PermissionManager(prompt_enabled=True)
32944484
3295 # Example 1: Get Weather
3296 print("\n1. Weather Information")
3297 print("---------------------")
4485 print("\n1. Simple GET request")
4486 try:
4487 result = fetch_url(
4488 "https://httpbin.org/get",
4489 permission_manager=permission_manager
4490 )
4491 print(f"Status: {result['status']}")
4492 print(f"Response content type: {result['headers'].get('Content-Type', 'Unknown')}")
4493 print(f"Response snippet: {json.dumps(result['content'], indent=2)[:200]}...")
4494 except Exception as e:
4495 print(f"Error: {e}")
32984496
4497 print("\n2. POST request with data")
32994498 try:
3300 # Replace 'YOUR_API_KEY' with an actual OpenWeatherMap API key
3301 api_key = "YOUR_API_KEY"
3302
3303 # Skip this example if no API key is provided
3304 if api_key == "YOUR_API_KEY":
3305 print("Skipping weather example - Please add your OpenWeatherMap API key to use this example.")
3306 else:
3307 print("Getting weather for London...")
3308 weather_info = get_weather(
3309 "London",
3310 days=3,
3311 units="metric",
3312 api_key=api_key,
3313 permission_manager=permission_manager
3314 )
3315
4499 post_data = {"name": "OllamaTools", "version": "0.1.0"}
4500 result = fetch_url(
4501 "https://httpbin.org/post",
4502 method="POST",
4503 data=post_data,
4504 permission_manager=permission_manager
4505 )
4506 print(f"Status: {result['status']}")
4507 print(f"Response content type: {result['headers'].get('Content-Type', 'Unknown')}")
4508 print(f"Response snippet: {json.dumps(result['content'], indent=2)[:200]}...")
4509 except Exception as e:
4510 print(f"Error: {e}")
4511
4512 print("\n3. GET request with parameters")
4513 try:
4514 params = {"query": "ollama", "limit": "5"}
4515 result = fetch_url(
4516 "https://httpbin.org/get",
4517 params=params,
4518 permission_manager=permission_manager
4519 )
4520 print(f"Status: {result['status']}")
4521 print(f"Response content type: {result['headers'].get('Content-Type', 'Unknown')}")
4522 print(f"Response snippet: {json.dumps(result['content'], indent=2)[:200]}...")
4523 except Exception as e:
4524 print(f"Error: {e}")
4525
4526 print("\n4. Request with custom headers")
4527 try:
4528 headers = {"X-API-Key": "demo-key", "User-Agent": "OllamaTools/0.1.0"}
4529 result = fetch_url(
4530 "https://httpbin.org/headers",
4531 headers=headers,
4532 permission_manager=permission_manager
4533 )
4534 print(f"Status: {result['status']}")
4535 print(f"Response content type: {result['headers'].get('Content-Type', 'Unknown')}")
4536 print(f"Response snippet: {json.dumps(result['content'], indent=2)[:200]}...")
4537 except Exception as e:
4538 print(f"Error: {e}")
4539
4540if __name__ == "__main__":
4541 main()
4542</file>
4543
4544<file path="examples/web_tools_examples.py">
4545"""
4546Examples demonstrating the use of advanced web tools in OllamaTools.
4547
4548This script shows how to use the get_weather and search_web functions.
4549"""
4550
4551from ollamatools.tools.web_tools import fetch_url, get_weather, search_web
4552from ollamatools.security import PermissionManager
4553import json
4554
4555def main():
4556 """Run the example."""
4557 print("OllamaTools Advanced Web Tools Example")
4558 print("=====================================")
4559
4560 # Create a permission manager
4561 permission_manager = PermissionManager(prompt_enabled=True)
4562
4563 # Example 1: Get Weather
4564 print("\n1. Weather Information")
4565 print("---------------------")
4566
4567 try:
4568 # Replace 'YOUR_API_KEY' with an actual OpenWeatherMap API key
4569 api_key = "YOUR_API_KEY"
4570
4571 # Skip this example if no API key is provided
4572 if api_key == "YOUR_API_KEY":
4573 print("Skipping weather example - Please add your OpenWeatherMap API key to use this example.")
4574 else:
4575 print("Getting weather for London...")
4576 weather_info = get_weather(
4577 "London",
4578 days=3,
4579 units="metric",
4580 api_key=api_key,
4581 permission_manager=permission_manager
4582 )
4583
33164584 # Display current weather
33174585 print(f"Current weather in {weather_info['location']['name']}, {weather_info['location']['country']}:")
33184586 print(f" Temperature: {weather_info['current']['temperature']}°C")
……@@ -3716,7 +4984,24 @@ import glob
37164984import subprocess
37174985import logging
37184986import shlex
3719from typing import List, Optional, Dict, Any, Union
4987from typing import List, Optional, Dict, Any, Union, Set
4988
4989# Import the command validation module
4990from ollamatools.command_validator import (
4991 validate_and_sanitize_command,
4992 CommandValidationError,
4993 get_secure_env,
4994 is_sudo_command
4995)
4996
4997# Import the path validation module
4998from ollamatools.path_validator import (
4999 validate_path_for_reading,
5000 validate_path_for_writing,
5001 validate_path_for_searching,
5002 PathValidationError,
5003 configure_path_sandbox
5004)
37205005
37215006# Set up logging
37225007logger = logging.getLogger(__name__)
……@@ -3725,7 +5010,8 @@ def execute_command(
37255010 command: str,
37265011 args: Optional[List[str]] = None,
37275012 timeout: int = 60,
3728 permission_manager = None
5013 permission_manager = None,
5014 custom_allowlist: Optional[set] = None
37295015) -> Dict[str, Any]:
37305016 """
37315017 Execute a shell command with arguments.
……@@ -3735,6 +5021,7 @@ def execute_command(
37355021 args: Command arguments (used only if command is a simple command)
37365022 timeout: Command timeout in seconds
37375023 permission_manager: Permission manager instance
5024 custom_allowlist: Optional custom allowlist for commands
37385025
37395026 Returns:
37405027 dict: Result with stdout, stderr, and returncode
……@@ -3742,10 +5029,14 @@ def execute_command(
37425029 # Handle case where command contains both command and arguments as a string
37435030 if isinstance(command, str) and ' ' in command and args is None:
37445031 # Split the command into parts using shlex to handle quotes properly
3745 command_parts = shlex.split(command)
3746 command = command_parts[0]
3747 args = command_parts[1:] if len(command_parts) > 1 else []
3748 logger.info(f"Split command '{command}' with args {args}")
5032 try:
5033 command_parts = shlex.split(command)
5034 command = command_parts[0]
5035 args = command_parts[1:] if len(command_parts) > 1 else []
5036 logger.info(f"Split command '{command}' with args {args}")
5037 except ValueError as e:
5038 logger.error(f"Invalid command syntax: {str(e)}")
5039 return {"error": f"Invalid command syntax: {str(e)}"}
37495040
37505041 # Default args to empty list if None
37515042 if args is None:
……@@ -3754,13 +5045,9 @@ def execute_command(
37545045 # Convert args to strings if they aren't already
37555046 args = [str(arg) for arg in args]
37565047
3757 # Sanitize the command and arguments
3758 command = str(command).strip()
3759
3760 # Check for sudo
3761 is_sudo = False
3762 if command == "sudo" or command.startswith("sudo "):
3763 is_sudo = True
5048 # Detect sudo before validation to ensure we prompt correctly
5049 is_sudo = is_sudo_command(command, args)
5050 if is_sudo:
37645051 logger.warning(f"Sudo command detected: {command} {' '.join(args)}")
37655052
37665053 # Check permission if permission manager is provided
……@@ -3776,18 +5063,27 @@ def execute_command(
37765063 logger.warning(f"Permission denied for command: {command} {' '.join(args)}")
37775064 return {"error": "Permission denied"}
37785065
3779 # Construct the full command
3780 full_command = [command] + args
3781 logger.info(f"Executing command: {' '.join(full_command)}")
3782
37835066 try:
5067 # Validate and sanitize the command and arguments
5068 validated_command, validated_args = validate_and_sanitize_command(
5069 command, args, custom_allowlist
5070 )
5071
5072 # Construct the full command
5073 full_command = [validated_command] + validated_args
5074 logger.info(f"Executing validated command: {' '.join(full_command)}")
5075
5076 # Get secure environment variables
5077 secure_env = get_secure_env()
5078
37845079 # Execute the command
37855080 result = subprocess.run(
37865081 full_command,
37875082 capture_output=True,
37885083 check=False,
37895084 text=True,
3790 timeout=timeout
5085 timeout=timeout,
5086 env=secure_env
37915087 )
37925088
37935089 # Return the result
……@@ -3797,23 +5093,31 @@ def execute_command(
37975093 "returncode": result.returncode,
37985094 "command": ' '.join(full_command) # Include the executed command for reference
37995095 }
5096 except CommandValidationError as e:
5097 logger.error(f"Command validation error: {str(e)}")
5098 return {
5099 "error": f"Command validation error: {str(e)}",
5100 "command": f"{command} {' '.join(args) if args else ''}"
5101 }
38005102 except subprocess.TimeoutExpired:
3801 logger.error(f"Command timed out after {timeout} seconds: {' '.join(full_command)}")
5103 logger.error(f"Command timed out after {timeout} seconds: {command} {' '.join(args)}")
38025104 return {
38035105 "error": f"Command timed out after {timeout} seconds",
3804 "command": ' '.join(full_command)
5106 "command": f"{command} {' '.join(args) if args else ''}"
38055107 }
38065108 except Exception as e:
38075109 logger.error(f"Error executing command: {str(e)}")
38085110 return {
38095111 "error": str(e),
3810 "command": ' '.join(full_command)
5112 "command": f"{command} {' '.join(args) if args else ''}"
38115113 }
38125114
38135115def read_file(
38145116 path: str,
38155117 max_size: Optional[int] = None,
3816 permission_manager = None
5118 permission_manager = None,
5119 sandbox_path: Optional[str] = None,
5120 additional_allowed_paths: Optional[Set[str]] = None
38175121) -> str:
38185122 """
38195123 Read file contents.
……@@ -3822,13 +5126,12 @@ def read_file(
38225126 path: File path
38235127 max_size: Maximum file size to read in bytes
38245128 permission_manager: Permission manager instance
5129 sandbox_path: Base path that defines the sandbox
5130 additional_allowed_paths: Additional paths to allow
38255131
38265132 Returns:
38275133 str: File contents
38285134 """
3829 # Sanitize the path
3830 path = os.path.abspath(path)
3831
38325135 # Check permission if permission manager is provided
38335136 if permission_manager:
38345137 function_args = {"path": path, "max_size": max_size}
……@@ -3842,23 +5145,28 @@ def read_file(
38425145 return "Permission denied"
38435146
38445147 try:
3845 # Check if the file exists
3846 if not os.path.isfile(path):
3847 logger.error(f"File not found: {path}")
3848 return f"File not found: {path}"
5148 # Validate the path for reading
5149 validated_path = validate_path_for_reading(
5150 path,
5151 sandbox_path=sandbox_path,
5152 additional_allowed_paths=additional_allowed_paths
5153 )
38495154
38505155 # Check the file size if max_size is specified
38515156 if max_size is not None:
3852 file_size = os.path.getsize(path)
5157 file_size = os.path.getsize(validated_path)
38535158 if file_size > max_size:
3854 logger.error(f"File size ({file_size} bytes) exceeds maximum size ({max_size} bytes): {path}")
5159 logger.error(f"File size ({file_size} bytes) exceeds maximum size ({max_size} bytes): {validated_path}")
38555160 return f"File size ({file_size} bytes) exceeds maximum size ({max_size} bytes)"
38565161
38575162 # Read the file
3858 with open(path, "r") as f:
5163 with open(validated_path, "r") as f:
38595164 content = f.read()
38605165
38615166 return content
5167 except PathValidationError as e:
5168 logger.error(f"Path validation error: {str(e)}")
5169 return f"Path validation error: {str(e)}"
38625170 except Exception as e:
38635171 logger.error(f"Error reading file: {str(e)}")
38645172 return f"Error reading file: {str(e)}"
……@@ -3867,7 +5175,10 @@ def write_file(
38675175 path: str,
38685176 content: str,
38695177 mode: str = 'w',
3870 permission_manager = None
5178 permission_manager = None,
5179 sandbox_path: Optional[str] = None,
5180 additional_allowed_paths: Optional[Set[str]] = None,
5181 additional_protected_paths: Optional[Set[str]] = None
38715182) -> bool:
38725183 """
38735184 Write content to a file.
……@@ -3877,12 +5188,14 @@ def write_file(
38775188 content: Content to write
38785189 mode: Write mode ('w' for write, 'a' for append)
38795190 permission_manager: Permission manager instance
5191 sandbox_path: Base path that defines the sandbox
5192 additional_allowed_paths: Additional paths to allow
5193 additional_protected_paths: Additional paths to protect
38805194
38815195 Returns:
38825196 bool: True if successful
38835197 """
3884 # Sanitize the path and mode
3885 path = os.path.abspath(path)
5198 # Validate the mode
38865199 if mode not in ['w', 'a']:
38875200 logger.error(f"Invalid mode: {mode}")
38885201 return False
……@@ -3900,14 +5213,25 @@ def write_file(
39005213 return False
39015214
39025215 try:
5216 # Validate the path for writing
5217 validated_path = validate_path_for_writing(
5218 path,
5219 sandbox_path=sandbox_path,
5220 additional_allowed_paths=additional_allowed_paths,
5221 additional_protected_paths=additional_protected_paths
5222 )
5223
39035224 # Create the directory if it doesn't exist
3904 os.makedirs(os.path.dirname(path), exist_ok=True)
5225 os.makedirs(os.path.dirname(validated_path), exist_ok=True)
39055226
39065227 # Write to the file
3907 with open(path, mode) as f:
5228 with open(validated_path, mode) as f:
39085229 f.write(content)
39095230
39105231 return True
5232 except PathValidationError as e:
5233 logger.error(f"Path validation error: {str(e)}")
5234 return False
39115235 except Exception as e:
39125236 logger.error(f"Error writing to file: {str(e)}")
39135237 return False
……@@ -3916,7 +5240,9 @@ def find_files(
39165240 pattern: str,
39175241 directory: str = '.',
39185242 recursive: bool = True,
3919 permission_manager = None
5243 permission_manager = None,
5244 sandbox_path: Optional[str] = None,
5245 additional_allowed_paths: Optional[Set[str]] = None
39205246) -> List[str]:
39215247 """
39225248 Find files matching a pattern.
……@@ -3926,13 +5252,12 @@ def find_files(
39265252 directory: Search directory
39275253 recursive: Whether to search recursively
39285254 permission_manager: Permission manager instance
5255 sandbox_path: Base path that defines the sandbox
5256 additional_allowed_paths: Additional paths to allow
39295257
39305258 Returns:
39315259 List[str]: Matching file paths
39325260 """
3933 # Sanitize the directory
3934 directory = os.path.abspath(directory)
3935
39365261 # Check permission if permission manager is provided
39375262 if permission_manager:
39385263 function_args = {"pattern": pattern, "directory": directory, "recursive": recursive}
……@@ -3946,16 +5271,41 @@ def find_files(
39465271 return []
39475272
39485273 try:
5274 # Validate the directory and pattern for searching
5275 validated_dir, validated_pattern = validate_path_for_searching(
5276 directory,
5277 pattern,
5278 sandbox_path=sandbox_path,
5279 additional_allowed_paths=additional_allowed_paths
5280 )
5281
39495282 # Construct the search pattern
3950 search_pattern = os.path.join(directory, '**', pattern) if recursive else os.path.join(directory, pattern)
5283 search_pattern = os.path.join(validated_dir, '**', validated_pattern) if recursive else os.path.join(validated_dir, validated_pattern)
39515284
39525285 # Find files
39535286 files = glob.glob(search_pattern, recursive=recursive)
39545287
3955 # Convert to absolute paths
3956 files = [os.path.abspath(file) for file in files]
5288 # Additional validation of found files (check if they're still in the sandbox)
5289 validated_files = []
5290 for file_path in files:
5291 try:
5292 # Only include files that are within the sandbox
5293 abs_path = os.path.abspath(file_path)
5294 if os.path.exists(abs_path) and (
5295 is_path_in_sandbox(
5296 abs_path,
5297 sandbox_path=sandbox_path,
5298 additional_allowed_paths=additional_allowed_paths
5299 )
5300 ):
5301 validated_files.append(abs_path)
5302 except Exception as e:
5303 logger.warning(f"Skipping file {file_path}: {str(e)}")
39575304
3958 return files
5305 return validated_files
5306 except PathValidationError as e:
5307 logger.error(f"Path validation error: {str(e)}")
5308 return []
39595309 except Exception as e:
39605310 logger.error(f"Error finding files: {str(e)}")
39615311 return []
……@@ -3970,12 +5320,20 @@ including weather information fetching and web search functionality.
39705320"""
39715321
39725322import requests
3973from typing import Dict, Any, Optional, Union, List
5323from typing import Dict, Any, Optional, Union, List, Set
39745324import logging
39755325import json
39765326from urllib.parse import urlparse, quote
39775327from bs4 import BeautifulSoup
39785328
5329# Import the URL validation module
5330from ollamatools.url_validator import (
5331 validate_url,
5332 URLValidationError,
5333 configure_domain_lists,
5334 create_url_validator
5335)
5336
39795337# Get logger
39805338logger = logging.getLogger(__name__)
39815339
……@@ -3986,7 +5344,10 @@ def fetch_url(
39865344 headers: Optional[Dict[str, str]] = None,
39875345 data: Optional[Dict[str, Any]] = None,
39885346 timeout: int = 30,
3989 permission_manager = None
5347 permission_manager = None,
5348 allowed_schemes: Optional[Set[str]] = None,
5349 domain_allowlist: Optional[Set[str]] = None,
5350 domain_blocklist: Optional[Set[str]] = None
39905351) -> Dict[str, Any]:
39915352 """
39925353 Fetch a URL with specified parameters.
……@@ -3999,6 +5360,9 @@ def fetch_url(
39995360 data: Request data
40005361 timeout: Request timeout in seconds
40015362 permission_manager: Optional permission manager to request permission
5363 allowed_schemes: Optional set of allowed URL schemes
5364 domain_allowlist: Optional set of allowed domains
5365 domain_blocklist: Optional set of blocked domains
40025366
40035367 Returns:
40045368 dict: Response with status, headers, and content
……@@ -4008,21 +5372,6 @@ def fetch_url(
40085372 requests.RequestException: If the request fails
40095373 PermissionError: If permission is denied
40105374 """
4011 # Validate URL
4012 if not url:
4013 raise ValueError("URL cannot be empty")
4014
4015 # Basic URL validation
4016 parsed_url = urlparse(url)
4017 if not parsed_url.scheme or not parsed_url.netloc:
4018 raise ValueError(f"Invalid URL: {url}")
4019
4020 # Validate method
4021 valid_methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']
4022 method = method.upper()
4023 if method not in valid_methods:
4024 raise ValueError(f"Invalid HTTP method: {method}. Must be one of {valid_methods}")
4025
40265375 # Request permission if permission manager is provided
40275376 if permission_manager:
40285377 args = {
……@@ -4037,40 +5386,73 @@ def fetch_url(
40375386 logger.warning(f"Permission denied for web request to {url}")
40385387 raise PermissionError(f"Permission denied for web request to {url}")
40395388
4040 logger.info(f"Making {method} request to {url}")
4041
5389 # Validate and normalize the URL
40425390 try:
4043 response = requests.request(
4044 method=method,
4045 url=url,
4046 params=params,
4047 headers=headers,
4048 json=data if method in ['POST', 'PUT', 'PATCH'] else None,
4049 timeout=timeout
5391 validated_url = validate_url(
5392 url,
5393 allowed_schemes=allowed_schemes,
5394 domain_allowlist=domain_allowlist,
5395 domain_blocklist=domain_blocklist
40505396 )
4051
4052 # Try to parse response as JSON, fall back to text
4053 content_type = response.headers.get('Content-Type', '')
4054 if 'application/json' in content_type:
4055 try:
4056 content = response.json()
4057 except ValueError:
5397 logger.debug(f"URL validated and normalized: {validated_url}")
5398 except URLValidationError as e:
5399 logger.error(f"URL validation error: {str(e)}")
5400 raise ValueError(f"URL validation failed: {str(e)}")
5401
5402 # Validate method
5403 valid_methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']
5404 method = method.upper()
5405 if method not in valid_methods:
5406 raise ValueError(f"Invalid HTTP method: {method}. Must be one of {valid_methods}")
5407
5408 # Set default headers if none provided
5409 if headers is None:
5410 headers = {}
5411
5412 # Add default User-Agent if not present
5413 if 'User-Agent' not in headers:
5414 headers['User-Agent'] = 'OllamaTools/0.1.0 (https://github.com/yourusername/ollamatools)'
5415
5416 logger.info(f"Making {method} request to {validated_url}")
5417
5418 try:
5419 # Create a session to manage cookies and connection pooling
5420 with requests.Session() as session:
5421 # Configure session with reasonable security defaults
5422 session.verify = True # Verify SSL certificates
5423
5424 # Make the request
5425 response = session.request(
5426 method=method,
5427 url=validated_url,
5428 params=params,
5429 headers=headers,
5430 json=data if method in ['POST', 'PUT', 'PATCH'] else None,
5431 timeout=timeout
5432 )
5433
5434 # Try to parse response as JSON, fall back to text
5435 content_type = response.headers.get('Content-Type', '')
5436 if 'application/json' in content_type:
5437 try:
5438 content = response.json()
5439 except ValueError:
5440 content = response.text
5441 else:
40585442 content = response.text
4059 else:
4060 content = response.text
4061
4062 result = {
4063 "status": response.status_code,
4064 "headers": dict(response.headers),
4065 "content": content,
4066 "url": response.url
4067 }
4068
4069 logger.debug(f"Request to {url} completed with status {response.status_code}")
4070 return result
5443
5444 result = {
5445 "status": response.status_code,
5446 "headers": dict(response.headers),
5447 "content": content,
5448 "url": response.url
5449 }
5450
5451 logger.debug(f"Request to {validated_url} completed with status {response.status_code}")
5452 return result
40715453
40725454 except requests.RequestException as e:
4073 logger.error(f"Request to {url} failed: {str(e)}")
5455 logger.error(f"Request to {validated_url} failed: {str(e)}")
40745456 raise
40755457
40765458
……@@ -4079,7 +5461,9 @@ def get_weather(
40795461 days: int = 3,
40805462 units: str = 'metric',
40815463 api_key: Optional[str] = None,
4082 permission_manager = None
5464 permission_manager = None,
5465 domain_allowlist: Optional[Set[str]] = None,
5466 domain_blocklist: Optional[Set[str]] = None
40835467) -> Dict[str, Any]:
40845468 """
40855469 Get weather information for a location.
……@@ -4090,6 +5474,8 @@ def get_weather(
40905474 units: Temperature units ('metric' or 'imperial')
40915475 api_key: OpenWeatherMap API key (if None, uses config)
40925476 permission_manager: Optional permission manager to request permission
5477 domain_allowlist: Optional set of allowed domains
5478 domain_blocklist: Optional set of blocked domains
40935479
40945480 Returns:
40955481 dict: Weather information including current conditions and forecast
……@@ -4132,6 +5518,11 @@ def get_weather(
41325518 logger.info(f"Fetching weather for {location}")
41335519
41345520 try:
5521 # Ensure allowlist includes the weather API domain
5522 weather_domain_allowlist = {"api.openweathermap.org"}
5523 if domain_allowlist:
5524 weather_domain_allowlist.update(domain_allowlist)
5525
41355526 # Base URL for OpenWeatherMap API
41365527 base_url = "https://api.openweathermap.org/data/2.5"
41375528
……@@ -4143,9 +5534,15 @@ def get_weather(
41435534 "units": units
41445535 }
41455536
4146 current_response = requests.get(current_url, params=params, timeout=10)
4147 current_response.raise_for_status()
4148 current_data = current_response.json()
5537 current_response = fetch_url(
5538 current_url,
5539 params=params,
5540 timeout=10,
5541 permission_manager=permission_manager,
5542 domain_allowlist=weather_domain_allowlist,
5543 domain_blocklist=domain_blocklist
5544 )
5545 current_data = current_response["content"]
41495546
41505547 # Get forecast
41515548 forecast_url = f"{base_url}/forecast"
……@@ -4156,9 +5553,15 @@ def get_weather(
41565553 "cnt": days * 8 # 8 forecasts per day (every 3 hours)
41575554 }
41585555
4159 forecast_response = requests.get(forecast_url, params=params, timeout=10)
4160 forecast_response.raise_for_status()
4161 forecast_data = forecast_response.json()
5556 forecast_response = fetch_url(
5557 forecast_url,
5558 params=params,
5559 timeout=10,
5560 permission_manager=permission_manager,
5561 domain_allowlist=weather_domain_allowlist,
5562 domain_blocklist=domain_blocklist
5563 )
5564 forecast_data = forecast_response["content"]
41625565
41635566 # Format the response
41645567 weather_info = {
……@@ -4197,7 +5600,7 @@ def get_weather(
41975600
41985601 return weather_info
41995602
4200 except requests.RequestException as e:
5603 except Exception as e:
42015604 logger.error(f"Weather API request failed: {str(e)}")
42025605 raise
42035606
……@@ -4206,7 +5609,9 @@ def search_web(
42065609 query: str,
42075610 num_results: int = 5,
42085611 api_key: Optional[str] = None,
4209 permission_manager = None
5612 permission_manager = None,
5613 domain_allowlist: Optional[Set[str]] = None,
5614 domain_blocklist: Optional[Set[str]] = None
42105615) -> List[Dict[str, str]]:
42115616 """
42125617 Search the web for a query.
……@@ -4216,6 +5621,8 @@ def search_web(
42165621 num_results: Number of results to return (1-10)
42175622 api_key: Search API key (if None, uses config)
42185623 permission_manager: Optional permission manager to request permission
5624 domain_allowlist: Optional set of allowed domains
5625 domain_blocklist: Optional set of blocked domains
42195626
42205627 Returns:
42215628 List[dict]: Search results with title, URL, and snippet
……@@ -4248,11 +5655,22 @@ def search_web(
42485655 # In a real implementation, this would get the API key from the config
42495656 # For now, we'll use a fallback to a simple search method that doesn't require API key
42505657 logger.warning("No API key provided for search API, using fallback method")
4251 return _fallback_search(query, num_results)
5658 return _fallback_search(
5659 query,
5660 num_results,
5661 permission_manager=permission_manager,
5662 domain_allowlist=domain_allowlist,
5663 domain_blocklist=domain_blocklist
5664 )
42525665
42535666 logger.info(f"Searching the web for: {query}")
42545667
42555668 try:
5669 # Ensure allowlist includes the search API domain
5670 search_domain_allowlist = {"serpapi.com"}
5671 if domain_allowlist:
5672 search_domain_allowlist.update(domain_allowlist)
5673
42565674 # Example using the Serpapi (Google Search API)
42575675 # In a real implementation, you would use a proper search API
42585676 url = "https://serpapi.com/search"
……@@ -4262,10 +5680,16 @@ def search_web(
42625680 "num": num_results
42635681 }
42645682
4265 response = requests.get(url, params=params, timeout=15)
4266 response.raise_for_status()
5683 response = fetch_url(
5684 url,
5685 params=params,
5686 timeout=15,
5687 permission_manager=permission_manager,
5688 domain_allowlist=search_domain_allowlist,
5689 domain_blocklist=domain_blocklist
5690 )
42675691
4268 data = response.json()
5692 data = response["content"]
42695693 results = []
42705694
42715695 # Process search results
……@@ -4279,15 +5703,27 @@ def search_web(
42795703
42805704 return results
42815705
4282 except requests.RequestException as e:
5706 except Exception as e:
42835707 logger.error(f"Search API request failed: {str(e)}")
42845708
42855709 # Fall back to the simple search method if API request fails
42865710 logger.info("Falling back to simple search method")
4287 return _fallback_search(query, num_results)
5711 return _fallback_search(
5712 query,
5713 num_results,
5714 permission_manager=permission_manager,
5715 domain_allowlist=domain_allowlist,
5716 domain_blocklist=domain_blocklist
5717 )
42885718
42895719
4290def _fallback_search(query: str, num_results: int = 5) -> List[Dict[str, str]]:
5720def _fallback_search(
5721 query: str,
5722 num_results: int = 5,
5723 permission_manager = None,
5724 domain_allowlist: Optional[Set[str]] = None,
5725 domain_blocklist: Optional[Set[str]] = None
5726) -> List[Dict[str, str]]:
42915727 """
42925728 Fallback search method using DuckDuckGo HTML scraping when API is not available.
42935729
……@@ -4297,6 +5733,9 @@ def _fallback_search(query: str, num_results: int = 5) -> List[Dict[str, str]]:
42975733 Args:
42985734 query: Search query
42995735 num_results: Number of results to return
5736 permission_manager: Optional permission manager to request permission
5737 domain_allowlist: Optional set of allowed domains
5738 domain_blocklist: Optional set of blocked domains
43005739
43015740 Returns:
43025741 List[dict]: Search results with title, URL, and snippet
……@@ -4306,13 +5745,25 @@ def _fallback_search(query: str, num_results: int = 5) -> List[Dict[str, str]]:
43065745 url = f"https://html.duckduckgo.com/html/?q={encoded_query}"
43075746
43085747 try:
5748 # Ensure allowlist includes the search domain
5749 search_domain_allowlist = {"html.duckduckgo.com"}
5750 if domain_allowlist:
5751 search_domain_allowlist.update(domain_allowlist)
5752
43095753 headers = {
43105754 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
43115755 }
4312 response = requests.get(url, headers=headers, timeout=15)
4313 response.raise_for_status()
43145756
4315 soup = BeautifulSoup(response.text, 'html.parser')
5757 response = fetch_url(
5758 url,
5759 headers=headers,
5760 timeout=15,
5761 permission_manager=permission_manager,
5762 domain_allowlist=search_domain_allowlist,
5763 domain_blocklist=domain_blocklist
5764 )
5765
5766 soup = BeautifulSoup(response["content"], 'html.parser')
43165767
43175768 # Find search results
43185769 result_elements = soup.select('.result')
……@@ -4328,15 +5779,20 @@ def _fallback_search(query: str, num_results: int = 5) -> List[Dict[str, str]]:
43285779 link = title_element.find('a').get('href', '')
43295780 snippet = snippet_element.get_text(strip=True) if snippet_element else ""
43305781
4331 results.append({
4332 "title": title,
4333 "url": link,
4334 "snippet": snippet
4335 })
5782 # Validate URL
5783 try:
5784 validate_url(link)
5785 results.append({
5786 "title": title,
5787 "url": link,
5788 "snippet": snippet
5789 })
5790 except URLValidationError:
5791 logger.warning(f"Skipping result with invalid URL: {link}")
43365792
43375793 return results
43385794
4339 except (requests.RequestException, Exception) as e:
5795 except Exception as e:
43405796 logger.error(f"Fallback search failed: {str(e)}")
43415797 # Return empty results if even the fallback fails
43425798 return []
……@@ -4486,7 +5942,7 @@ enabling automatic invocation of system commands, web requests, and
44865942documentation utilities based on conversational context.
44875943"""
44885944
4489__version__ = "0.1.0"
5945__version__ = "0.2.0"
44905946
44915947from .config import Config
44925948from .security import PermissionManager
……@@ -4499,15 +5955,102 @@ from .utils.logger import (
44995955 get_ollamatools_logger
45005956)
45015957
5958# Import new security modules
5959from .command_validator import (
5960 validate_and_sanitize_command,
5961 CommandValidationError,
5962 get_secure_env,
5963 is_command_in_allowlist,
5964 DEFAULT_ALLOWLIST
5965)
5966
5967from .url_validator import (
5968 validate_url,
5969 URLValidationError,
5970 configure_domain_lists,
5971 DEFAULT_ALLOWED_SCHEMES,
5972 DEFAULT_DOMAIN_ALLOWLIST,
5973 DEFAULT_DOMAIN_BLOCKLIST
5974)
5975
5976from .path_validator import (
5977 validate_path_for_reading,
5978 validate_path_for_writing,
5979 validate_path_for_searching,
5980 PathValidationError,
5981 configure_path_sandbox,
5982 DEFAULT_SANDBOX_PATH,
5983 DEFAULT_ALLOWED_PATHS,
5984 DEFAULT_PROTECTED_PATHS
5985)
5986
5987# Import error handling utilities
5988from .errors import (
5989 OllamaToolsError,
5990 CommandError,
5991 FileError,
5992 WebError,
5993 ApiError,
5994 PermissionError,
5995 TimeoutError,
5996 ConfigurationError,
5997 OllamaClientError,
5998 ERROR_CODES,
5999 handle_exception,
6000 format_error_response
6001)
6002
45026003__all__ = [
6004 # Core components
45036005 'Config',
45046006 'PermissionManager',
45056007 'OllamaAgent',
6008
6009 # Logger utilities
45066010 'setup_logger',
45076011 'get_logger',
45086012 'enable_debug_logging',
45096013 'disable_logging',
4510 'get_ollamatools_logger'
6014 'get_ollamatools_logger',
6015
6016 # Command validation
6017 'validate_and_sanitize_command',
6018 'CommandValidationError',
6019 'get_secure_env',
6020 'is_command_in_allowlist',
6021 'DEFAULT_ALLOWLIST',
6022
6023 # URL validation
6024 'validate_url',
6025 'URLValidationError',
6026 'configure_domain_lists',
6027 'DEFAULT_ALLOWED_SCHEMES',
6028 'DEFAULT_DOMAIN_ALLOWLIST',
6029 'DEFAULT_DOMAIN_BLOCKLIST',
6030
6031 # Path validation
6032 'validate_path_for_reading',
6033 'validate_path_for_writing',
6034 'validate_path_for_searching',
6035 'PathValidationError',
6036 'configure_path_sandbox',
6037 'DEFAULT_SANDBOX_PATH',
6038 'DEFAULT_ALLOWED_PATHS',
6039 'DEFAULT_PROTECTED_PATHS',
6040
6041 # Error handling
6042 'OllamaToolsError',
6043 'CommandError',
6044 'FileError',
6045 'WebError',
6046 'ApiError',
6047 'PermissionError',
6048 'TimeoutError',
6049 'ConfigurationError',
6050 'OllamaClientError',
6051 'ERROR_CODES',
6052 'handle_exception',
6053 'format_error_response'
45116054]
45126055</file>
45136056
……@@ -4965,265 +6508,1807 @@ class OllamaAgent:
49656508 elif "Dict" in annotation_str or "dict" in annotation_str:
49666509 return "object"
49676510
4968 # Default to string for unknown types
4969 return "string"
6511 # Default to string for unknown types
6512 return "string"
6513</file>
6514
6515<file path="ollamatools/command_validator.py">
6516"""
6517Command validation module for OllamaTools.
6518
6519This module provides functions for validating and sanitizing shell commands
6520to prevent command injection and other security vulnerabilities.
6521"""
6522
6523import os
6524import re
6525import shlex
6526import logging
6527import pathlib
6528from typing import List, Optional, Dict, Set, Tuple, Union
6529
6530# Set up logging
6531logger = logging.getLogger(__name__)
6532
6533# Default allowlist of commands
6534DEFAULT_ALLOWLIST = {
6535 # File operations
6536 'ls', 'dir', 'find', 'grep', 'cat', 'less', 'head', 'tail',
6537 # Directory operations
6538 'pwd', 'cd', 'mkdir', 'rmdir',
6539 # System information
6540 'uname', 'hostname', 'whoami', 'id', 'ps', 'top', 'df', 'du', 'free',
6541 # Network utilities
6542 'ping', 'traceroute', 'curl', 'wget', 'netstat', 'dig', 'nslookup',
6543 # Text processing
6544 'echo', 'printf', 'sed', 'awk', 'cut', 'sort', 'uniq', 'wc',
6545 # File manipulation
6546 'cp', 'mv', 'rm', 'touch', 'chmod', 'chown', 'chgrp', 'ln',
6547 # Compression
6548 'tar', 'gzip', 'gunzip', 'zip', 'unzip',
6549 # Package management
6550 'apt-get', 'apt', 'yum', 'dnf', 'brew',
6551 # Process control
6552 'kill', 'pkill', 'pgrep',
6553 # Admin
6554 'sudo',
6555}
6556
6557# Commands that require special validation
6558HIGH_RISK_COMMANDS = {
6559 'rm', 'mv', 'cp', # File operations that can cause data loss
6560 'chmod', 'chown', 'chgrp', # Permission changes
6561 'sudo', # Privilege escalation
6562 'wget', 'curl', # Network operations
6563}
6564
6565# Paths that should be protected (not allowed to be modified)
6566PROTECTED_PATHS = {
6567 '/etc', '/bin', '/sbin', '/usr/bin', '/usr/sbin',
6568 '/boot', '/lib', '/lib64', '/usr/lib', '/usr/lib64',
6569 '/sys', '/proc', '/dev'
6570}
6571
6572# Regular expression patterns for dangerous argument patterns
6573DANGEROUS_ARG_PATTERNS = [
6574 r'[|;&`$()]', # Shell metacharacters
6575 r'>(>)?[^>]*', # Redirections
6576 r'<(<)?[^<]*', # Input redirections
6577 r'\$\([^)]*\)', # Command substitution
6578 r'`[^`]*`', # Backtick command substitution
6579 r'\$\{[^}]*\}', # Variable expansion
6580 r'\.\.', # Path traversal
6581 r'~[^/]*', # Home directory reference
6582 r'\\\\', # Double backslash
6583]
6584
6585class CommandValidationError(Exception):
6586 """Exception raised for command validation errors."""
6587 pass
6588
6589
6590def is_command_in_allowlist(command: str, custom_allowlist: Optional[Set[str]] = None) -> bool:
6591 """
6592 Check if a command is in the allowlist.
6593
6594 Args:
6595 command: Command to check
6596 custom_allowlist: Optional custom allowlist to use instead of the default
6597
6598 Returns:
6599 bool: True if command is in allowlist, False otherwise
6600 """
6601 allowlist = custom_allowlist if custom_allowlist is not None else DEFAULT_ALLOWLIST
6602
6603 # Strip path information for the check
6604 cmd_name = os.path.basename(command)
6605
6606 return cmd_name in allowlist
6607
6608
6609def validate_command_path(command: str) -> str:
6610 """
6611 Validate that a command's path is safe.
6612
6613 Args:
6614 command: Command to validate
6615
6616 Returns:
6617 str: Validated command path
6618
6619 Raises:
6620 CommandValidationError: If the command path is not valid
6621 """
6622 # Check for absolute path
6623 if os.path.isabs(command):
6624 cmd_path = pathlib.Path(command).resolve()
6625 cmd_name = os.path.basename(command)
6626
6627 # Ensure the command exists
6628 if not cmd_path.exists():
6629 raise CommandValidationError(f"Command '{command}' not found")
6630
6631 # Ensure the path is to a file not a directory
6632 if not cmd_path.is_file():
6633 raise CommandValidationError(f"Command path '{command}' is not a file")
6634
6635 # Check if command in a standard location
6636 std_paths = ['/bin', '/usr/bin', '/usr/local/bin', '/sbin', '/usr/sbin']
6637 parent_path = str(cmd_path.parent)
6638 if not any(parent_path.startswith(p) for p in std_paths):
6639 logger.warning(f"Command '{command}' is outside standard paths: {parent_path}")
6640
6641 # Additional permission check
6642 if not os.access(cmd_path, os.X_OK):
6643 raise CommandValidationError(f"Command '{command}' is not executable")
6644
6645 return str(cmd_path)
6646 else:
6647 # For non-absolute paths, we'll rely on the allowlist check
6648 return command
6649
6650
6651def validate_command_args(args: List[str], command: str) -> List[str]:
6652 """
6653 Validate command arguments for security issues.
6654
6655 Args:
6656 args: Command arguments to validate
6657 command: The command these args apply to (for context-specific checks)
6658
6659 Returns:
6660 List[str]: Validated arguments
6661
6662 Raises:
6663 CommandValidationError: If any arguments are not valid
6664 """
6665 validated_args = []
6666
6667 # Check if command is high risk
6668 is_high_risk = os.path.basename(command) in HIGH_RISK_COMMANDS
6669
6670 for arg in args:
6671 # Skip empty args
6672 if not arg:
6673 continue
6674
6675 # Check for dangerous patterns
6676 for pattern in DANGEROUS_ARG_PATTERNS:
6677 if re.search(pattern, arg):
6678 raise CommandValidationError(
6679 f"Argument '{arg}' contains dangerous pattern matching '{pattern}'"
6680 )
6681
6682 # Special checks for specific commands
6683 cmd_name = os.path.basename(command)
6684
6685 # Special checks for rm command
6686 if cmd_name == 'rm' and (arg == '-rf' or arg == '-fr' or arg == '-r' or arg == '-f'):
6687 # These arguments need special attention with protected paths
6688 if '-r' in arg or '-f' in arg:
6689 logger.warning(f"High-risk argument '{arg}' detected for '{command}'")
6690
6691 # Check file paths in arguments for protected paths
6692 if is_high_risk and arg.startswith('/'):
6693 arg_path = pathlib.Path(arg).resolve()
6694 arg_path_str = str(arg_path)
6695
6696 # Check against protected paths
6697 for protected in PROTECTED_PATHS:
6698 if arg_path_str.startswith(protected):
6699 raise CommandValidationError(
6700 f"Argument '{arg}' references protected path '{protected}'"
6701 )
6702
6703 validated_args.append(arg)
6704
6705 return validated_args
6706
6707
6708def sanitize_command(command: str) -> str:
6709 """
6710 Sanitize a command string to prevent command injection.
6711
6712 Args:
6713 command: Command string to sanitize
6714
6715 Returns:
6716 str: Sanitized command string
6717 """
6718 # Convert to string and trim
6719 if not isinstance(command, str):
6720 command = str(command)
6721
6722 sanitized = command.strip()
6723
6724 # Remove any null bytes or control characters
6725 sanitized = re.sub(r'[\x00-\x1F\x7F]', '', sanitized)
6726
6727 # Remove shell metacharacters
6728 dangerous_chars = r'[;&|`$()"\'\\]'
6729 sanitized = re.sub(dangerous_chars, '', sanitized)
6730
6731 return sanitized
6732
6733
6734def validate_and_sanitize_command(
6735 command: str,
6736 args: Optional[List[str]] = None,
6737 custom_allowlist: Optional[Set[str]] = None
6738) -> Tuple[str, List[str]]:
6739 """
6740 Validate and sanitize a command and its arguments.
6741
6742 Args:
6743 command: Command to validate and sanitize
6744 args: Optional command arguments
6745 custom_allowlist: Optional custom allowlist
6746
6747 Returns:
6748 tuple: (sanitized_command, sanitized_args)
6749
6750 Raises:
6751 CommandValidationError: If the command or arguments are not valid
6752 """
6753 # Handle None args
6754 if args is None:
6755 args = []
6756
6757 # Handle case where command contains both command and arguments as a string
6758 if isinstance(command, str) and ' ' in command and args is None:
6759 try:
6760 # Split using shlex to handle quotes properly
6761 command_parts = shlex.split(command)
6762 command = command_parts[0]
6763 args = command_parts[1:] if len(command_parts) > 1 else []
6764 logger.debug(f"Split command into cmd='{command}', args={args}")
6765 except ValueError as e:
6766 raise CommandValidationError(f"Invalid command syntax: {str(e)}")
6767
6768 # Check if command is in allowlist
6769 if not is_command_in_allowlist(command, custom_allowlist):
6770 raise CommandValidationError(
6771 f"Command '{command}' not in allowed command list"
6772 )
6773
6774 # Validate command path
6775 validated_command = validate_command_path(command)
6776
6777 # Validate and sanitize args
6778 validated_args = validate_command_args(args, validated_command)
6779
6780 return validated_command, validated_args
6781
6782
6783def is_sudo_command(command: str, args: Optional[List[str]] = None) -> bool:
6784 """
6785 Check if a command uses sudo.
6786
6787 Args:
6788 command: Command to check
6789 args: Optional command arguments
6790
6791 Returns:
6792 bool: True if the command uses sudo, False otherwise
6793 """
6794 if args is None:
6795 args = []
6796
6797 # Check if command is sudo directly
6798 if os.path.basename(command) == 'sudo':
6799 return True
6800
6801 # Check if command path contains sudo
6802 if 'sudo' in command.split(os.path.sep):
6803 return True
6804
6805 # Check if sudo appears in the full command string
6806 full_command = f"{command} {' '.join(args)}"
6807 if full_command.lstrip().startswith('sudo '):
6808 return True
6809
6810 return False
6811
6812
6813def get_secure_env() -> Dict[str, str]:
6814 """
6815 Get a sanitized environment dictionary for command execution.
6816
6817 Returns:
6818 dict: Sanitized environment variables
6819 """
6820 # Start with a minimal environment
6821 secure_env = {
6822 'PATH': '/usr/local/bin:/usr/bin:/bin',
6823 'LANG': os.environ.get('LANG', 'C.UTF-8'),
6824 'LC_ALL': os.environ.get('LC_ALL', 'C.UTF-8'),
6825 'TERM': os.environ.get('TERM', 'xterm'),
6826 'HOME': os.environ.get('HOME', '/tmp'),
6827 'USER': os.environ.get('USER', 'nobody'),
6828 }
6829
6830 # Copy allowed environment variables
6831 allowed_vars = {
6832 'DISPLAY', 'EDITOR', 'PAGER', 'TZ',
6833 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
6834 'http_proxy', 'https_proxy', 'no_proxy'
6835 }
6836
6837 for var in allowed_vars:
6838 if var in os.environ:
6839 secure_env[var] = os.environ[var]
6840
6841 return secure_env
6842</file>
6843
6844<file path="ollamatools/config.py">
6845"""
6846Configuration management for OllamaTools.
6847
6848This module contains the Config class, which handles configuration settings for
6849the OllamaTools library.
6850"""
6851
6852import json
6853import os
6854from typing import Optional, Dict, Any
6855
6856from pydantic import BaseModel, validator, Field
6857
6858
6859class Config(BaseModel):
6860 """
6861 Configuration class for OllamaTools.
6862
6863 This class manages configuration settings for the OllamaTools library using
6864 Pydantic for validation. It provides methods for loading and saving configuration
6865 to a file.
6866 """
6867
6868 model: str = "llama3.1"
6869 host: str = "http://localhost:11434"
6870 permission_prompts: bool = True
6871 sudo_always_prompt: bool = True
6872 timeout_seconds: int = 60
6873 max_file_size: Optional[int] = None
6874
6875 @validator('timeout_seconds')
6876 def validate_timeout(cls, v):
6877 """Validate that timeout is positive."""
6878 if v <= 0:
6879 raise ValueError("Timeout must be positive")
6880 return v
6881
6882 @validator('max_file_size')
6883 def validate_max_file_size(cls, v):
6884 """Validate that max_file_size is positive if provided."""
6885 if v is not None and v <= 0:
6886 raise ValueError("Max file size must be positive")
6887 return v
6888
6889 @classmethod
6890 def load_from_file(cls, file_path: str) -> "Config":
6891 """
6892 Load configuration from a JSON file.
6893
6894 Args:
6895 file_path: Path to the configuration file
6896
6897 Returns:
6898 Config: Configuration object
6899
6900 Raises:
6901 FileNotFoundError: If the file does not exist
6902 json.JSONDecodeError: If the file contains invalid JSON
6903 ValueError: If the configuration is invalid
6904 """
6905 if not os.path.exists(file_path):
6906 raise FileNotFoundError(f"Configuration file not found: {file_path}")
6907
6908 with open(file_path, 'r') as f:
6909 try:
6910 config_data = json.load(f)
6911 return cls(**config_data)
6912 except json.JSONDecodeError:
6913 raise json.JSONDecodeError(f"Invalid JSON in configuration file: {file_path}", "", 0)
6914
6915 def save_to_file(self, file_path: str) -> None:
6916 """
6917 Save configuration to a JSON file.
6918
6919 Args:
6920 file_path: Path to the configuration file
6921
6922 Raises:
6923 PermissionError: If the file cannot be written
6924 """
6925 try:
6926 # Create directory if it doesn't exist
6927 os.makedirs(os.path.dirname(os.path.abspath(file_path)), exist_ok=True)
6928
6929 with open(file_path, 'w') as f:
6930 json.dump(self.dict(), f, indent=2)
6931 except PermissionError:
6932 raise PermissionError(f"Cannot write to configuration file: {file_path}")
6933
6934 def to_dict(self) -> Dict[str, Any]:
6935 """
6936 Convert configuration to a dictionary.
6937
6938 Returns:
6939 Dict[str, Any]: Configuration as a dictionary
6940 """
6941 return self.dict(exclude_none=True)
6942
6943 @classmethod
6944 def default_config_path(cls) -> str:
6945 """
6946 Get the default configuration file path.
6947
6948 Returns:
6949 str: Default configuration file path
6950 """
6951 return os.path.expanduser("~/.config/ollamatools/config.json")
6952</file>
6953
6954<file path="ollamatools/errors.py">
6955"""
6956Error definitions for OllamaTools.
6957
6958This module defines custom exceptions and error utilities for standardized
6959error handling across the OllamaTools library.
6960"""
6961
6962from typing import Dict, Any, Optional, List, Tuple, Union
6963import logging
6964import sys
6965import traceback
6966
6967# Set up logging
6968logger = logging.getLogger(__name__)
6969
6970# Define error codes
6971ERROR_CODES = {
6972 # General errors
6973 'GENERAL_ERROR': 100,
6974 'NOT_IMPLEMENTED': 101,
6975 'CONFIGURATION_ERROR': 102,
6976 'TIMEOUT_ERROR': 103,
6977
6978 # Permission errors
6979 'PERMISSION_DENIED': 200,
6980 'PERMISSION_PROMPT_CANCELLED': 201,
6981
6982 # Command execution errors
6983 'COMMAND_VALIDATION_ERROR': 300,
6984 'COMMAND_EXECUTION_ERROR': 301,
6985 'COMMAND_NOT_FOUND': 302,
6986 'COMMAND_TIMEOUT': 303,
6987 'COMMAND_NOT_ALLOWED': 304,
6988
6989 # File operation errors
6990 'FILE_NOT_FOUND': 400,
6991 'FILE_READ_ERROR': 401,
6992 'FILE_WRITE_ERROR': 402,
6993 'FILE_PERMISSION_ERROR': 403,
6994 'FILE_SIZE_EXCEEDED': 404,
6995 'PATH_TRAVERSAL_ERROR': 405,
6996 'PATH_VALIDATION_ERROR': 406,
6997 'PATH_OUTSIDE_SANDBOX': 407,
6998
6999 # Web request errors
7000 'URL_VALIDATION_ERROR': 500,
7001 'URL_FETCH_ERROR': 501,
7002 'URL_CONNECTION_ERROR': 502,
7003 'URL_RESPONSE_ERROR': 503,
7004 'URL_BLOCKED': 504,
7005 'URL_TIMEOUT': 505,
7006
7007 # API errors
7008 'API_ERROR': 600,
7009 'API_KEY_MISSING': 601,
7010 'API_KEY_INVALID': 602,
7011 'API_LIMIT_EXCEEDED': 603,
7012 'API_SERVICE_UNAVAILABLE': 604,
7013
7014 # Ollama client errors
7015 'OLLAMA_CONNECTION_ERROR': 700,
7016 'OLLAMA_MODEL_ERROR': 701,
7017 'OLLAMA_API_ERROR': 702,
7018 'OLLAMA_PARSING_ERROR': 703,
7019}
7020
7021
7022class OllamaToolsError(Exception):
7023 """Base exception class for all OllamaTools errors."""
7024
7025 def __init__(
7026 self,
7027 message: str,
7028 code: Optional[int] = None,
7029 details: Optional[Dict[str, Any]] = None
7030 ):
7031 """
7032 Initialize the exception.
7033
7034 Args:
7035 message: Error message
7036 code: Error code
7037 details: Additional error details
7038 """
7039 self.message = message
7040 self.code = code or ERROR_CODES.get('GENERAL_ERROR', 100)
7041 self.details = details or {}
7042
7043 # Construct the full error message
7044 full_message = f"[Error {self.code}] {message}"
7045 if details:
7046 full_message += f" - Details: {details}"
7047
7048 super().__init__(full_message)
7049
7050
7051class ConfigurationError(OllamaToolsError):
7052 """Error raised for configuration issues."""
7053
7054 def __init__(
7055 self,
7056 message: str,
7057 details: Optional[Dict[str, Any]] = None
7058 ):
7059 super().__init__(
7060 message,
7061 code=ERROR_CODES.get('CONFIGURATION_ERROR'),
7062 details=details
7063 )
7064
7065
7066class TimeoutError(OllamaToolsError):
7067 """Error raised when an operation times out."""
7068
7069 def __init__(
7070 self,
7071 message: str,
7072 timeout: Optional[int] = None,
7073 details: Optional[Dict[str, Any]] = None
7074 ):
7075 if timeout is not None:
7076 details = details or {}
7077 details['timeout'] = timeout
7078
7079 super().__init__(
7080 message,
7081 code=ERROR_CODES.get('TIMEOUT_ERROR'),
7082 details=details
7083 )
7084
7085
7086class PermissionError(OllamaToolsError):
7087 """Error raised when permission is denied for an operation."""
7088
7089 def __init__(
7090 self,
7091 message: str,
7092 function_name: Optional[str] = None,
7093 args: Optional[Dict[str, Any]] = None,
7094 details: Optional[Dict[str, Any]] = None
7095 ):
7096 combined_details = details or {}
7097 if function_name:
7098 combined_details['function'] = function_name
7099 if args:
7100 combined_details['args'] = args
7101
7102 super().__init__(
7103 message,
7104 code=ERROR_CODES.get('PERMISSION_DENIED'),
7105 details=combined_details
7106 )
7107
7108
7109class CommandError(OllamaToolsError):
7110 """Error raised for command execution issues."""
7111
7112 def __init__(
7113 self,
7114 message: str,
7115 command: Optional[str] = None,
7116 args: Optional[List[str]] = None,
7117 exit_code: Optional[int] = None,
7118 stderr: Optional[str] = None,
7119 code: Optional[int] = None,
7120 details: Optional[Dict[str, Any]] = None
7121 ):
7122 error_code = code or ERROR_CODES.get('COMMAND_EXECUTION_ERROR')
7123
7124 combined_details = details or {}
7125 if command:
7126 combined_details['command'] = command
7127 if args:
7128 combined_details['args'] = args
7129 if exit_code is not None:
7130 combined_details['exit_code'] = exit_code
7131 if stderr:
7132 combined_details['stderr'] = stderr
7133
7134 super().__init__(
7135 message,
7136 code=error_code,
7137 details=combined_details
7138 )
7139
7140
7141class FileError(OllamaToolsError):
7142 """Error raised for file operation issues."""
7143
7144 def __init__(
7145 self,
7146 message: str,
7147 path: Optional[str] = None,
7148 code: Optional[int] = None,
7149 details: Optional[Dict[str, Any]] = None
7150 ):
7151 error_code = code or ERROR_CODES.get('FILE_READ_ERROR')
7152
7153 combined_details = details or {}
7154 if path:
7155 combined_details['path'] = path
7156
7157 super().__init__(
7158 message,
7159 code=error_code,
7160 details=combined_details
7161 )
7162
7163
7164class WebError(OllamaToolsError):
7165 """Error raised for web request issues."""
7166
7167 def __init__(
7168 self,
7169 message: str,
7170 url: Optional[str] = None,
7171 status_code: Optional[int] = None,
7172 code: Optional[int] = None,
7173 details: Optional[Dict[str, Any]] = None
7174 ):
7175 error_code = code or ERROR_CODES.get('URL_FETCH_ERROR')
7176
7177 combined_details = details or {}
7178 if url:
7179 combined_details['url'] = url
7180 if status_code is not None:
7181 combined_details['status_code'] = status_code
7182
7183 super().__init__(
7184 message,
7185 code=error_code,
7186 details=combined_details
7187 )
7188
7189
7190class ApiError(OllamaToolsError):
7191 """Error raised for API-related issues."""
7192
7193 def __init__(
7194 self,
7195 message: str,
7196 api_name: Optional[str] = None,
7197 status_code: Optional[int] = None,
7198 code: Optional[int] = None,
7199 details: Optional[Dict[str, Any]] = None
7200 ):
7201 error_code = code or ERROR_CODES.get('API_ERROR')
7202
7203 combined_details = details or {}
7204 if api_name:
7205 combined_details['api'] = api_name
7206 if status_code is not None:
7207 combined_details['status_code'] = status_code
7208
7209 super().__init__(
7210 message,
7211 code=error_code,
7212 details=combined_details
7213 )
7214
7215
7216class OllamaClientError(OllamaToolsError):
7217 """Error raised for Ollama client issues."""
7218
7219 def __init__(
7220 self,
7221 message: str,
7222 model: Optional[str] = None,
7223 code: Optional[int] = None,
7224 details: Optional[Dict[str, Any]] = None
7225 ):
7226 error_code = code or ERROR_CODES.get('OLLAMA_API_ERROR')
7227
7228 combined_details = details or {}
7229 if model:
7230 combined_details['model'] = model
7231
7232 super().__init__(
7233 message,
7234 code=error_code,
7235 details=combined_details
7236 )
7237
7238
7239def get_error_details(exception: Exception) -> Dict[str, Any]:
7240 """
7241 Extract detailed information from an exception.
7242
7243 Args:
7244 exception: The exception to extract details from
7245
7246 Returns:
7247 dict: Error details
7248 """
7249 error_type = type(exception).__name__
7250 error_message = str(exception)
7251
7252 # Get the traceback
7253 tb = traceback.extract_tb(sys.exc_info()[2])
7254 tb_formatted = []
7255
7256 for frame in tb:
7257 tb_formatted.append({
7258 'file': frame.filename,
7259 'line': frame.lineno,
7260 'function': frame.name,
7261 'code': frame.line
7262 })
7263
7264 # Create the error details
7265 details = {
7266 'type': error_type,
7267 'message': error_message,
7268 'traceback': tb_formatted
7269 }
7270
7271 # Add additional details if it's an OllamaToolsError
7272 if isinstance(exception, OllamaToolsError):
7273 details['code'] = exception.code
7274 if exception.details:
7275 details['additional_details'] = exception.details
7276
7277 return details
7278
7279
7280def format_error_response(exception: Exception) -> Dict[str, Any]:
7281 """
7282 Format an exception as a standardized error response.
7283
7284 Args:
7285 exception: The exception to format
7286
7287 Returns:
7288 dict: Formatted error response
7289 """
7290 details = get_error_details(exception)
7291
7292 # Create a user-friendly error response
7293 error_response = {
7294 'error': True,
7295 'error_type': details['type'],
7296 'error_message': details['message']
7297 }
7298
7299 # Add code if available
7300 if 'code' in details:
7301 error_response['error_code'] = details['code']
7302
7303 return error_response
7304
7305
7306def handle_exception(
7307 exception: Exception,
7308 log_error: bool = True,
7309 include_traceback: bool = False
7310) -> Dict[str, Any]:
7311 """
7312 Handle an exception and return a standardized error response.
7313
7314 Args:
7315 exception: The exception to handle
7316 log_error: Whether to log the error
7317 include_traceback: Whether to include traceback in the response
7318
7319 Returns:
7320 dict: Error response
7321 """
7322 # Log the error if requested
7323 if log_error:
7324 error_details = get_error_details(exception)
7325 logger.error(
7326 f"Error: {error_details['type']}: {error_details['message']}",
7327 exc_info=True
7328 )
7329
7330 # Format the error response
7331 error_response = format_error_response(exception)
7332
7333 # Include traceback if requested
7334 if include_traceback:
7335 error_details = get_error_details(exception)
7336 error_response['traceback'] = error_details['traceback']
7337
7338 return error_response
7339</file>
7340
7341<file path="ollamatools/logger.py">
7342"""
7343Logging utilities for OllamaTools.
7344
7345This module contains logging utilities used by the OllamaTools library.
7346"""
7347
7348import logging
7349import os
7350import sys
7351from typing import Optional, Union, Dict, Any, List
7352
7353
7354def get_logger(
7355 name: str,
7356 level: Union[int, str] = logging.INFO,
7357 log_file: Optional[str] = None,
7358 log_format: Optional[str] = None,
7359 propagate: bool = False,
7360 additional_handlers: Optional[List[logging.Handler]] = None,
7361 extra: Optional[Dict[str, Any]] = None
7362) -> logging.Logger:
7363 """
7364 Get a configured logger instance.
7365
7366 Args:
7367 name: Logger name
7368 level: Log level (can be integer or string like 'INFO', 'DEBUG')
7369 log_file: Optional log file path
7370 log_format: Optional custom log format
7371 propagate: Whether to propagate logs to parent loggers
7372 additional_handlers: Optional list of additional handlers to add
7373 extra: Optional extra fields to add to all log records
7374
7375 Returns:
7376 logging.Logger: Configured logger instance
7377 """
7378 # Convert string level to integer if needed
7379 if isinstance(level, str):
7380 level = getattr(logging, level.upper())
7381
7382 # Create or get the logger
7383 logger = logging.getLogger(name)
7384 logger.setLevel(level)
7385 logger.propagate = propagate
7386
7387 # Remove existing handlers to avoid duplicates if the logger already exists
7388 for handler in logger.handlers[:]:
7389 logger.removeHandler(handler)
7390
7391 # Default format if none provided
7392 if log_format is None:
7393 log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
7394
7395 formatter = logging.Formatter(log_format)
7396
7397 # Add console handler
7398 console_handler = logging.StreamHandler(stream=sys.stdout)
7399 console_handler.setLevel(level)
7400 console_handler.setFormatter(formatter)
7401 logger.addHandler(console_handler)
7402
7403 # Add file handler if specified
7404 if log_file:
7405 # Create directory if it doesn't exist
7406 log_dir = os.path.dirname(os.path.abspath(log_file))
7407 if log_dir:
7408 os.makedirs(log_dir, exist_ok=True)
7409
7410 file_handler = logging.FileHandler(log_file)
7411 file_handler.setLevel(level)
7412 file_handler.setFormatter(formatter)
7413 logger.addHandler(file_handler)
7414
7415 # Add any additional handlers
7416 if additional_handlers:
7417 for handler in additional_handlers:
7418 logger.addHandler(handler)
7419
7420 # Add a filter for extra fields if provided
7421 if extra:
7422 class ExtraFilter(logging.Filter):
7423 def filter(self, record):
7424 for key, value in extra.items():
7425 setattr(record, key, value)
7426 return True
7427
7428 extra_filter = ExtraFilter()
7429 logger.addFilter(extra_filter)
7430
7431 return logger
7432
7433
7434def enable_debug_logging(logger_names: Optional[List[str]] = None) -> None:
7435 """
7436 Enable debug logging for specified loggers or all loggers.
7437
7438 Args:
7439 logger_names: Optional list of logger names to enable debug logging for,
7440 if None, enables for root logger
7441 """
7442 if logger_names is None:
7443 # Enable debug logging for the root logger
7444 logging.getLogger().setLevel(logging.DEBUG)
7445 else:
7446 # Enable debug logging for specific loggers
7447 for name in logger_names:
7448 logging.getLogger(name).setLevel(logging.DEBUG)
7449
7450
7451def disable_logging(logger_names: Optional[List[str]] = None) -> None:
7452 """
7453 Disable logging for specified loggers or all loggers.
7454
7455 Args:
7456 logger_names: Optional list of logger names to disable logging for,
7457 if None, disables for root logger
7458 """
7459 if logger_names is None:
7460 # Disable logging for the root logger
7461 logging.getLogger().setLevel(logging.CRITICAL + 1)
7462 else:
7463 # Disable logging for specific loggers
7464 for name in logger_names:
7465 logging.getLogger(name).setLevel(logging.CRITICAL + 1)
7466
7467
7468def get_ollamatools_logger(
7469 component: str,
7470 level: Union[int, str] = logging.INFO,
7471 log_file: Optional[str] = None
7472) -> logging.Logger:
7473 """
7474 Get a logger for a specific OllamaTools component.
7475
7476 Args:
7477 component: Component name (e.g., 'agent', 'system_tools')
7478 level: Log level
7479 log_file: Optional log file path
7480
7481 Returns:
7482 logging.Logger: Configured logger instance
7483 """
7484 logger_name = f"ollamatools.{component}"
7485 return get_logger(logger_name, level, log_file)
7486</file>
7487
7488<file path="ollamatools/opensearch_web_tools.py">
7489"""
7490OpenSearchNG integration for OllamaTools.
7491
7492This module extends the web_tools capabilities by adding integration with
7493a locally running OpenSearchNG instance for improved search functionality.
7494"""
7495
7496import requests
7497import json
7498import logging
7499from typing import Dict, Any, Optional, List, Union, Set
7500from urllib.parse import quote_plus
7501
7502# Import URL validation utilities
7503from ollamatools.url_validator import (
7504 validate_url,
7505 URLValidationError,
7506 configure_domain_lists,
7507)
7508
7509# Import error handling utilities
7510from ollamatools.errors import (
7511 WebError,
7512 ERROR_CODES,
7513 handle_exception
7514)
7515
7516# Set up logging
7517logger = logging.getLogger(__name__)
7518
7519# Default OpenSearchNG configuration
7520DEFAULT_OPENSEARCH_CONFIG = {
7521 "base_url": "http://localhost:8081", # Default local OpenSearchNG URL
7522 "search_path": "/search", # Path for search endpoint
7523 "preferences_path": "/preferences", # Path for preferences endpoint
7524 "categories": ["general"], # Default search categories
7525 "engines": ["google", "bing", "duckduckgo"], # Default search engines
7526 "language": "en-US", # Default language
7527 "format": "json", # Response format (html, json, csv, rss)
7528 "timeout": 10, # Default timeout in seconds
7529 "num_results": 10, # Default number of results
7530 "safe_search": 0 # 0=off, 1=moderate, 2=strict
7531}
7532
7533def opensearch_query(
7534 query: str,
7535 config: Optional[Dict[str, Any]] = None,
7536 permission_manager = None,
7537 domain_allowlist: Optional[Set[str]] = None,
7538 domain_blocklist: Optional[Set[str]] = None
7539) -> Dict[str, Any]:
7540 """
7541 Perform a search query using a local OpenSearchNG instance.
7542
7543 Args:
7544 query: Search query string
7545 config: OpenSearchNG configuration (defaults used if None)
7546 permission_manager: Optional permission manager to request permission
7547 domain_allowlist: Optional set of allowed domains for results
7548 domain_blocklist: Optional set of blocked domains for results
7549
7550 Returns:
7551 dict: Search results with metadata
7552
7553 Raises:
7554 ValueError: If parameters are invalid
7555 WebError: If the search fails
7556 PermissionError: If permission is denied
7557 """
7558 # Validate input
7559 if not query or not isinstance(query, str):
7560 raise ValueError("Query must be a non-empty string")
7561
7562 # Use default config if none provided
7563 search_config = DEFAULT_OPENSEARCH_CONFIG.copy()
7564 if config:
7565 search_config.update(config)
7566
7567 # Request permission if permission manager is provided
7568 if permission_manager:
7569 args = {
7570 "query": query,
7571 "config": search_config,
7572 }
7573
7574 if not permission_manager.request_permission("opensearch_query", args):
7575 logger.warning(f"Permission denied for OpenSearchNG query: {query}")
7576 raise PermissionError(f"Permission denied for OpenSearchNG query: {query}")
7577
7578 # Construct the search URL
7579 base_url = search_config["base_url"]
7580 search_path = search_config["search_path"]
7581 search_url = f"{base_url}{search_path}"
7582
7583 # Validate the OpenSearchNG instance URL
7584 try:
7585 # Allow localhost for OpenSearchNG access
7586 local_allowlist = {"localhost", "127.0.0.1"}
7587 if domain_allowlist:
7588 local_allowlist.update(domain_allowlist)
7589
7590 # Extract the host from the base URL for validation
7591 from urllib.parse import urlparse
7592 parsed_url = urlparse(base_url)
7593 hostname = parsed_url.netloc.split(':')[0]
7594
7595 if hostname not in local_allowlist:
7596 validate_url(
7597 base_url,
7598 domain_allowlist=local_allowlist,
7599 domain_blocklist=domain_blocklist
7600 )
7601 except URLValidationError as e:
7602 # Make a special exception for localhost
7603 if "localhost" not in base_url and "127.0.0.1" not in base_url:
7604 raise ValueError(f"Invalid OpenSearchNG URL: {str(e)}")
7605
7606 # Prepare query parameters
7607 params = {
7608 "q": query,
7609 "format": search_config["format"],
7610 "categories": ",".join(search_config["categories"]),
7611 "engines": ",".join(search_config["engines"]),
7612 "language": search_config["language"],
7613 "safesearch": search_config["safe_search"],
7614 "time_range": "", # Optional time range
7615 "pageno": 1, # First page of results
7616 }
7617
7618 # Create session with appropriate headers
7619 with requests.Session() as session:
7620 headers = {
7621 "User-Agent": "OllamaTools/0.2.0",
7622 "Accept": "application/json"
7623 }
7624 session.headers.update(headers)
7625
7626 try:
7627 logger.info(f"Making OpenSearchNG query: {query}")
7628 response = session.get(
7629 search_url,
7630 params=params,
7631 timeout=search_config["timeout"]
7632 )
7633
7634 # Check for successful response
7635 if response.status_code != 200:
7636 raise WebError(
7637 f"OpenSearchNG query failed with status code: {response.status_code}",
7638 url=search_url,
7639 status_code=response.status_code,
7640 code=ERROR_CODES["URL_RESPONSE_ERROR"]
7641 )
7642
7643 # Parse the response based on format
7644 if search_config["format"] == "json":
7645 try:
7646 result_data = response.json()
7647 except json.JSONDecodeError:
7648 raise WebError(
7649 "Failed to parse JSON response from OpenSearchNG",
7650 url=search_url,
7651 code=ERROR_CODES["URL_RESPONSE_ERROR"]
7652 )
7653 else:
7654 # For other formats, return the raw content
7655 result_data = {"raw_content": response.text}
7656
7657 # Validate and process the results
7658 processed_results = process_opensearch_results(result_data, query)
7659
7660 return processed_results
7661
7662 except requests.RequestException as e:
7663 raise WebError(
7664 f"OpenSearchNG request failed: {str(e)}",
7665 url=search_url,
7666 code=ERROR_CODES["URL_CONNECTION_ERROR"]
7667 )
7668
7669def process_opensearch_results(
7670 result_data: Dict[str, Any],
7671 query: str
7672) -> Dict[str, Any]:
7673 """
7674 Process and format the OpenSearchNG results.
7675
7676 Args:
7677 result_data: Raw result data from OpenSearchNG
7678 query: Original search query
7679
7680 Returns:
7681 dict: Processed search results with metadata
7682 """
7683 processed_results = {
7684 "query": query,
7685 "results": [],
7686 "suggestion": None,
7687 "answer": None,
7688 "infobox": None,
7689 "engines": [],
7690 "timing": {}
7691 }
7692
7693 # Extract search results
7694 if "results" in result_data:
7695 for result in result_data["results"]:
7696 processed_result = {
7697 "title": result.get("title", ""),
7698 "url": result.get("url", ""),
7699 "content": result.get("content", ""),
7700 "engine": result.get("engine", ""),
7701 "score": result.get("score", 0),
7702 "category": result.get("category", ""),
7703 "pretty_url": result.get("pretty_url", "")
7704 }
7705 processed_results["results"].append(processed_result)
7706
7707 # Extract suggestions
7708 if "suggestions" in result_data and result_data["suggestions"]:
7709 processed_results["suggestion"] = result_data["suggestions"][0]
7710
7711 # Extract answer if available
7712 if "answers" in result_data and result_data["answers"]:
7713 processed_results["answer"] = result_data["answers"][0]
7714
7715 # Extract infobox if available
7716 if "infoboxes" in result_data and result_data["infoboxes"]:
7717 processed_results["infobox"] = result_data["infoboxes"][0]
7718
7719 # Extract engines used
7720 if "engines" in result_data:
7721 processed_results["engines"] = result_data["engines"]
7722
7723 # Extract timing information
7724 if "timing" in result_data:
7725 processed_results["timing"] = result_data["timing"]
7726
7727 return processed_results
7728
7729def get_opensearch_info(
7730 config: Optional[Dict[str, Any]] = None,
7731 permission_manager = None
7732) -> Dict[str, Any]:
7733 """
7734 Get information about the OpenSearchNG instance.
7735
7736 Args:
7737 config: OpenSearchNG configuration (defaults used if None)
7738 permission_manager: Optional permission manager to request permission
7739
7740 Returns:
7741 dict: Information about the OpenSearchNG instance
7742
7743 Raises:
7744 WebError: If the request fails
7745 PermissionError: If permission is denied
7746 """
7747 # Use default config if none provided
7748 search_config = DEFAULT_OPENSEARCH_CONFIG.copy()
7749 if config:
7750 search_config.update(config)
7751
7752 # Request permission if permission manager is provided
7753 if permission_manager:
7754 if not permission_manager.request_permission("get_opensearch_info", {"config": search_config}):
7755 logger.warning("Permission denied for getting OpenSearchNG info")
7756 raise PermissionError("Permission denied for getting OpenSearchNG info")
7757
7758 # Construct the URL
7759 base_url = search_config["base_url"]
7760 config_url = f"{base_url}/config"
7761
7762 try:
7763 logger.info("Getting OpenSearchNG instance information")
7764 response = requests.get(
7765 config_url,
7766 timeout=search_config["timeout"]
7767 )
7768
7769 # Check for successful response
7770 if response.status_code != 200:
7771 raise WebError(
7772 f"OpenSearchNG info request failed with status code: {response.status_code}",
7773 url=config_url,
7774 status_code=response.status_code,
7775 code=ERROR_CODES["URL_RESPONSE_ERROR"]
7776 )
7777
7778 # Parse the response
7779 try:
7780 config_data = response.json()
7781 return config_data
7782 except json.JSONDecodeError:
7783 raise WebError(
7784 "Failed to parse JSON response from OpenSearchNG",
7785 url=config_url,
7786 code=ERROR_CODES["URL_RESPONSE_ERROR"]
7787 )
7788
7789 except requests.RequestException as e:
7790 raise WebError(
7791 f"OpenSearchNG info request failed: {str(e)}",
7792 url=config_url,
7793 code=ERROR_CODES["URL_CONNECTION_ERROR"]
7794 )
7795
7796def opensearch_fetch_url(
7797 url: str,
7798 config: Optional[Dict[str, Any]] = None,
7799 permission_manager = None,
7800 domain_allowlist: Optional[Set[str]] = None,
7801 domain_blocklist: Optional[Set[str]] = None
7802) -> Dict[str, Any]:
7803 """
7804 Fetch a URL through the OpenSearchNG proxy to avoid direct connections.
7805 This provides an additional security layer for web requests.
7806
7807 Args:
7808 url: URL to fetch
7809 config: OpenSearchNG configuration (defaults used if None)
7810 permission_manager: Optional permission manager to request permission
7811 domain_allowlist: Optional set of allowed domains
7812 domain_blocklist: Optional set of blocked domains
7813
7814 Returns:
7815 dict: Response content and metadata
7816
7817 Raises:
7818 ValueError: If URL is invalid
7819 WebError: If the fetch fails
7820 PermissionError: If permission is denied
7821 """
7822 # Validate URL
7823 try:
7824 validated_url = validate_url(
7825 url,
7826 domain_allowlist=domain_allowlist,
7827 domain_blocklist=domain_blocklist
7828 )
7829 except URLValidationError as e:
7830 raise ValueError(f"URL validation failed: {str(e)}")
7831
7832 # Use default config if none provided
7833 search_config = DEFAULT_OPENSEARCH_CONFIG.copy()
7834 if config:
7835 search_config.update(config)
7836
7837 # Request permission if permission manager is provided
7838 if permission_manager:
7839 args = {
7840 "url": url,
7841 "config": search_config
7842 }
7843
7844 if not permission_manager.request_permission("opensearch_fetch_url", args):
7845 logger.warning(f"Permission denied for fetching URL via OpenSearchNG: {url}")
7846 raise PermissionError(f"Permission denied for fetching URL via OpenSearchNG: {url}")
7847
7848 # Construct the URL for the OpenSearchNG proxy
7849 base_url = search_config["base_url"]
7850 proxy_url = f"{base_url}/url_proxy"
7851
7852 # Prepare query parameters
7853 params = {
7854 "url": validated_url
7855 }
7856
7857 try:
7858 logger.info(f"Fetching URL via OpenSearchNG proxy: {validated_url}")
7859 response = requests.get(
7860 proxy_url,
7861 params=params,
7862 timeout=search_config["timeout"]
7863 )
7864
7865 # Check for successful response
7866 if response.status_code != 200:
7867 raise WebError(
7868 f"URL fetch via OpenSearchNG failed with status code: {response.status_code}",
7869 url=proxy_url,
7870 status_code=response.status_code,
7871 code=ERROR_CODES["URL_RESPONSE_ERROR"]
7872 )
7873
7874 # Create result with metadata
7875 result = {
7876 "status": response.status_code,
7877 "headers": dict(response.headers),
7878 "content": response.text,
7879 "url": validated_url
7880 }
7881
7882 return result
7883
7884 except requests.RequestException as e:
7885 raise WebError(
7886 f"URL fetch via OpenSearchNG failed: {str(e)}",
7887 url=proxy_url,
7888 code=ERROR_CODES["URL_CONNECTION_ERROR"]
7889 )
49707890</file>
49717891
4972<file path="ollamatools/config.py">
7892<file path="ollamatools/path_validator.py">
49737893"""
4974Configuration management for OllamaTools.
7894Path validation module for OllamaTools.
49757895
4976This module contains the Config class, which handles configuration settings for
4977the OllamaTools library.
7896This module provides functions for validating and sanitizing file paths
7897to prevent path traversal attacks and other file path-related security issues.
49787898"""
49797899
4980import json
49817900import os
4982from typing import Optional, Dict, Any
4983
4984from pydantic import BaseModel, validator, Field
7901import re
7902import pathlib
7903from typing import Optional, List, Set, Dict, Any, Union, Tuple
7904import logging
49857905
7906# Set up logging
7907logger = logging.getLogger(__name__)
49867908
4987class Config(BaseModel):
7909# Default protected paths - should not be modified
7910DEFAULT_PROTECTED_PATHS = {
7911 '/etc',
7912 '/bin',
7913 '/sbin',
7914 '/usr/bin',
7915 '/usr/sbin',
7916 '/usr/lib',
7917 '/usr/lib64',
7918 '/boot',
7919 '/dev',
7920 '/proc',
7921 '/sys',
7922 '/var/lib',
7923 '/var/log',
7924 '/var/run',
7925 '/var/lock',
7926}
7927
7928# Default paths to allow even if they're in protected paths
7929DEFAULT_ALLOWED_PATHS = {
7930 '/etc/hosts', # commonly read for network configuration
7931 '/etc/hostname', # commonly read for system identification
7932 '/etc/passwd', # commonly read for user information (non-sensitive part)
7933}
7934
7935# Default sandbox path - where operations are restricted to
7936DEFAULT_SANDBOX_PATH = os.path.expanduser('~')
7937
7938class PathValidationError(Exception):
7939 """Exception raised for path validation errors."""
7940 pass
7941
7942
7943def normalize_path(path: str) -> str:
49887944 """
4989 Configuration class for OllamaTools.
7945 Normalize a file path to resolve symlinks, relative paths, etc.
49907946
4991 This class manages configuration settings for the OllamaTools library using
4992 Pydantic for validation. It provides methods for loading and saving configuration
4993 to a file.
7947 Args:
7948 path: File path to normalize
7949
7950 Returns:
7951 str: Normalized absolute path
49947952 """
7953 # Convert to string if it's not already
7954 if not isinstance(path, str):
7955 path = str(path)
49957956
4996 model: str = "llama3.1"
4997 host: str = "http://localhost:11434"
4998 permission_prompts: bool = True
4999 sudo_always_prompt: bool = True
5000 timeout_seconds: int = 60
5001 max_file_size: Optional[int] = None
7957 # Remove any null bytes
7958 path = path.replace('\0', '')
50027959
5003 @validator('timeout_seconds')
5004 def validate_timeout(cls, v):
5005 """Validate that timeout is positive."""
5006 if v <= 0:
5007 raise ValueError("Timeout must be positive")
5008 return v
7960 # Trim whitespace
7961 path = path.strip()
50097962
5010 @validator('max_file_size')
5011 def validate_max_file_size(cls, v):
5012 """Validate that max_file_size is positive if provided."""
5013 if v is not None and v <= 0:
5014 raise ValueError("Max file size must be positive")
5015 return v
7963 # Create an absolute path
7964 abs_path = os.path.abspath(path)
50167965
5017 @classmethod
5018 def load_from_file(cls, file_path: str) -> "Config":
5019 """
5020 Load configuration from a JSON file.
5021
5022 Args:
5023 file_path: Path to the configuration file
5024
5025 Returns:
5026 Config: Configuration object
5027
5028 Raises:
5029 FileNotFoundError: If the file does not exist
5030 json.JSONDecodeError: If the file contains invalid JSON
5031 ValueError: If the configuration is invalid
5032 """
5033 if not os.path.exists(file_path):
5034 raise FileNotFoundError(f"Configuration file not found: {file_path}")
5035
5036 with open(file_path, 'r') as f:
5037 try:
5038 config_data = json.load(f)
5039 return cls(**config_data)
5040 except json.JSONDecodeError:
5041 raise json.JSONDecodeError(f"Invalid JSON in configuration file: {file_path}", "", 0)
7966 # Normalize the path (resolves ., .., symlinks)
7967 try:
7968 norm_path = os.path.normpath(abs_path)
7969 return norm_path
7970 except Exception as e:
7971 raise PathValidationError(f"Path normalization error: {str(e)}")
7972
7973
7974def resolve_symlinks(path: str) -> str:
7975 """
7976 Resolve symlinks in a path.
50427977
5043 def save_to_file(self, file_path: str) -> None:
5044 """
5045 Save configuration to a JSON file.
7978 Args:
7979 path: File path that might contain symlinks
50467980
5047 Args:
5048 file_path: Path to the configuration file
5049
5050 Raises:
5051 PermissionError: If the file cannot be written
5052 """
5053 try:
5054 # Create directory if it doesn't exist
5055 os.makedirs(os.path.dirname(os.path.abspath(file_path)), exist_ok=True)
5056
5057 with open(file_path, 'w') as f:
5058 json.dump(self.dict(), f, indent=2)
5059 except PermissionError:
5060 raise PermissionError(f"Cannot write to configuration file: {file_path}")
7981 Returns:
7982 str: Path with all symlinks resolved
7983 """
7984 try:
7985 # Use pathlib to resolve symlinks
7986 resolved_path = str(pathlib.Path(path).resolve())
7987 return resolved_path
7988 except Exception as e:
7989 raise PathValidationError(f"Failed to resolve symlinks in path '{path}': {str(e)}")
7990
7991
7992def is_path_traversal_attempt(path: str) -> bool:
7993 """
7994 Check if a path contains traversal patterns that might be used in attacks.
50617995
5062 def to_dict(self) -> Dict[str, Any]:
5063 """
5064 Convert configuration to a dictionary.
7996 Args:
7997 path: File path to check
50657998
5066 Returns:
5067 Dict[str, Any]: Configuration as a dictionary
5068 """
5069 return self.dict(exclude_none=True)
7999 Returns:
8000 bool: True if the path contains suspicious patterns, False otherwise
8001 """
8002 # Normalize for easy pattern matching
8003 normalized = os.path.normpath(path)
8004
8005 # Check for common traversal patterns
8006 suspicious_patterns = [
8007 r'\.\./', # Parent directory references
8008 r'/\.\./', # Parent directory references
8009 r'/\.\.$', # Path ending with parent reference
8010 r'^\.\./.*', # Path starting with parent reference
8011 r'\\\\', # Double backslash (Windows)
8012 r'\.\\', # Current directory backslash (Windows)
8013 r'//', # Double forward slash
8014 r'\0', # Null byte
8015 r'%00', # URL-encoded null byte
8016 r'%2e%2e%2f', # URL-encoded ../
8017 r'%2e%2e/', # URL-encoded ../
8018 r'\$\{.*?\}', # Shell variable expansion
8019 r'`.*?`', # Command substitution
8020 ]
50708021
5071 @classmethod
5072 def default_config_path(cls) -> str:
5073 """
5074 Get the default configuration file path.
5075
5076 Returns:
5077 str: Default configuration file path
5078 """
5079 return os.path.expanduser("~/.config/ollamatools/config.json")
5080</file>
5081
5082<file path="ollamatools/logger.py">
5083"""
5084Logging utilities for OllamaTools.
8022 for pattern in suspicious_patterns:
8023 if re.search(pattern, path):
8024 return True
8025
8026 return False
50858027
5086This module contains logging utilities used by the OllamaTools library.
5087"""
50888028
5089import logging
5090import os
5091import sys
5092from typing import Optional, Union, Dict, Any, List
8029def is_path_in_sandbox(
8030 path: str,
8031 sandbox_path: Optional[str] = None,
8032 additional_allowed_paths: Optional[Set[str]] = None
8033) -> bool:
8034 """
8035 Check if a path is within the sandbox path or explicitly allowed.
8036
8037 Args:
8038 path: File path to check
8039 sandbox_path: Base path that defines the sandbox (default is user's home)
8040 additional_allowed_paths: Additional paths to allow
8041
8042 Returns:
8043 bool: True if the path is in the sandbox or allowed, False otherwise
8044 """
8045 # Resolve the sandbox path
8046 if sandbox_path is None:
8047 sandbox_path = DEFAULT_SANDBOX_PATH
8048
8049 # Normalize all paths for comparison
8050 normalized_path = normalize_path(path)
8051 normalized_sandbox = normalize_path(sandbox_path)
8052
8053 # Check if the path is explicitly allowed
8054 allowed_paths = DEFAULT_ALLOWED_PATHS.copy()
8055 if additional_allowed_paths:
8056 allowed_paths.update(additional_allowed_paths)
8057
8058 if normalized_path in allowed_paths:
8059 return True
8060
8061 # Check if the path is a subdirectory of the sandbox
8062 return normalized_path.startswith(normalized_sandbox)
50938063
50948064
5095def get_logger(
5096 name: str,
5097 level: Union[int, str] = logging.INFO,
5098 log_file: Optional[str] = None,
5099 log_format: Optional[str] = None,
5100 propagate: bool = False,
5101 additional_handlers: Optional[List[logging.Handler]] = None,
5102 extra: Optional[Dict[str, Any]] = None
5103) -> logging.Logger:
8065def is_path_protected(
8066 path: str,
8067 additional_protected_paths: Optional[Set[str]] = None,
8068 allowed_paths: Optional[Set[str]] = None
8069) -> bool:
51048070 """
5105 Get a configured logger instance.
8071 Check if a path is in a protected area that should not be modified.
51068072
51078073 Args:
5108 name: Logger name
5109 level: Log level (can be integer or string like 'INFO', 'DEBUG')
5110 log_file: Optional log file path
5111 log_format: Optional custom log format
5112 propagate: Whether to propagate logs to parent loggers
5113 additional_handlers: Optional list of additional handlers to add
5114 extra: Optional extra fields to add to all log records
8074 path: File path to check
8075 additional_protected_paths: Additional paths to protect
8076 allowed_paths: Paths explicitly allowed even if in protected areas
51158077
51168078 Returns:
5117 logging.Logger: Configured logger instance
8079 bool: True if the path is protected, False otherwise
51188080 """
5119 # Convert string level to integer if needed
5120 if isinstance(level, str):
5121 level = getattr(logging, level.upper())
8081 # Normalize the path
8082 normalized_path = normalize_path(path)
51228083
5123 # Create or get the logger
5124 logger = logging.getLogger(name)
5125 logger.setLevel(level)
5126 logger.propagate = propagate
8084 # Combine default and additional protected paths
8085 protected_paths = DEFAULT_PROTECTED_PATHS.copy()
8086 if additional_protected_paths:
8087 protected_paths.update(additional_protected_paths)
51278088
5128 # Remove existing handlers to avoid duplicates if the logger already exists
5129 for handler in logger.handlers[:]:
5130 logger.removeHandler(handler)
8089 # Combine default and additional allowed paths
8090 all_allowed_paths = DEFAULT_ALLOWED_PATHS.copy()
8091 if allowed_paths:
8092 all_allowed_paths.update(allowed_paths)
51318093
5132 # Default format if none provided
5133 if log_format is None:
5134 log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
8094 # First check if the path is explicitly allowed
8095 if normalized_path in all_allowed_paths:
8096 return False
51358097
5136 formatter = logging.Formatter(log_format)
8098 # Then check if the path is in a protected area
8099 for protected in protected_paths:
8100 if normalized_path == protected or normalized_path.startswith(protected + os.path.sep):
8101 return True
51378102
5138 # Add console handler
5139 console_handler = logging.StreamHandler(stream=sys.stdout)
5140 console_handler.setLevel(level)
5141 console_handler.setFormatter(formatter)
5142 logger.addHandler(console_handler)
8103 return False
8104
8105
8106def validate_path_for_reading(
8107 path: str,
8108 sandbox_path: Optional[str] = None,
8109 additional_allowed_paths: Optional[Set[str]] = None,
8110 resolve_links: bool = True
8111) -> str:
8112 """
8113 Validate a path for reading operations (e.g., read_file).
51438114
5144 # Add file handler if specified
5145 if log_file:
5146 # Create directory if it doesn't exist
5147 log_dir = os.path.dirname(os.path.abspath(log_file))
5148 if log_dir:
5149 os.makedirs(log_dir, exist_ok=True)
8115 Args:
8116 path: File path to validate
8117 sandbox_path: Base path that defines the sandbox
8118 additional_allowed_paths: Additional paths to allow
8119 resolve_links: Whether to resolve symlinks
51508120
5151 file_handler = logging.FileHandler(log_file)
5152 file_handler.setLevel(level)
5153 file_handler.setFormatter(formatter)
5154 logger.addHandler(file_handler)
8121 Returns:
8122 str: Validated path
8123
8124 Raises:
8125 PathValidationError: If the path does not pass validation
8126 """
8127 # Check for empty path
8128 if not path:
8129 raise PathValidationError("Path cannot be empty")
51558130
5156 # Add any additional handlers
5157 if additional_handlers:
5158 for handler in additional_handlers:
5159 logger.addHandler(handler)
8131 # Normalize path
8132 normalized_path = normalize_path(path)
51608133
5161 # Add a filter for extra fields if provided
5162 if extra:
5163 class ExtraFilter(logging.Filter):
5164 def filter(self, record):
5165 for key, value in extra.items():
5166 setattr(record, key, value)
5167 return True
5168
5169 extra_filter = ExtraFilter()
5170 logger.addFilter(extra_filter)
8134 # Check for path traversal attempts
8135 if is_path_traversal_attempt(path):
8136 raise PathValidationError(f"Path '{path}' contains suspicious traversal patterns")
51718137
5172 return logger
8138 # Resolve symlinks if requested
8139 if resolve_links:
8140 normalized_path = resolve_symlinks(normalized_path)
8141
8142 # Check if path exists (for reading, the file should exist)
8143 if not os.path.exists(normalized_path):
8144 raise PathValidationError(f"Path '{path}' does not exist")
8145
8146 # Check if path is a file (for reading, it should be a file not a directory)
8147 if os.path.isdir(normalized_path):
8148 raise PathValidationError(f"Path '{path}' is a directory, not a file")
8149
8150 # Check if path is in sandbox or explicitly allowed
8151 if not is_path_in_sandbox(normalized_path, sandbox_path, additional_allowed_paths):
8152 raise PathValidationError(f"Path '{path}' is outside the allowed directories")
8153
8154 return normalized_path
51738155
51748156
5175def enable_debug_logging(logger_names: Optional[List[str]] = None) -> None:
8157def validate_path_for_writing(
8158 path: str,
8159 sandbox_path: Optional[str] = None,
8160 additional_allowed_paths: Optional[Set[str]] = None,
8161 additional_protected_paths: Optional[Set[str]] = None,
8162 resolve_links: bool = True
8163) -> str:
8164 """
8165 Validate a path for writing operations (e.g., write_file).
8166
8167 Args:
8168 path: File path to validate
8169 sandbox_path: Base path that defines the sandbox
8170 additional_allowed_paths: Additional paths to allow
8171 additional_protected_paths: Additional paths to protect
8172 resolve_links: Whether to resolve symlinks
8173
8174 Returns:
8175 str: Validated path
8176
8177 Raises:
8178 PathValidationError: If the path does not pass validation
51768179 """
5177 Enable debug logging for specified loggers or all loggers.
8180 # Check for empty path
8181 if not path:
8182 raise PathValidationError("Path cannot be empty")
51788183
5179 Args:
5180 logger_names: Optional list of logger names to enable debug logging for,
5181 if None, enables for root logger
5182 """
5183 if logger_names is None:
5184 # Enable debug logging for the root logger
5185 logging.getLogger().setLevel(logging.DEBUG)
5186 else:
5187 # Enable debug logging for specific loggers
5188 for name in logger_names:
5189 logging.getLogger(name).setLevel(logging.DEBUG)
8184 # Normalize path
8185 normalized_path = normalize_path(path)
8186
8187 # Check for path traversal attempts
8188 if is_path_traversal_attempt(path):
8189 raise PathValidationError(f"Path '{path}' contains suspicious traversal patterns")
8190
8191 # Resolve symlinks if requested
8192 if resolve_links:
8193 normalized_path = resolve_symlinks(normalized_path)
8194
8195 # Check if path is protected (for writing, protected paths are off-limits)
8196 if is_path_protected(normalized_path, additional_protected_paths, additional_allowed_paths):
8197 raise PathValidationError(f"Path '{path}' is in a protected area")
8198
8199 # Check if path is in sandbox or explicitly allowed
8200 if not is_path_in_sandbox(normalized_path, sandbox_path, additional_allowed_paths):
8201 raise PathValidationError(f"Path '{path}' is outside the allowed directories")
8202
8203 # Ensure the directory exists
8204 directory = os.path.dirname(normalized_path)
8205 if not os.path.exists(directory):
8206 try:
8207 # Create the directory if it doesn't exist
8208 os.makedirs(directory, exist_ok=True)
8209 except Exception as e:
8210 raise PathValidationError(f"Cannot create directory for path '{path}': {str(e)}")
8211
8212 return normalized_path
51908213
51918214
5192def disable_logging(logger_names: Optional[List[str]] = None) -> None:
8215def validate_path_for_searching(
8216 directory: str,
8217 pattern: str,
8218 sandbox_path: Optional[str] = None,
8219 additional_allowed_paths: Optional[Set[str]] = None,
8220 resolve_links: bool = True
8221) -> Tuple[str, str]:
51938222 """
5194 Disable logging for specified loggers or all loggers.
8223 Validate a directory and pattern for file searching operations (e.g., find_files).
51958224
51968225 Args:
5197 logger_names: Optional list of logger names to disable logging for,
5198 if None, disables for root logger
8226 directory: Directory to search in
8227 pattern: File pattern to search for
8228 sandbox_path: Base path that defines the sandbox
8229 additional_allowed_paths: Additional paths to allow
8230 resolve_links: Whether to resolve symlinks
8231
8232 Returns:
8233 tuple: (validated_directory, validated_pattern)
8234
8235 Raises:
8236 PathValidationError: If the directory or pattern does not pass validation
51998237 """
5200 if logger_names is None:
5201 # Disable logging for the root logger
5202 logging.getLogger().setLevel(logging.CRITICAL + 1)
5203 else:
5204 # Disable logging for specific loggers
5205 for name in logger_names:
5206 logging.getLogger(name).setLevel(logging.CRITICAL + 1)
8238 # Check for empty directory or pattern
8239 if not directory:
8240 raise PathValidationError("Directory cannot be empty")
8241
8242 if not pattern:
8243 raise PathValidationError("Pattern cannot be empty")
8244
8245 # Normalize directory path
8246 normalized_dir = normalize_path(directory)
8247
8248 # Check for path traversal attempts in directory
8249 if is_path_traversal_attempt(directory):
8250 raise PathValidationError(f"Directory '{directory}' contains suspicious traversal patterns")
8251
8252 # Resolve symlinks if requested
8253 if resolve_links:
8254 normalized_dir = resolve_symlinks(normalized_dir)
8255
8256 # Check if directory exists
8257 if not os.path.exists(normalized_dir):
8258 raise PathValidationError(f"Directory '{directory}' does not exist")
8259
8260 # Check if path is a directory
8261 if not os.path.isdir(normalized_dir):
8262 raise PathValidationError(f"Path '{directory}' is not a directory")
8263
8264 # Check if directory is in sandbox or explicitly allowed
8265 if not is_path_in_sandbox(normalized_dir, sandbox_path, additional_allowed_paths):
8266 raise PathValidationError(f"Directory '{directory}' is outside the allowed directories")
8267
8268 # Validate and sanitize the pattern
8269 # Remove any path components from the pattern
8270 sanitized_pattern = os.path.basename(pattern)
8271
8272 # Check for suspicious patterns in the pattern
8273 suspicious_pattern_chars = r'[&|;`$()<>]'
8274 if re.search(suspicious_pattern_chars, sanitized_pattern):
8275 raise PathValidationError(f"Pattern '{pattern}' contains suspicious characters")
8276
8277 return normalized_dir, sanitized_pattern
52078278
52088279
5209def get_ollamatools_logger(
5210 component: str,
5211 level: Union[int, str] = logging.INFO,
5212 log_file: Optional[str] = None
5213) -> logging.Logger:
8280def configure_path_sandbox(
8281 sandbox_path: Optional[str] = None,
8282 additional_allowed_paths: Optional[Set[str]] = None,
8283 additional_protected_paths: Optional[Set[str]] = None
8284) -> Dict[str, Union[str, Set[str]]]:
52148285 """
5215 Get a logger for a specific OllamaTools component.
8286 Configure the path sandbox with custom settings.
52168287
52178288 Args:
5218 component: Component name (e.g., 'agent', 'system_tools')
5219 level: Log level
5220 log_file: Optional log file path
8289 sandbox_path: Base path that defines the sandbox
8290 additional_allowed_paths: Additional paths to allow
8291 additional_protected_paths: Additional paths to protect
52218292
52228293 Returns:
5223 logging.Logger: Configured logger instance
8294 dict: Configuration dictionary with sandbox settings
52248295 """
5225 logger_name = f"ollamatools.{component}"
5226 return get_logger(logger_name, level, log_file)
8296 # Start with default values
8297 config = {
8298 'sandbox_path': sandbox_path or DEFAULT_SANDBOX_PATH,
8299 'allowed_paths': DEFAULT_ALLOWED_PATHS.copy(),
8300 'protected_paths': DEFAULT_PROTECTED_PATHS.copy()
8301 }
8302
8303 # Add additional allowed paths
8304 if additional_allowed_paths:
8305 config['allowed_paths'].update(additional_allowed_paths)
8306
8307 # Add additional protected paths
8308 if additional_protected_paths:
8309 config['protected_paths'].update(additional_protected_paths)
8310
8311 return config
52278312</file>
52288313
52298314<file path="ollamatools/security.py">
……@@ -5234,7 +8319,13 @@ This module provides security controls for executing functions,
52348319with a focus on a prompt-based permission system.
52358320"""
52368321from typing import Dict, Any, Optional, Tuple, Set
8322import logging
8323import re
8324
8325from ollamatools.errors import PermissionError, ERROR_CODES
52378326
8327# Set up logging
8328logger = logging.getLogger(__name__)
52388329
52398330class PermissionManager:
52408331 """
……@@ -5266,6 +8357,9 @@ class PermissionManager:
52668357 self.prompt_enabled = prompt_enabled
52678358 self.sudo_always_prompt = sudo_always_prompt
52688359 self.session_permissions = set() # Store allowed operations for the session
8360
8361 logger.debug("PermissionManager initialized with prompt_enabled=%s, sudo_always_prompt=%s",
8362 prompt_enabled, sudo_always_prompt)
52698363
52708364 def request_permission(
52718365 self,
……@@ -5286,6 +8380,9 @@ class PermissionManager:
52868380
52878381 Returns:
52888382 bool: True if permission granted, False otherwise
8383
8384 Raises:
8385 PermissionError: If there's an issue with the permission request
52898386 """
52908387 # Format the arguments as a readable string
52918388 args_str = ', '.join(f"{k}={v!r}" for k, v in args.items())
……@@ -5299,13 +8396,16 @@ class PermissionManager:
52998396 # Always prompt for sudo if sudo_always_prompt is True
53008397 if is_sudo and self.sudo_always_prompt:
53018398 should_prompt = True
8399 logger.info("Forcing permission prompt for sudo command")
53028400
53038401 # Check if this operation is already allowed for the session
53048402 if permission_key in self.session_permissions:
8403 logger.debug("Permission already granted for session: %s", permission_key)
53058404 return True
53068405
53078406 # If prompting is disabled and not overridden by sudo, grant permission
53088407 if not should_prompt:
8408 logger.debug("Permission prompts disabled, automatically granting permission for: %s", permission_key)
53098409 return True
53108410
53118411 # Display the permission prompt
……@@ -5326,35 +8426,62 @@ class PermissionManager:
53268426 print(" [D] Deny: Reject the operation")
53278427
53288428 while True:
5329 response = input("\nYour choice [A/S/M/D]: ").strip().upper()
5330
5331 if response == 'A': # Allow once
5332 return True
5333
5334 elif response == 'S': # Allow for session
5335 self.session_permissions.add(permission_key)
5336 return True
8429 try:
8430 response = input("\nYour choice [A/S/M/D]: ").strip().upper()
53378431
5338 elif response == 'M': # Modify
5339 modified_args = self.modify_command(function_name, args)
5340 if modified_args is not None:
5341 # Call request_permission again with modified arguments
5342 # but make sure we don't get into an infinite loop
5343 return self.request_permission(
5344 function_name,
5345 modified_args,
5346 self.detect_sudo(modified_args.get('command', '')) if 'command' in modified_args else is_sudo
5347 )
5348 else:
5349 print("Modification cancelled.")
8432 if response == 'A': # Allow once
8433 logger.info("Permission granted for: %s", permission_key)
8434 return True
8435
8436 elif response == 'S': # Allow for session
8437 self.session_permissions.add(permission_key)
8438 logger.info("Permission granted for session: %s", permission_key)
8439 return True
8440
8441 elif response == 'M': # Modify
8442 modified_args = self.modify_command(function_name, args)
8443 if modified_args is not None:
8444 # Generate a new permission key for the modified args
8445 modified_args_str = ', '.join(f"{k}={v!r}" for k, v in modified_args.items())
8446 modified_key = f"{function_name}:{modified_args_str}"
8447
8448 # Add to session permissions if it's a modification of an existing permission
8449 if permission_key in self.session_permissions:
8450 self.session_permissions.add(modified_key)
8451
8452 # Check if sudo status has changed
8453 new_is_sudo = False
8454 if function_name == "execute_command" and "command" in modified_args:
8455 new_is_sudo = self.detect_sudo(modified_args["command"])
8456
8457 # Call request_permission again with modified arguments
8458 logger.info("Requesting permission for modified operation: %s", modified_key)
8459 return self.request_permission(
8460 function_name,
8461 modified_args,
8462 is_sudo=new_is_sudo
8463 )
8464 else:
8465 logger.info("Modification cancelled for: %s", permission_key)
8466 print("Modification cancelled.")
8467 return False
8468
8469 elif response == 'D': # Deny
8470 logger.info("Permission denied for: %s", permission_key)
8471 print("Permission denied.")
53508472 return False
53518473
5352 elif response == 'D': # Deny
5353 print("Permission denied.")
5354 return False
5355
5356 else:
5357 print("Invalid choice. Please enter A, S, M, or D.")
8474 else:
8475 print("Invalid choice. Please enter A, S, M, or D.")
8476 except KeyboardInterrupt:
8477 logger.warning("Permission request interrupted")
8478 print("\nOperation cancelled.")
8479 raise PermissionError(
8480 "Permission request interrupted by user",
8481 function_name=function_name,
8482 args=args,
8483 code=ERROR_CODES.get('PERMISSION_PROMPT_CANCELLED')
8484 )
53588485
53598486 def modify_command(
53608487 self,
……@@ -5370,6 +8497,9 @@ class PermissionManager:
53708497
53718498 Returns:
53728499 Optional[Dict[str, Any]]: Modified arguments or None if cancelled
8500
8501 Raises:
8502 PermissionError: If there's an issue with the modification process
53738503 """
53748504 print("\n" + "="*60)
53758505 print("MODIFY OPERATION")
……@@ -5380,85 +8510,471 @@ class PermissionManager:
53808510 # Create a copy of the arguments that will be modified
53818511 modified_args = args.copy()
53828512
5383 # Present each argument for modification
5384 for key, value in args.items():
5385 while True:
5386 new_value = input(f" {key} [{value}] (Press Enter to keep, 'C' to cancel): ")
5387
5388 if not new_value: # Keep current value
5389 break
5390 elif new_value.upper() == 'C': # Cancel the modification
5391 return None
5392 else:
5393 # Try to convert to the same type as the original value
5394 try:
5395 # Handle different types appropriately
5396 if isinstance(value, bool):
5397 # Convert string to boolean
5398 if new_value.lower() in ('true', 'yes', '1', 'y'):
5399 modified_args[key] = True
5400 elif new_value.lower() in ('false', 'no', '0', 'n'):
5401 modified_args[key] = False
5402 else:
5403 print(f"Invalid boolean value. Use 'true', 'false', 'yes', 'no', '1', '0', 'y', or 'n'.")
5404 continue
5405 elif isinstance(value, int):
5406 modified_args[key] = int(new_value)
5407 elif isinstance(value, float):
5408 modified_args[key] = float(new_value)
5409 else:
5410 # For strings and other types, use the new value as is
5411 modified_args[key] = new_value
8513 try:
8514 # Present each argument for modification
8515 for key, value in args.items():
8516 while True:
8517 new_value = input(f" {key} [{value}] (Press Enter to keep, 'C' to cancel): ")
8518
8519 if not new_value: # Keep current value
54128520 break
5413 except ValueError:
5414 print(f"Invalid value for {key}. Expected type: {type(value).__name__}")
8521 elif new_value.upper() == 'C': # Cancel the modification
8522 return None
8523 else:
8524 # Try to convert to the same type as the original value
8525 try:
8526 # Handle different types appropriately
8527 if isinstance(value, bool):
8528 # Convert string to boolean
8529 if new_value.lower() in ('true', 'yes', '1', 'y'):
8530 modified_args[key] = True
8531 elif new_value.lower() in ('false', 'no', '0', 'n'):
8532 modified_args[key] = False
8533 else:
8534 print(f"Invalid boolean value. Use 'true', 'false', 'yes', 'no', '1', '0', 'y', or 'n'.")
8535 continue
8536 elif isinstance(value, int):
8537 modified_args[key] = int(new_value)
8538 elif isinstance(value, float):
8539 modified_args[key] = float(new_value)
8540 else:
8541 # For strings and other types, use the new value as is
8542 modified_args[key] = new_value
8543 break
8544 except ValueError:
8545 print(f"Invalid value for {key}. Expected type: {type(value).__name__}")
8546
8547 # Show the modified arguments for confirmation
8548 print("\nModified arguments:")
8549 for key, value in modified_args.items():
8550 print(f" {key}: {value}")
8551
8552 # Confirm the modifications
8553 confirm = input("\nConfirm these modifications? [Y/n]: ").strip().lower()
8554 if confirm in ('', 'y', 'yes'):
8555 logger.info("Modified args confirmed for function: %s", function_name)
8556 return modified_args
8557 else:
8558 logger.info("Modified args rejected for function: %s", function_name)
8559 return None
8560
8561 except KeyboardInterrupt:
8562 logger.warning("Command modification interrupted")
8563 print("\nModification cancelled.")
8564 return None
8565 except Exception as e:
8566 logger.error("Error modifying command: %s", str(e))
8567 raise PermissionError(
8568 f"Error modifying command: {str(e)}",
8569 function_name=function_name,
8570 args=args
8571 )
8572
8573 def detect_sudo(self, command: str) -> bool:
8574 """
8575 Detect if a command uses sudo.
8576
8577 Args:
8578 command: Command string
8579
8580 Returns:
8581 bool: True if command uses sudo
8582 """
8583 # Basic detection for sudo command
8584 if not command:
8585 return False
8586
8587 # Split the command to handle commands with arguments
8588 cmd_parts = command.strip().split()
8589 if not cmd_parts:
8590 return False
8591
8592 # Check if the command starts with sudo
8593 if cmd_parts[0].lower() == "sudo":
8594 logger.info("Sudo command detected: %s", command)
8595 return True
8596
8597 # More advanced detection - check for sudo within a command string
8598 sudo_pattern = r'(^|\s+)sudo(\s+|$)'
8599 if re.search(sudo_pattern, command):
8600 logger.info("Sudo pattern detected in command: %s", command)
8601 return True
54158602
5416 print("\nModified arguments:")
5417 for key, value in modified_args.items():
5418 print(f" {key}: {value}")
8603 return False
8604
8605 def clear_session_permissions(self):
8606 """
8607 Clear all session permissions.
8608 """
8609 num_permissions = len(self.session_permissions)
8610 self.session_permissions.clear()
8611 logger.info("Cleared %d session permissions", num_permissions)
8612
8613 def add_session_permission(self, function_name: str, args: Dict[str, Any]):
8614 """
8615 Add a specific operation to the session permissions.
8616
8617 Args:
8618 function_name: Name of the function
8619 args: Function arguments
8620 """
8621 args_str = ', '.join(f"{k}={v!r}" for k, v in args.items())
8622 permission_key = f"{function_name}:{args_str}"
8623 self.session_permissions.add(permission_key)
8624 logger.info("Added session permission: %s", permission_key)
8625
8626 def has_session_permission(self, function_name: str, args: Dict[str, Any]) -> bool:
8627 """
8628 Check if an operation has session permission.
8629
8630 Args:
8631 function_name: Name of the function
8632 args: Function arguments
8633
8634 Returns:
8635 bool: True if the operation has session permission
8636 """
8637 args_str = ', '.join(f"{k}={v!r}" for k, v in args.items())
8638 permission_key = f"{function_name}:{args_str}"
8639 return permission_key in self.session_permissions
8640</file>
8641
8642<file path="ollamatools/security1.py">
8643"""
8644Tests for the permission management system.
8645"""
8646import pytest
8647import sys
8648import os
8649
8650# Add the parent directory to the path so we can import the package
8651sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
8652
8653from ollamatools.security import PermissionManager
8654</file>
8655
8656<file path="ollamatools/url_validator.py">
8657"""
8658URL validation module for OllamaTools.
8659
8660This module provides functions for validating and sanitizing URLs
8661to prevent security vulnerabilities like SSRF attacks and URL injection.
8662"""
8663
8664import re
8665import ipaddress
8666from urllib.parse import urlparse, parse_qs, urlencode, urlunparse, unquote
8667import logging
8668from typing import Dict, Any, List, Set, Optional, Tuple, Union
8669
8670# Set up logging
8671logger = logging.getLogger(__name__)
8672
8673# Default allowed schemes
8674DEFAULT_ALLOWED_SCHEMES = {'http', 'https'}
8675
8676# Default blocklist of domains
8677DEFAULT_DOMAIN_BLOCKLIST = {
8678 'localhost', '127.0.0.1', '0.0.0.0', '::1', # Localhost variations
8679 '169.254.169.254', # AWS metadata endpoint
8680 '169.254.170.2', # AWS ECS metadata endpoint
8681 '169.254.170.23', # AWS Lambda metadata endpoint
8682 '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', # Private IP ranges
8683 'fc00::/7', # Unique local addresses (IPv6)
8684 'fd00::/8', # Unique local addresses (IPv6)
8685 'fe80::/10', # Link-local addresses (IPv6)
8686}
8687
8688# Default allowlist of domains (overrides blocklist)
8689DEFAULT_DOMAIN_ALLOWLIST = set()
8690
8691# Suspicious URL patterns
8692SUSPICIOUS_URL_PATTERNS = [
8693 r'(file|ftp|gopher|data|telnet|ldap|ssh)://', # Non-HTTP protocols
8694 r'@', # URL with credentials
8695 r'\.\.', # Directory traversal
8696 r'<|>|\{|\}|\[|\]|\|', # Special characters
8697]
8698
8699class URLValidationError(Exception):
8700 """Exception raised for URL validation errors."""
8701 pass
8702
8703
8704def normalize_url(url: str) -> str:
8705 """
8706 Normalize a URL to prevent bypass techniques.
8707
8708 Args:
8709 url: URL to normalize
8710
8711 Returns:
8712 str: Normalized URL
8713 """
8714 # Handle empty URLs
8715 if not url:
8716 raise URLValidationError("URL cannot be empty")
8717
8718 # Ensure URL is a string
8719 url = str(url).strip()
8720
8721 # Remove control characters and whitespace
8722 url = re.sub(r'[\x00-\x20\x7F]', '', url)
8723
8724 try:
8725 # Parse the URL
8726 parsed = urlparse(url)
8727
8728 # Check if scheme and netloc exist
8729 if not parsed.scheme or not parsed.netloc:
8730 raise URLValidationError(f"Invalid URL: {url} - missing scheme or host")
8731
8732 # Decode any URL-encoded components that might be hiding malicious content
8733 path = unquote(parsed.path)
8734
8735 # Normalize path to remove traversal sequences
8736 path = normalize_path(path)
8737
8738 # Parse and rebuild query parameters to normalize
8739 query_params = parse_qs(parsed.query, keep_blank_values=True)
8740 normalized_query = urlencode(query_params, doseq=True)
8741
8742 # Rebuild the normalized URL
8743 normalized_url = urlunparse((
8744 parsed.scheme.lower(), # Normalize scheme to lowercase
8745 parsed.netloc.lower(), # Normalize domain to lowercase
8746 path,
8747 parsed.params,
8748 normalized_query,
8749 '' # Remove fragment as it's client-side only
8750 ))
8751
8752 return normalized_url
8753
8754 except Exception as e:
8755 raise URLValidationError(f"URL normalization error: {str(e)}")
8756
8757
8758def normalize_path(path: str) -> str:
8759 """
8760 Normalize a URL path to remove traversal sequences and other issues.
8761
8762 Args:
8763 path: URL path to normalize
8764
8765 Returns:
8766 str: Normalized path
8767 """
8768 # Remove consecutive slashes
8769 path = re.sub(r'/+', '/', path)
8770
8771 # Remove trailing slash for consistency unless the path is just "/"
8772 if path != '/' and path.endswith('/'):
8773 path = path[:-1]
8774
8775 # Process path components to resolve traversal sequences
8776 # This is complex, so we'll do it manually
8777 components = path.split('/')
8778 normalized_components = []
8779
8780 for component in components:
8781 if component == '' or component == '.':
8782 # Skip empty components and current directory references
8783 continue
8784 elif component == '..':
8785 # Go up one directory level
8786 if normalized_components:
8787 normalized_components.pop()
8788 else:
8789 normalized_components.append(component)
8790
8791 # Rebuild the path
8792 normalized_path = '/' + '/'.join(normalized_components)
8793
8794 # Ensure path starts with "/"
8795 if not normalized_path.startswith('/'):
8796 normalized_path = '/' + normalized_path
8797
8798 return normalized_path
8799
8800
8801def is_ip_address_in_blocklist(ip_str: str, blocklist: Set[str]) -> bool:
8802 """
8803 Check if an IP address is in the blocklist.
8804
8805 Args:
8806 ip_str: IP address string
8807 blocklist: Set of blocked IP addresses or CIDR ranges
8808
8809 Returns:
8810 bool: True if the IP is blocked, False otherwise
8811 """
8812 try:
8813 # Parse the IP address
8814 ip = ipaddress.ip_address(ip_str)
8815
8816 # Check if the IP is directly in the blocklist
8817 if ip_str in blocklist:
8818 return True
8819
8820 # Check if the IP is in any of the blocklisted CIDR ranges
8821 for block in blocklist:
8822 if '/' in block: # It's a CIDR block
8823 try:
8824 network = ipaddress.ip_network(block, strict=False)
8825 if ip in network:
8826 return True
8827 except ValueError:
8828 continue
8829
8830 return False
8831 except ValueError:
8832 # Not a valid IP address
8833 return False
8834
8835
8836def validate_url(
8837 url: str,
8838 allowed_schemes: Optional[Set[str]] = None,
8839 domain_allowlist: Optional[Set[str]] = None,
8840 domain_blocklist: Optional[Set[str]] = None,
8841) -> str:
8842 """
8843 Validate a URL against security rules.
8844
8845 Args:
8846 url: URL to validate
8847 allowed_schemes: Optional set of allowed URL schemes (defaults to http, https)
8848 domain_allowlist: Optional set of allowed domains
8849 domain_blocklist: Optional set of blocked domains
8850
8851 Returns:
8852 str: Validated and normalized URL
8853
8854 Raises:
8855 URLValidationError: If the URL does not pass validation
8856 """
8857 # Use default values if not provided
8858 if allowed_schemes is None:
8859 allowed_schemes = DEFAULT_ALLOWED_SCHEMES
8860
8861 if domain_blocklist is None:
8862 domain_blocklist = DEFAULT_DOMAIN_BLOCKLIST
8863
8864 if domain_allowlist is None:
8865 domain_allowlist = DEFAULT_DOMAIN_ALLOWLIST
8866
8867 # Normalize the URL
8868 try:
8869 normalized_url = normalize_url(url)
8870 except URLValidationError as e:
8871 raise URLValidationError(f"URL normalization failed: {str(e)}")
8872
8873 # Parse the normalized URL
8874 parsed = urlparse(normalized_url)
8875
8876 # Check scheme
8877 if parsed.scheme not in allowed_schemes:
8878 raise URLValidationError(
8879 f"URL scheme '{parsed.scheme}' not allowed. Allowed schemes: {allowed_schemes}"
8880 )
8881
8882 # Extract hostname
8883 hostname = parsed.netloc.split(':')[0].lower()
8884
8885 # Check allowlist first (override blocklist)
8886 if domain_allowlist and hostname in domain_allowlist:
8887 return normalized_url
8888
8889 # Check hostname against blocklist
8890 if hostname in domain_blocklist:
8891 raise URLValidationError(f"Domain '{hostname}' is blocked")
8892
8893 # Check for IP address
8894 try:
8895 ip = ipaddress.ip_address(hostname)
8896
8897 # Check if IP is in blocklist
8898 if is_ip_address_in_blocklist(str(ip), domain_blocklist):
8899 raise URLValidationError(f"IP address '{ip}' is blocked")
8900
8901 # Check for localhost/internal IP
8902 if ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_link_local:
8903 raise URLValidationError(f"IP address '{ip}' is internal/reserved")
8904 except ValueError:
8905 # Not an IP address, continue with domain checks
8906 pass
8907
8908 # Check for suspicious patterns
8909 for pattern in SUSPICIOUS_URL_PATTERNS:
8910 if re.search(pattern, normalized_url):
8911 raise URLValidationError(f"URL contains suspicious pattern: {pattern}")
8912
8913 # Check port if specified
8914 if ':' in parsed.netloc:
8915 try:
8916 port = int(parsed.netloc.split(':')[1])
8917 if port < 1 or port > 65535:
8918 raise URLValidationError(f"Invalid port number: {port}")
8919 except (IndexError, ValueError):
8920 raise URLValidationError(f"Invalid port specification in URL: {parsed.netloc}")
8921
8922 return normalized_url
8923
8924
8925def configure_domain_lists(
8926 additional_allowlist: Optional[Set[str]] = None,
8927 additional_blocklist: Optional[Set[str]] = None
8928) -> Tuple[Set[str], Set[str]]:
8929 """
8930 Configure domain allowlist and blocklist by adding custom entries.
8931
8932 Args:
8933 additional_allowlist: Additional domains to allow
8934 additional_blocklist: Additional domains to block
54198935
5420 # Confirm the modifications
5421 confirm = input("\nConfirm these modifications? [Y/n]: ").strip().lower()
5422 if confirm in ('', 'y', 'yes'):
5423 return modified_args
5424 else:
5425 return None
8936 Returns:
8937 tuple: (configured_allowlist, configured_blocklist)
8938 """
8939 # Start with default lists
8940 allowlist = DEFAULT_DOMAIN_ALLOWLIST.copy()
8941 blocklist = DEFAULT_DOMAIN_BLOCKLIST.copy()
54268942
5427 def detect_sudo(self, command: str) -> bool:
5428 """
5429 Detect if a command uses sudo.
8943 # Add additional entries
8944 if additional_allowlist:
8945 allowlist.update(additional_allowlist)
54308946
5431 Args:
5432 command: Command string
5433
5434 Returns:
5435 bool: True if command uses sudo
5436 """
5437 # Basic detection for sudo command
5438 if not command:
5439 return False
5440
5441 # Split the command to handle commands with arguments
5442 cmd_parts = command.strip().split()
5443 if not cmd_parts:
5444 return False
5445
5446 # Check if the command starts with sudo
5447 return cmd_parts[0].lower() == "sudo"
5448</file>
5449
5450<file path="ollamatools/security1.py">
5451"""
5452Tests for the permission management system.
5453"""
5454import pytest
5455import sys
5456import os
8947 if additional_blocklist:
8948 blocklist.update(additional_blocklist)
8949
8950 return allowlist, blocklist
54578951
5458# Add the parent directory to the path so we can import the package
5459sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
54608952
5461from ollamatools.security import PermissionManager
8953def create_url_validator(
8954 allowed_schemes: Optional[Set[str]] = None,
8955 domain_allowlist: Optional[Set[str]] = None,
8956 domain_blocklist: Optional[Set[str]] = None,
8957) -> callable:
8958 """
8959 Create a URL validator function with the specified configuration.
8960
8961 Args:
8962 allowed_schemes: Set of allowed URL schemes
8963 domain_allowlist: Set of allowed domains
8964 domain_blocklist: Set of blocked domains
8965
8966 Returns:
8967 callable: URL validator function
8968 """
8969 def validate(url: str) -> str:
8970 return validate_url(
8971 url,
8972 allowed_schemes=allowed_schemes,
8973 domain_allowlist=domain_allowlist,
8974 domain_blocklist=domain_blocklist
8975 )
8976
8977 return validate
54628978</file>
54638979
54648980<file path="ollamatools.egg-info/dependency_links.txt">
……@@ -7812,115 +11328,488 @@ def test_post_request_with_data(mock_request):
781211328 mock_response.url = 'https://example.com/api'
781311329 mock_request.return_value = mock_response
781411330
7815 # Test data
7816 data = {'name': 'test', 'value': 42}
11331 # Test data
11332 data = {'name': 'test', 'value': 42}
11333
11334 # Call function
11335 result = fetch_url(
11336 'https://example.com/api',
11337 method='POST',
11338 data=data
11339 )
11340
11341 # Check result
11342 assert result['status'] == 201
11343 assert result['content'] == {'id': '123'}
11344
11345 # Check mock was called correctly
11346 mock_request.assert_called_once_with(
11347 method='POST',
11348 url='https://example.com/api',
11349 params=None,
11350 headers=None,
11351 json=data,
11352 timeout=30
11353 )
11354
11355# Test request with invalid URL
11356def test_invalid_url():
11357 with pytest.raises(ValueError):
11358 fetch_url('')
11359
11360 with pytest.raises(ValueError):
11361 fetch_url('not-a-url')
11362
11363# Test request with invalid method
11364def test_invalid_method():
11365 with pytest.raises(ValueError):
11366 fetch_url('https://example.com', method='INVALID')
11367
11368# Test request timeout
11369@patch('requests.request')
11370def test_request_timeout(mock_request):
11371 mock_request.side_effect = requests.exceptions.Timeout("Request timed out")
11372
11373 with pytest.raises(requests.exceptions.Timeout):
11374 fetch_url('https://example.com', timeout=5)
11375
11376# Test permission denied
11377def test_permission_denied():
11378 # Setup mock permission manager
11379 mock_permission_manager = MagicMock()
11380 mock_permission_manager.request_permission.return_value = False
11381
11382 with pytest.raises(PermissionError):
11383 fetch_url(
11384 'https://example.com',
11385 permission_manager=mock_permission_manager
11386 )
11387
11388 # Check permission was requested
11389 mock_permission_manager.request_permission.assert_called_once()
11390
11391# Test handling non-JSON content
11392@patch('requests.request')
11393def test_non_json_content(mock_request):
11394 # Setup mock response
11395 mock_response = MagicMock()
11396 mock_response.status_code = 200
11397 mock_response.headers = {'Content-Type': 'text/html'}
11398 mock_response.text = '<html><body>Hello World</body></html>'
11399 mock_response.url = 'https://example.com'
11400 mock_request.return_value = mock_response
11401
11402 # Call function
11403 result = fetch_url('https://example.com')
11404
11405 # Check result
11406 assert result['status'] == 200
11407 assert result['content'] == '<html><body>Hello World</body></html>'
11408</file>
11409
11410<file path="CHANGELOG.md">
11411# Changelog
11412
11413All notable changes to the OllamaTools project will be documented in this file.
11414
11415The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
11416and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
11417
11418## [0.2.0] - 2025-04-15
11419
11420### Added
11421- Comprehensive security enhancements:
11422 - Enhanced command validation with command allowlists
11423 - Path validation with sandbox restrictions
11424 - URL validation with domain allowlists/blocklists
11425 - Standardized error handling system
11426 - Improved permission management with session permissions
11427- New modules:
11428 - `command_validator.py` for robust command sanitization
11429 - `path_validator.py` for secure file path handling
11430 - `url_validator.py` for safe web request validation
11431 - `errors.py` for standardized error definitions
11432- New documentation:
11433 - `SECURITY.md` with detailed security guidelines
11434 - Security examples in `examples/security_example.py`
11435- Features:
11436 - Path sandboxing for file operations
11437 - URL normalization to prevent validation bypass
11438 - Secure environment variables for command execution
11439 - Domain blocklists to prevent SSRF attacks
11440 - Symbolic link resolution for file paths
11441 - Protection for system directories
11442 - Session-based permission system
11443
11444### Changed
11445- Updated `execute_command` to use new command validation
11446- Enhanced `read_file`, `write_file`, and `find_files` with path validation
11447- Improved `fetch_url` with URL validation
11448- Updated `PermissionManager` with better sudo detection
11449- Modified permission system to support session permissions
11450- Upgraded error reporting with standardized error classes
11451- Improved logging for security events
11452
11453### Security
11454- Fixed command injection vulnerabilities in `execute_command`
11455- Fixed path traversal vulnerabilities in file operations
11456- Fixed SSRF vulnerabilities in `fetch_url`
11457- Added protected path enforcement for system directories
11458- Improved sudo detection for privilege escalation protection
11459- Implemented comprehensive allowlist system for commands
11460- Enhanced permission prompt UI with more options
11461
11462## [0.1.0] - 2025-04-10
11463
11464### Added
11465- Initial release of OllamaTools
11466- Core OllamaAgent implementation
11467- Security system with permission management
11468- Configuration management
11469- System tools for command execution and file operations
11470- Web tools for HTTP requests, weather data, and web search
11471- Documentation tools for markdown generation and text processing
11472- Logging utilities
11473- Comprehensive test suite
11474- Example scripts for all components
11475- Performance optimization features
11476- Complete documentation
11477
11478### Security
11479- Implemented prompt-based permission system
11480- Special handling for sudo commands
11481- Command sanitization to prevent injection attacks
11482- Resource usage limitations
11483- Timeouts for long-running operations
11484</file>
11485
11486<file path="command_validator.py">
11487"""
11488Command validation module for OllamaTools.
11489
11490This module provides functions for validating and sanitizing shell commands
11491to prevent command injection and other security vulnerabilities.
11492"""
11493
11494import os
11495import re
11496import shlex
11497import logging
11498import pathlib
11499from typing import List, Optional, Dict, Set, Tuple, Union
11500
11501# Set up logging
11502logger = logging.getLogger(__name__)
11503
11504# Default allowlist of commands
11505DEFAULT_ALLOWLIST = {
11506 # File operations
11507 'ls', 'dir', 'find', 'grep', 'cat', 'less', 'head', 'tail',
11508 # Directory operations
11509 'pwd', 'cd', 'mkdir', 'rmdir',
11510 # System information
11511 'uname', 'hostname', 'whoami', 'id', 'ps', 'top', 'df', 'du', 'free',
11512 # Network utilities
11513 'ping', 'traceroute', 'curl', 'wget', 'netstat', 'dig', 'nslookup',
11514 # Text processing
11515 'echo', 'printf', 'sed', 'awk', 'cut', 'sort', 'uniq', 'wc',
11516 # File manipulation
11517 'cp', 'mv', 'rm', 'touch', 'chmod', 'chown', 'chgrp', 'ln',
11518 # Compression
11519 'tar', 'gzip', 'gunzip', 'zip', 'unzip',
11520 # Package management
11521 'apt-get', 'apt', 'yum', 'dnf', 'brew',
11522 # Process control
11523 'kill', 'pkill', 'pgrep',
11524 # Admin
11525 'sudo',
11526}
11527
11528# Commands that require special validation
11529HIGH_RISK_COMMANDS = {
11530 'rm', 'mv', 'cp', # File operations that can cause data loss
11531 'chmod', 'chown', 'chgrp', # Permission changes
11532 'sudo', # Privilege escalation
11533 'wget', 'curl', # Network operations
11534}
11535
11536# Paths that should be protected (not allowed to be modified)
11537PROTECTED_PATHS = {
11538 '/etc', '/bin', '/sbin', '/usr/bin', '/usr/sbin',
11539 '/boot', '/lib', '/lib64', '/usr/lib', '/usr/lib64',
11540 '/sys', '/proc', '/dev'
11541}
11542
11543# Regular expression patterns for dangerous argument patterns
11544DANGEROUS_ARG_PATTERNS = [
11545 r'[|;&`$()]', # Shell metacharacters
11546 r'>(>)?[^>]*', # Redirections
11547 r'<(<)?[^<]*', # Input redirections
11548 r'\$\([^)]*\)', # Command substitution
11549 r'`[^`]*`', # Backtick command substitution
11550 r'\$\{[^}]*\}', # Variable expansion
11551 r'\.\.', # Path traversal
11552 r'~[^/]*', # Home directory reference
11553 r'\\\\', # Double backslash
11554]
11555
11556class CommandValidationError(Exception):
11557 """Exception raised for command validation errors."""
11558 pass
11559
11560
11561def is_command_in_allowlist(command: str, custom_allowlist: Optional[Set[str]] = None) -> bool:
11562 """
11563 Check if a command is in the allowlist.
11564
11565 Args:
11566 command: Command to check
11567 custom_allowlist: Optional custom allowlist to use instead of the default
11568
11569 Returns:
11570 bool: True if command is in allowlist, False otherwise
11571 """
11572 allowlist = custom_allowlist if custom_allowlist is not None else DEFAULT_ALLOWLIST
11573
11574 # Strip path information for the check
11575 cmd_name = os.path.basename(command)
11576
11577 return cmd_name in allowlist
11578
11579
11580def validate_command_path(command: str) -> str:
11581 """
11582 Validate that a command's path is safe.
11583
11584 Args:
11585 command: Command to validate
11586
11587 Returns:
11588 str: Validated command path
11589
11590 Raises:
11591 CommandValidationError: If the command path is not valid
11592 """
11593 # Check for absolute path
11594 if os.path.isabs(command):
11595 cmd_path = pathlib.Path(command).resolve()
11596 cmd_name = os.path.basename(command)
11597
11598 # Ensure the command exists
11599 if not cmd_path.exists():
11600 raise CommandValidationError(f"Command '{command}' not found")
11601
11602 # Ensure the path is to a file not a directory
11603 if not cmd_path.is_file():
11604 raise CommandValidationError(f"Command path '{command}' is not a file")
11605
11606 # Check if command in a standard location
11607 std_paths = ['/bin', '/usr/bin', '/usr/local/bin', '/sbin', '/usr/sbin']
11608 parent_path = str(cmd_path.parent)
11609 if not any(parent_path.startswith(p) for p in std_paths):
11610 logger.warning(f"Command '{command}' is outside standard paths: {parent_path}")
11611
11612 # Additional permission check
11613 if not os.access(cmd_path, os.X_OK):
11614 raise CommandValidationError(f"Command '{command}' is not executable")
11615
11616 return str(cmd_path)
11617 else:
11618 # For non-absolute paths, we'll rely on the allowlist check
11619 return command
11620
11621
11622def validate_command_args(args: List[str], command: str) -> List[str]:
11623 """
11624 Validate command arguments for security issues.
11625
11626 Args:
11627 args: Command arguments to validate
11628 command: The command these args apply to (for context-specific checks)
11629
11630 Returns:
11631 List[str]: Validated arguments
11632
11633 Raises:
11634 CommandValidationError: If any arguments are not valid
11635 """
11636 validated_args = []
11637
11638 # Check if command is high risk
11639 is_high_risk = os.path.basename(command) in HIGH_RISK_COMMANDS
11640
11641 for arg in args:
11642 # Skip empty args
11643 if not arg:
11644 continue
11645
11646 # Check for dangerous patterns
11647 for pattern in DANGEROUS_ARG_PATTERNS:
11648 if re.search(pattern, arg):
11649 raise CommandValidationError(
11650 f"Argument '{arg}' contains dangerous pattern matching '{pattern}'"
11651 )
11652
11653 # Special checks for specific commands
11654 cmd_name = os.path.basename(command)
11655
11656 # Special checks for rm command
11657 if cmd_name == 'rm' and (arg == '-rf' or arg == '-fr' or arg == '-r' or arg == '-f'):
11658 # These arguments need special attention with protected paths
11659 if '-r' in arg or '-f' in arg:
11660 logger.warning(f"High-risk argument '{arg}' detected for '{command}'")
11661
11662 # Check file paths in arguments for protected paths
11663 if is_high_risk and arg.startswith('/'):
11664 arg_path = pathlib.Path(arg).resolve()
11665 arg_path_str = str(arg_path)
11666
11667 # Check against protected paths
11668 for protected in PROTECTED_PATHS:
11669 if arg_path_str.startswith(protected):
11670 raise CommandValidationError(
11671 f"Argument '{arg}' references protected path '{protected}'"
11672 )
11673
11674 validated_args.append(arg)
11675
11676 return validated_args
11677
11678
11679def sanitize_command(command: str) -> str:
11680 """
11681 Sanitize a command string to prevent command injection.
11682
11683 Args:
11684 command: Command string to sanitize
11685
11686 Returns:
11687 str: Sanitized command string
11688 """
11689 # Convert to string and trim
11690 if not isinstance(command, str):
11691 command = str(command)
11692
11693 sanitized = command.strip()
11694
11695 # Remove any null bytes or control characters
11696 sanitized = re.sub(r'[\x00-\x1F\x7F]', '', sanitized)
11697
11698 # Remove shell metacharacters
11699 dangerous_chars = r'[;&|`$()"\'\\]'
11700 sanitized = re.sub(dangerous_chars, '', sanitized)
11701
11702 return sanitized
11703
11704
11705def validate_and_sanitize_command(
11706 command: str,
11707 args: Optional[List[str]] = None,
11708 custom_allowlist: Optional[Set[str]] = None
11709) -> Tuple[str, List[str]]:
11710 """
11711 Validate and sanitize a command and its arguments.
11712
11713 Args:
11714 command: Command to validate and sanitize
11715 args: Optional command arguments
11716 custom_allowlist: Optional custom allowlist
11717
11718 Returns:
11719 tuple: (sanitized_command, sanitized_args)
11720
11721 Raises:
11722 CommandValidationError: If the command or arguments are not valid
11723 """
11724 # Handle None args
11725 if args is None:
11726 args = []
11727
11728 # Handle case where command contains both command and arguments as a string
11729 if isinstance(command, str) and ' ' in command and args is None:
11730 try:
11731 # Split using shlex to handle quotes properly
11732 command_parts = shlex.split(command)
11733 command = command_parts[0]
11734 args = command_parts[1:] if len(command_parts) > 1 else []
11735 logger.debug(f"Split command into cmd='{command}', args={args}")
11736 except ValueError as e:
11737 raise CommandValidationError(f"Invalid command syntax: {str(e)}")
781711738
7818 # Call function
7819 result = fetch_url(
7820 'https://example.com/api',
7821 method='POST',
7822 data=data
7823 )
11739 # Check if command is in allowlist
11740 if not is_command_in_allowlist(command, custom_allowlist):
11741 raise CommandValidationError(
11742 f"Command '{command}' not in allowed command list"
11743 )
782411744
7825 # Check result
7826 assert result['status'] == 201
7827 assert result['content'] == {'id': '123'}
11745 # Validate command path
11746 validated_command = validate_command_path(command)
782811747
7829 # Check mock was called correctly
7830 mock_request.assert_called_once_with(
7831 method='POST',
7832 url='https://example.com/api',
7833 params=None,
7834 headers=None,
7835 json=data,
7836 timeout=30
7837 )
7838
7839# Test request with invalid URL
7840def test_invalid_url():
7841 with pytest.raises(ValueError):
7842 fetch_url('')
11748 # Validate and sanitize args
11749 validated_args = validate_command_args(args, validated_command)
784311750
7844 with pytest.raises(ValueError):
7845 fetch_url('not-a-url')
11751 return validated_command, validated_args
784611752
7847# Test request with invalid method
7848def test_invalid_method():
7849 with pytest.raises(ValueError):
7850 fetch_url('https://example.com', method='INVALID')
785111753
7852# Test request timeout
7853@patch('requests.request')
7854def test_request_timeout(mock_request):
7855 mock_request.side_effect = requests.exceptions.Timeout("Request timed out")
11754def is_sudo_command(command: str, args: Optional[List[str]] = None) -> bool:
11755 """
11756 Check if a command uses sudo.
785611757
7857 with pytest.raises(requests.exceptions.Timeout):
7858 fetch_url('https://example.com', timeout=5)
7859
7860# Test permission denied
7861def test_permission_denied():
7862 # Setup mock permission manager
7863 mock_permission_manager = MagicMock()
7864 mock_permission_manager.request_permission.return_value = False
11758 Args:
11759 command: Command to check
11760 args: Optional command arguments
11761
11762 Returns:
11763 bool: True if the command uses sudo, False otherwise
11764 """
11765 if args is None:
11766 args = []
786511767
7866 with pytest.raises(PermissionError):
7867 fetch_url(
7868 'https://example.com',
7869 permission_manager=mock_permission_manager
7870 )
11768 # Check if command is sudo directly
11769 if os.path.basename(command) == 'sudo':
11770 return True
787111771
7872 # Check permission was requested
7873 mock_permission_manager.request_permission.assert_called_once()
7874
7875# Test handling non-JSON content
7876@patch('requests.request')
7877def test_non_json_content(mock_request):
7878 # Setup mock response
7879 mock_response = MagicMock()
7880 mock_response.status_code = 200
7881 mock_response.headers = {'Content-Type': 'text/html'}
7882 mock_response.text = '<html><body>Hello World</body></html>'
7883 mock_response.url = 'https://example.com'
7884 mock_request.return_value = mock_response
11772 # Check if command path contains sudo
11773 if 'sudo' in command.split(os.path.sep):
11774 return True
788511775
7886 # Call function
7887 result = fetch_url('https://example.com')
11776 # Check if sudo appears in the full command string
11777 full_command = f"{command} {' '.join(args)}"
11778 if full_command.lstrip().startswith('sudo '):
11779 return True
788811780
7889 # Check result
7890 assert result['status'] == 200
7891 assert result['content'] == '<html><body>Hello World</body></html>'
7892</file>
7893
7894<file path="CHANGELOG.md">
7895# Changelog
7896
7897All notable changes to the OllamaTools project will be documented in this file.
11781 return False
789811782
7899The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
7900and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7901
7902## [0.1.0] - 2025-04-10
7903
7904### Added
7905- Initial release of OllamaTools
7906- Core OllamaAgent implementation
7907- Security system with permission management
7908- Configuration management
7909- System tools for command execution and file operations
7910- Web tools for HTTP requests, weather data, and web search
7911- Documentation tools for markdown generation and text processing
7912- Logging utilities
7913- Comprehensive test suite
7914- Example scripts for all components
7915- Performance optimization features
7916- Complete documentation
791711783
7918### Security
7919- Implemented prompt-based permission system
7920- Special handling for sudo commands
7921- Command sanitization to prevent injection attacks
7922- Resource usage limitations
7923- Timeouts for long-running operations
11784def get_secure_env() -> Dict[str, str]:
11785 """
11786 Get a sanitized environment dictionary for command execution.
11787
11788 Returns:
11789 dict: Sanitized environment variables
11790 """
11791 # Start with a minimal environment
11792 secure_env = {
11793 'PATH': '/usr/local/bin:/usr/bin:/bin',
11794 'LANG': os.environ.get('LANG', 'C.UTF-8'),
11795 'LC_ALL': os.environ.get('LC_ALL', 'C.UTF-8'),
11796 'TERM': os.environ.get('TERM', 'xterm'),
11797 'HOME': os.environ.get('HOME', '/tmp'),
11798 'USER': os.environ.get('USER', 'nobody'),
11799 }
11800
11801 # Copy allowed environment variables
11802 allowed_vars = {
11803 'DISPLAY', 'EDITOR', 'PAGER', 'TZ',
11804 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
11805 'http_proxy', 'https_proxy', 'no_proxy'
11806 }
11807
11808 for var in allowed_vars:
11809 if var in os.environ:
11810 secure_env[var] = os.environ[var]
11811
11812 return secure_env
792411813</file>
792511814
792611815<file path="COMPLETION_REPORT.md">
……@@ -8232,30 +12121,452 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
823212121copies of the Software, and to permit persons to whom the Software is
823312122furnished to do so, subject to the following conditions:
823412123
8235The above copyright notice and this permission notice shall be included in all
8236copies or substantial portions of the Software.
12124The above copyright notice and this permission notice shall be included in all
12125copies or substantial portions of the Software.
12126
12127THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12128IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
12129FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
12130AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
12131LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
12132OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
12133SOFTWARE.
12134</file>
12135
12136<file path="make_executable.sh">
12137#!/bin/bash
12138# Make scripts executable
12139chmod +x mkdir_command.sh
12140chmod +x scripts/install_and_verify.sh
12141chmod +x examples/final_demo.py
12142</file>
12143
12144<file path="mkdir_command.sh">
12145#!/bin/bash
12146# Create scripts directory if it doesn't exist
12147mkdir -p scripts
12148</file>
12149
12150<file path="path_validator.py">
12151"""
12152Path validation module for OllamaTools.
12153
12154This module provides functions for validating and sanitizing file paths
12155to prevent path traversal attacks and other file path-related security issues.
12156"""
12157
12158import os
12159import re
12160import pathlib
12161from typing import Optional, List, Set, Dict, Any, Union, Tuple
12162import logging
12163
12164# Set up logging
12165logger = logging.getLogger(__name__)
12166
12167# Default protected paths - should not be modified
12168DEFAULT_PROTECTED_PATHS = {
12169 '/etc',
12170 '/bin',
12171 '/sbin',
12172 '/usr/bin',
12173 '/usr/sbin',
12174 '/usr/lib',
12175 '/usr/lib64',
12176 '/boot',
12177 '/dev',
12178 '/proc',
12179 '/sys',
12180 '/var/lib',
12181 '/var/log',
12182 '/var/run',
12183 '/var/lock',
12184}
12185
12186# Default paths to allow even if they're in protected paths
12187DEFAULT_ALLOWED_PATHS = {
12188 '/etc/hosts', # commonly read for network configuration
12189 '/etc/hostname', # commonly read for system identification
12190 '/etc/passwd', # commonly read for user information (non-sensitive part)
12191}
12192
12193# Default sandbox path - where operations are restricted to
12194DEFAULT_SANDBOX_PATH = os.path.expanduser('~')
12195
12196class PathValidationError(Exception):
12197 """Exception raised for path validation errors."""
12198 pass
12199
12200
12201def normalize_path(path: str) -> str:
12202 """
12203 Normalize a file path to resolve symlinks, relative paths, etc.
12204
12205 Args:
12206 path: File path to normalize
12207
12208 Returns:
12209 str: Normalized absolute path
12210 """
12211 # Convert to string if it's not already
12212 if not isinstance(path, str):
12213 path = str(path)
12214
12215 # Remove any null bytes
12216 path = path.replace('\0', '')
12217
12218 # Trim whitespace
12219 path = path.strip()
12220
12221 # Create an absolute path
12222 abs_path = os.path.abspath(path)
12223
12224 # Normalize the path (resolves ., .., symlinks)
12225 try:
12226 norm_path = os.path.normpath(abs_path)
12227 return norm_path
12228 except Exception as e:
12229 raise PathValidationError(f"Path normalization error: {str(e)}")
12230
12231
12232def resolve_symlinks(path: str) -> str:
12233 """
12234 Resolve symlinks in a path.
12235
12236 Args:
12237 path: File path that might contain symlinks
12238
12239 Returns:
12240 str: Path with all symlinks resolved
12241 """
12242 try:
12243 # Use pathlib to resolve symlinks
12244 resolved_path = str(pathlib.Path(path).resolve())
12245 return resolved_path
12246 except Exception as e:
12247 raise PathValidationError(f"Failed to resolve symlinks in path '{path}': {str(e)}")
12248
12249
12250def is_path_traversal_attempt(path: str) -> bool:
12251 """
12252 Check if a path contains traversal patterns that might be used in attacks.
12253
12254 Args:
12255 path: File path to check
12256
12257 Returns:
12258 bool: True if the path contains suspicious patterns, False otherwise
12259 """
12260 # Normalize for easy pattern matching
12261 normalized = os.path.normpath(path)
12262
12263 # Check for common traversal patterns
12264 suspicious_patterns = [
12265 r'\.\./', # Parent directory references
12266 r'/\.\./', # Parent directory references
12267 r'/\.\.$', # Path ending with parent reference
12268 r'^\.\./.*', # Path starting with parent reference
12269 r'\\\\', # Double backslash (Windows)
12270 r'\.\\', # Current directory backslash (Windows)
12271 r'//', # Double forward slash
12272 r'\0', # Null byte
12273 r'%00', # URL-encoded null byte
12274 r'%2e%2e%2f', # URL-encoded ../
12275 r'%2e%2e/', # URL-encoded ../
12276 r'\$\{.*?\}', # Shell variable expansion
12277 r'`.*?`', # Command substitution
12278 ]
12279
12280 for pattern in suspicious_patterns:
12281 if re.search(pattern, path):
12282 return True
12283
12284 return False
12285
12286
12287def is_path_in_sandbox(
12288 path: str,
12289 sandbox_path: Optional[str] = None,
12290 additional_allowed_paths: Optional[Set[str]] = None
12291) -> bool:
12292 """
12293 Check if a path is within the sandbox path or explicitly allowed.
12294
12295 Args:
12296 path: File path to check
12297 sandbox_path: Base path that defines the sandbox (default is user's home)
12298 additional_allowed_paths: Additional paths to allow
12299
12300 Returns:
12301 bool: True if the path is in the sandbox or allowed, False otherwise
12302 """
12303 # Resolve the sandbox path
12304 if sandbox_path is None:
12305 sandbox_path = DEFAULT_SANDBOX_PATH
12306
12307 # Normalize all paths for comparison
12308 normalized_path = normalize_path(path)
12309 normalized_sandbox = normalize_path(sandbox_path)
12310
12311 # Check if the path is explicitly allowed
12312 allowed_paths = DEFAULT_ALLOWED_PATHS.copy()
12313 if additional_allowed_paths:
12314 allowed_paths.update(additional_allowed_paths)
12315
12316 if normalized_path in allowed_paths:
12317 return True
12318
12319 # Check if the path is a subdirectory of the sandbox
12320 return normalized_path.startswith(normalized_sandbox)
12321
12322
12323def is_path_protected(
12324 path: str,
12325 additional_protected_paths: Optional[Set[str]] = None,
12326 allowed_paths: Optional[Set[str]] = None
12327) -> bool:
12328 """
12329 Check if a path is in a protected area that should not be modified.
12330
12331 Args:
12332 path: File path to check
12333 additional_protected_paths: Additional paths to protect
12334 allowed_paths: Paths explicitly allowed even if in protected areas
12335
12336 Returns:
12337 bool: True if the path is protected, False otherwise
12338 """
12339 # Normalize the path
12340 normalized_path = normalize_path(path)
12341
12342 # Combine default and additional protected paths
12343 protected_paths = DEFAULT_PROTECTED_PATHS.copy()
12344 if additional_protected_paths:
12345 protected_paths.update(additional_protected_paths)
12346
12347 # Combine default and additional allowed paths
12348 all_allowed_paths = DEFAULT_ALLOWED_PATHS.copy()
12349 if allowed_paths:
12350 all_allowed_paths.update(allowed_paths)
12351
12352 # First check if the path is explicitly allowed
12353 if normalized_path in all_allowed_paths:
12354 return False
12355
12356 # Then check if the path is in a protected area
12357 for protected in protected_paths:
12358 if normalized_path == protected or normalized_path.startswith(protected + os.path.sep):
12359 return True
12360
12361 return False
12362
12363
12364def validate_path_for_reading(
12365 path: str,
12366 sandbox_path: Optional[str] = None,
12367 additional_allowed_paths: Optional[Set[str]] = None,
12368 resolve_links: bool = True
12369) -> str:
12370 """
12371 Validate a path for reading operations (e.g., read_file).
12372
12373 Args:
12374 path: File path to validate
12375 sandbox_path: Base path that defines the sandbox
12376 additional_allowed_paths: Additional paths to allow
12377 resolve_links: Whether to resolve symlinks
12378
12379 Returns:
12380 str: Validated path
12381
12382 Raises:
12383 PathValidationError: If the path does not pass validation
12384 """
12385 # Check for empty path
12386 if not path:
12387 raise PathValidationError("Path cannot be empty")
12388
12389 # Normalize path
12390 normalized_path = normalize_path(path)
12391
12392 # Check for path traversal attempts
12393 if is_path_traversal_attempt(path):
12394 raise PathValidationError(f"Path '{path}' contains suspicious traversal patterns")
12395
12396 # Resolve symlinks if requested
12397 if resolve_links:
12398 normalized_path = resolve_symlinks(normalized_path)
12399
12400 # Check if path exists (for reading, the file should exist)
12401 if not os.path.exists(normalized_path):
12402 raise PathValidationError(f"Path '{path}' does not exist")
12403
12404 # Check if path is a file (for reading, it should be a file not a directory)
12405 if os.path.isdir(normalized_path):
12406 raise PathValidationError(f"Path '{path}' is a directory, not a file")
12407
12408 # Check if path is in sandbox or explicitly allowed
12409 if not is_path_in_sandbox(normalized_path, sandbox_path, additional_allowed_paths):
12410 raise PathValidationError(f"Path '{path}' is outside the allowed directories")
12411
12412 return normalized_path
12413
12414
12415def validate_path_for_writing(
12416 path: str,
12417 sandbox_path: Optional[str] = None,
12418 additional_allowed_paths: Optional[Set[str]] = None,
12419 additional_protected_paths: Optional[Set[str]] = None,
12420 resolve_links: bool = True
12421) -> str:
12422 """
12423 Validate a path for writing operations (e.g., write_file).
12424
12425 Args:
12426 path: File path to validate
12427 sandbox_path: Base path that defines the sandbox
12428 additional_allowed_paths: Additional paths to allow
12429 additional_protected_paths: Additional paths to protect
12430 resolve_links: Whether to resolve symlinks
12431
12432 Returns:
12433 str: Validated path
12434
12435 Raises:
12436 PathValidationError: If the path does not pass validation
12437 """
12438 # Check for empty path
12439 if not path:
12440 raise PathValidationError("Path cannot be empty")
12441
12442 # Normalize path
12443 normalized_path = normalize_path(path)
12444
12445 # Check for path traversal attempts
12446 if is_path_traversal_attempt(path):
12447 raise PathValidationError(f"Path '{path}' contains suspicious traversal patterns")
12448
12449 # Resolve symlinks if requested
12450 if resolve_links:
12451 normalized_path = resolve_symlinks(normalized_path)
12452
12453 # Check if path is protected (for writing, protected paths are off-limits)
12454 if is_path_protected(normalized_path, additional_protected_paths, additional_allowed_paths):
12455 raise PathValidationError(f"Path '{path}' is in a protected area")
12456
12457 # Check if path is in sandbox or explicitly allowed
12458 if not is_path_in_sandbox(normalized_path, sandbox_path, additional_allowed_paths):
12459 raise PathValidationError(f"Path '{path}' is outside the allowed directories")
12460
12461 # Ensure the directory exists
12462 directory = os.path.dirname(normalized_path)
12463 if not os.path.exists(directory):
12464 try:
12465 # Create the directory if it doesn't exist
12466 os.makedirs(directory, exist_ok=True)
12467 except Exception as e:
12468 raise PathValidationError(f"Cannot create directory for path '{path}': {str(e)}")
12469
12470 return normalized_path
12471
823712472
8238THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
8239IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
8240FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
8241AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
8242LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
8243OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
8244SOFTWARE.
8245</file>
12473def validate_path_for_searching(
12474 directory: str,
12475 pattern: str,
12476 sandbox_path: Optional[str] = None,
12477 additional_allowed_paths: Optional[Set[str]] = None,
12478 resolve_links: bool = True
12479) -> Tuple[str, str]:
12480 """
12481 Validate a directory and pattern for file searching operations (e.g., find_files).
12482
12483 Args:
12484 directory: Directory to search in
12485 pattern: File pattern to search for
12486 sandbox_path: Base path that defines the sandbox
12487 additional_allowed_paths: Additional paths to allow
12488 resolve_links: Whether to resolve symlinks
12489
12490 Returns:
12491 tuple: (validated_directory, validated_pattern)
12492
12493 Raises:
12494 PathValidationError: If the directory or pattern does not pass validation
12495 """
12496 # Check for empty directory or pattern
12497 if not directory:
12498 raise PathValidationError("Directory cannot be empty")
12499
12500 if not pattern:
12501 raise PathValidationError("Pattern cannot be empty")
12502
12503 # Normalize directory path
12504 normalized_dir = normalize_path(directory)
12505
12506 # Check for path traversal attempts in directory
12507 if is_path_traversal_attempt(directory):
12508 raise PathValidationError(f"Directory '{directory}' contains suspicious traversal patterns")
12509
12510 # Resolve symlinks if requested
12511 if resolve_links:
12512 normalized_dir = resolve_symlinks(normalized_dir)
12513
12514 # Check if directory exists
12515 if not os.path.exists(normalized_dir):
12516 raise PathValidationError(f"Directory '{directory}' does not exist")
12517
12518 # Check if path is a directory
12519 if not os.path.isdir(normalized_dir):
12520 raise PathValidationError(f"Path '{directory}' is not a directory")
12521
12522 # Check if directory is in sandbox or explicitly allowed
12523 if not is_path_in_sandbox(normalized_dir, sandbox_path, additional_allowed_paths):
12524 raise PathValidationError(f"Directory '{directory}' is outside the allowed directories")
12525
12526 # Validate and sanitize the pattern
12527 # Remove any path components from the pattern
12528 sanitized_pattern = os.path.basename(pattern)
12529
12530 # Check for suspicious patterns in the pattern
12531 suspicious_pattern_chars = r'[&|;`$()<>]'
12532 if re.search(suspicious_pattern_chars, sanitized_pattern):
12533 raise PathValidationError(f"Pattern '{pattern}' contains suspicious characters")
12534
12535 return normalized_dir, sanitized_pattern
824612536
8247<file path="make_executable.sh">
8248#!/bin/bash
8249# Make scripts executable
8250chmod +x mkdir_command.sh
8251chmod +x scripts/install_and_verify.sh
8252chmod +x examples/final_demo.py
8253</file>
825412537
8255<file path="mkdir_command.sh">
8256#!/bin/bash
8257# Create scripts directory if it doesn't exist
8258mkdir -p scripts
12538def configure_path_sandbox(
12539 sandbox_path: Optional[str] = None,
12540 additional_allowed_paths: Optional[Set[str]] = None,
12541 additional_protected_paths: Optional[Set[str]] = None
12542) -> Dict[str, Union[str, Set[str]]]:
12543 """
12544 Configure the path sandbox with custom settings.
12545
12546 Args:
12547 sandbox_path: Base path that defines the sandbox
12548 additional_allowed_paths: Additional paths to allow
12549 additional_protected_paths: Additional paths to protect
12550
12551 Returns:
12552 dict: Configuration dictionary with sandbox settings
12553 """
12554 # Start with default values
12555 config = {
12556 'sandbox_path': sandbox_path or DEFAULT_SANDBOX_PATH,
12557 'allowed_paths': DEFAULT_ALLOWED_PATHS.copy(),
12558 'protected_paths': DEFAULT_PROTECTED_PATHS.copy()
12559 }
12560
12561 # Add additional allowed paths
12562 if additional_allowed_paths:
12563 config['allowed_paths'].update(additional_allowed_paths)
12564
12565 # Add additional protected paths
12566 if additional_protected_paths:
12567 config['protected_paths'].update(additional_protected_paths)
12568
12569 return config
825912570</file>
826012571
826112572<file path="pytest.ini">
……@@ -8364,6 +12675,237 @@ doc = generate_markdown(content, title,
836412675```
836512676</file>
836612677
12678<file path="SECURITY.md">
12679# OllamaTools Security Documentation
12680
12681This document provides an overview of the security features and best practices for the OllamaTools library.
12682
12683## Security Architecture
12684
12685OllamaTools implements multiple layers of security protections:
12686
126871. **Permission System**: A comprehensive permission system that prompts for user approval before executing potentially sensitive operations
126882. **Command Validation**: Robust validation and sanitization of system commands to prevent command injection attacks
126893. **Path Validation**: Secure file path handling to prevent path traversal attacks and unauthorized file access
126904. **URL Validation**: URL validation and sanitization to prevent SSRF and other web-related vulnerabilities
126915. **Standardized Error Handling**: Consistent error handling with detailed diagnostics for security issues
12692
12693## Permission System
12694
12695The permission system is the first line of defense:
12696
12697- **Interactive Prompts**: Users are prompted to approve sensitive operations before execution
12698- **Session Permissions**: Allows permissions to be granted for the duration of a session
12699- **Operation Modification**: Ability to modify operations before execution
12700- **Sudo Detection**: Special handling for sudo commands that elevate privileges
12701
12702### Permission Options
12703
12704When prompted for permission, users can:
12705
12706- **Allow**: Grant permission for this operation only
12707- **Allow for Session**: Grant permission for similar operations in this session
12708- **Modify**: Modify the operation before execution
12709- **Deny**: Reject the operation
12710
12711Example:
12712```
12713================
12714PERMISSION REQUEST
12715================
12716Function: execute_command
12717Arguments: command='ls', args=['-la']
12718
12719Options:
12720 [A] Allow: Grant permission for this operation
12721 [S] Allow for Session: Grant permission for similar operations in this session
12722 [M] Modify: Modify the operation before execution
12723 [D] Deny: Reject the operation
12724
12725Your choice [A/S/M/D]:
12726```
12727
12728## Command Validation
12729
12730The command validation module provides the following security features:
12731
12732- **Command Allowlist**: Only explicitly allowed commands can be executed
12733- **Argument Validation**: Arguments are checked for dangerous patterns
12734- **Shell Injection Prevention**: Shell metacharacters are properly handled
12735- **Secure Environment**: Commands are executed in a sanitized environment
12736- **Timeout Controls**: All commands have configurable timeouts
12737
12738### Default Command Allowlist
12739
12740The default allowlist includes:
12741
12742- Common system utilities (`ls`, `cat`, `pwd`, etc.)
12743- File operations (`cp`, `mv`, `rm`, etc.)
12744- Text processing tools (`grep`, `sed`, `awk`, etc.)
12745- Network utilities (`ping`, `curl`, etc.)
12746
12747Custom allowlists can be defined if needed.
12748
12749## Path Validation
12750
12751File path validation provides:
12752
12753- **Path Sandbox**: File operations are restricted to specific directories
12754- **Path Traversal Prevention**: Prevents directory traversal attacks
12755- **Symlink Handling**: Safe handling of symbolic links
12756- **Protected Path Enforcement**: System directories are protected from modification
12757- **Write Protection**: Additional validation for write operations to prevent data loss
12758
12759### Path Sandbox Configuration
12760
12761By default, file operations are restricted to the user's home directory. This can be customized:
12762
12763```python
12764from ollamatools.path_validator import configure_path_sandbox
12765
12766# Configure custom sandbox
12767sandbox_config = configure_path_sandbox(
12768 sandbox_path="/custom/sandbox/path",
12769 additional_allowed_paths={"/path/to/allow"},
12770 additional_protected_paths={"/path/to/protect"}
12771)
12772```
12773
12774## URL Validation
12775
12776URL validation provides:
12777
12778- **Scheme Validation**: Only http and https schemes are allowed by default
12779- **Domain Blocklist**: Blocks localhost, internal IPs, and other dangerous domains
12780- **Domain Allowlist**: Allows explicitly approved domains
12781- **URL Normalization**: Normalizes URLs to prevent validation bypass
12782- **Parameter Validation**: Validates URL parameters for security issues
12783
12784### URL Validation Configuration
12785
12786```python
12787from ollamatools.url_validator import configure_domain_lists
12788
12789# Configure custom domain lists
12790allowlist, blocklist = configure_domain_lists(
12791 additional_allowlist={"trusted-domain.com"},
12792 additional_blocklist={"dangerous-domain.com"}
12793)
12794```
12795
12796## Error Handling
12797
12798The error handling system provides:
12799
12800- **Standardized Error Classes**: Consistent hierarchy of error classes
12801- **Error Codes**: Numeric error codes for each type of error
12802- **Detailed Error Information**: Comprehensive error details for debugging
12803- **Security-Focused Logging**: Appropriate logging of security issues
12804
12805### Error Types
12806
12807- `OllamaToolsError`: Base exception for all errors
12808- `CommandError`: For command execution issues
12809- `FileError`: For file operation issues
12810- `WebError`: For web request issues
12811- `PermissionError`: For permission issues
12812- `ApiError`: For API-related issues
12813- `ConfigurationError`: For configuration issues
12814- `TimeoutError`: For timeout issues
12815- `OllamaClientError`: For Ollama client issues
12816
12817## Best Practices
12818
12819### Command Execution
12820
128211. **Minimize Command Usage**: Only use execute_command when absolutely necessary
128222. **Use Specific Commands**: Be as specific as possible when executing commands
128233. **Validate User Input**: Never pass unvalidated user input to commands
128244. **Handle Errors**: Always check command results for errors
128255. **Set Appropriate Timeouts**: Use timeouts appropriate to the expected runtime
12826
12827### File Operations
12828
128291. **Read-Only When Possible**: Use read operations instead of write when possible
128302. **Be Specific**: Use specific file paths rather than patterns when possible
128313. **Validate File Contents**: Validate file contents before writing
128324. **Use Path Validation**: Always use the path validation functions
128335. **Check For Errors**: Handle file operation errors gracefully
12834
12835### Web Requests
12836
128371. **Use Allowlists**: Define domain allowlists for web requests
128382. **Set Timeouts**: Always set appropriate timeouts for web requests
128393. **Validate Responses**: Check response status and content
128404. **Limit Request Rate**: Avoid making too many requests in a short time
128415. **Handle Errors**: Properly handle web request errors
12842
12843## Configuration
12844
12845Security features can be configured at different levels:
12846
12847### Global Configuration
12848
12849```python
12850from ollamatools import Config
12851
12852config = Config(
12853 permission_prompts=True, # Enable permission prompts
12854 sudo_always_prompt=True, # Always prompt for sudo commands
12855 timeout_seconds=30, # Default timeout for operations
12856 max_file_size=1024 * 1024 # 1 MB max file size
12857)
12858```
12859
12860### Function-Level Configuration
12861
12862```python
12863from ollamatools.tools.system_tools import execute_command
12864
12865# Custom allowlist for this operation
12866custom_allowlist = {"ls", "cat", "grep"}
12867
12868result = execute_command(
12869 command="ls",
12870 args=["-la"],
12871 timeout=10,
12872 custom_allowlist=custom_allowlist
12873)
12874```
12875
12876## Logging and Monitoring
12877
12878OllamaTools includes comprehensive logging for security events:
12879
12880- Permission requests and decisions
12881- Command execution attempts
12882- File operation access
12883- Web request details
12884- Security validation failures
12885
12886Enable debug logging for more detailed security information:
12887
12888```python
12889from ollamatools import enable_debug_logging
12890
12891enable_debug_logging("ollamatools.security")
12892```
12893
12894## Security Updates
12895
12896The security enhancements in this version include:
12897
128981. Enhanced command validation and sanitization
128992. Improved URL validation to prevent SSRF attacks
129003. New path validation module for file operations
129014. Standardized error handling
129025. Better sudo command detection
129036. Path sandboxing for file operations
129047. Improved permission request workflow
12905
12906Future updates will continue to improve security features based on evolving best practices.
12907</file>
12908
836712909<file path="setup.py">
836812910"""
836912911Setup script for the OllamaTools package.
……@@ -8525,4 +13067,328 @@ if __name__ == "__main__":
852513067tree -L 3 # Limit depth to 3 levels
852613068</file>
852713069
13070<file path="url_validator.py">
13071"""
13072URL validation module for OllamaTools.
13073
13074This module provides functions for validating and sanitizing URLs
13075to prevent security vulnerabilities like SSRF attacks and URL injection.
13076"""
13077
13078import re
13079import ipaddress
13080from urllib.parse import urlparse, parse_qs, urlencode, urlunparse, unquote
13081import logging
13082from typing import Dict, Any, List, Set, Optional, Tuple, Union
13083
13084# Set up logging
13085logger = logging.getLogger(__name__)
13086
13087# Default allowed schemes
13088DEFAULT_ALLOWED_SCHEMES = {'http', 'https'}
13089
13090# Default blocklist of domains
13091DEFAULT_DOMAIN_BLOCKLIST = {
13092 'localhost', '127.0.0.1', '0.0.0.0', '::1', # Localhost variations
13093 '169.254.169.254', # AWS metadata endpoint
13094 '169.254.170.2', # AWS ECS metadata endpoint
13095 '169.254.170.23', # AWS Lambda metadata endpoint
13096 '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', # Private IP ranges
13097 'fc00::/7', # Unique local addresses (IPv6)
13098 'fd00::/8', # Unique local addresses (IPv6)
13099 'fe80::/10', # Link-local addresses (IPv6)
13100}
13101
13102# Default allowlist of domains (overrides blocklist)
13103DEFAULT_DOMAIN_ALLOWLIST = set()
13104
13105# Suspicious URL patterns
13106SUSPICIOUS_URL_PATTERNS = [
13107 r'(file|ftp|gopher|data|telnet|ldap|ssh)://', # Non-HTTP protocols
13108 r'@', # URL with credentials
13109 r'\.\.', # Directory traversal
13110 r'<|>|\{|\}|\[|\]|\|', # Special characters
13111]
13112
13113class URLValidationError(Exception):
13114 """Exception raised for URL validation errors."""
13115 pass
13116
13117
13118def normalize_url(url: str) -> str:
13119 """
13120 Normalize a URL to prevent bypass techniques.
13121
13122 Args:
13123 url: URL to normalize
13124
13125 Returns:
13126 str: Normalized URL
13127 """
13128 # Handle empty URLs
13129 if not url:
13130 raise URLValidationError("URL cannot be empty")
13131
13132 # Ensure URL is a string
13133 url = str(url).strip()
13134
13135 # Remove control characters and whitespace
13136 url = re.sub(r'[\x00-\x20\x7F]', '', url)
13137
13138 try:
13139 # Parse the URL
13140 parsed = urlparse(url)
13141
13142 # Check if scheme and netloc exist
13143 if not parsed.scheme or not parsed.netloc:
13144 raise URLValidationError(f"Invalid URL: {url} - missing scheme or host")
13145
13146 # Decode any URL-encoded components that might be hiding malicious content
13147 path = unquote(parsed.path)
13148
13149 # Normalize path to remove traversal sequences
13150 path = normalize_path(path)
13151
13152 # Parse and rebuild query parameters to normalize
13153 query_params = parse_qs(parsed.query, keep_blank_values=True)
13154 normalized_query = urlencode(query_params, doseq=True)
13155
13156 # Rebuild the normalized URL
13157 normalized_url = urlunparse((
13158 parsed.scheme.lower(), # Normalize scheme to lowercase
13159 parsed.netloc.lower(), # Normalize domain to lowercase
13160 path,
13161 parsed.params,
13162 normalized_query,
13163 '' # Remove fragment as it's client-side only
13164 ))
13165
13166 return normalized_url
13167
13168 except Exception as e:
13169 raise URLValidationError(f"URL normalization error: {str(e)}")
13170
13171
13172def normalize_path(path: str) -> str:
13173 """
13174 Normalize a URL path to remove traversal sequences and other issues.
13175
13176 Args:
13177 path: URL path to normalize
13178
13179 Returns:
13180 str: Normalized path
13181 """
13182 # Remove consecutive slashes
13183 path = re.sub(r'/+', '/', path)
13184
13185 # Remove trailing slash for consistency unless the path is just "/"
13186 if path != '/' and path.endswith('/'):
13187 path = path[:-1]
13188
13189 # Process path components to resolve traversal sequences
13190 # This is complex, so we'll do it manually
13191 components = path.split('/')
13192 normalized_components = []
13193
13194 for component in components:
13195 if component == '' or component == '.':
13196 # Skip empty components and current directory references
13197 continue
13198 elif component == '..':
13199 # Go up one directory level
13200 if normalized_components:
13201 normalized_components.pop()
13202 else:
13203 normalized_components.append(component)
13204
13205 # Rebuild the path
13206 normalized_path = '/' + '/'.join(normalized_components)
13207
13208 # Ensure path starts with "/"
13209 if not normalized_path.startswith('/'):
13210 normalized_path = '/' + normalized_path
13211
13212 return normalized_path
13213
13214
13215def is_ip_address_in_blocklist(ip_str: str, blocklist: Set[str]) -> bool:
13216 """
13217 Check if an IP address is in the blocklist.
13218
13219 Args:
13220 ip_str: IP address string
13221 blocklist: Set of blocked IP addresses or CIDR ranges
13222
13223 Returns:
13224 bool: True if the IP is blocked, False otherwise
13225 """
13226 try:
13227 # Parse the IP address
13228 ip = ipaddress.ip_address(ip_str)
13229
13230 # Check if the IP is directly in the blocklist
13231 if ip_str in blocklist:
13232 return True
13233
13234 # Check if the IP is in any of the blocklisted CIDR ranges
13235 for block in blocklist:
13236 if '/' in block: # It's a CIDR block
13237 try:
13238 network = ipaddress.ip_network(block, strict=False)
13239 if ip in network:
13240 return True
13241 except ValueError:
13242 continue
13243
13244 return False
13245 except ValueError:
13246 # Not a valid IP address
13247 return False
13248
13249
13250def validate_url(
13251 url: str,
13252 allowed_schemes: Optional[Set[str]] = None,
13253 domain_allowlist: Optional[Set[str]] = None,
13254 domain_blocklist: Optional[Set[str]] = None,
13255) -> str:
13256 """
13257 Validate a URL against security rules.
13258
13259 Args:
13260 url: URL to validate
13261 allowed_schemes: Optional set of allowed URL schemes (defaults to http, https)
13262 domain_allowlist: Optional set of allowed domains
13263 domain_blocklist: Optional set of blocked domains
13264
13265 Returns:
13266 str: Validated and normalized URL
13267
13268 Raises:
13269 URLValidationError: If the URL does not pass validation
13270 """
13271 # Use default values if not provided
13272 if allowed_schemes is None:
13273 allowed_schemes = DEFAULT_ALLOWED_SCHEMES
13274
13275 if domain_blocklist is None:
13276 domain_blocklist = DEFAULT_DOMAIN_BLOCKLIST
13277
13278 if domain_allowlist is None:
13279 domain_allowlist = DEFAULT_DOMAIN_ALLOWLIST
13280
13281 # Normalize the URL
13282 try:
13283 normalized_url = normalize_url(url)
13284 except URLValidationError as e:
13285 raise URLValidationError(f"URL normalization failed: {str(e)}")
13286
13287 # Parse the normalized URL
13288 parsed = urlparse(normalized_url)
13289
13290 # Check scheme
13291 if parsed.scheme not in allowed_schemes:
13292 raise URLValidationError(
13293 f"URL scheme '{parsed.scheme}' not allowed. Allowed schemes: {allowed_schemes}"
13294 )
13295
13296 # Extract hostname
13297 hostname = parsed.netloc.split(':')[0].lower()
13298
13299 # Check allowlist first (override blocklist)
13300 if domain_allowlist and hostname in domain_allowlist:
13301 return normalized_url
13302
13303 # Check hostname against blocklist
13304 if hostname in domain_blocklist:
13305 raise URLValidationError(f"Domain '{hostname}' is blocked")
13306
13307 # Check for IP address
13308 try:
13309 ip = ipaddress.ip_address(hostname)
13310
13311 # Check if IP is in blocklist
13312 if is_ip_address_in_blocklist(str(ip), domain_blocklist):
13313 raise URLValidationError(f"IP address '{ip}' is blocked")
13314
13315 # Check for localhost/internal IP
13316 if ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_link_local:
13317 raise URLValidationError(f"IP address '{ip}' is internal/reserved")
13318 except ValueError:
13319 # Not an IP address, continue with domain checks
13320 pass
13321
13322 # Check for suspicious patterns
13323 for pattern in SUSPICIOUS_URL_PATTERNS:
13324 if re.search(pattern, normalized_url):
13325 raise URLValidationError(f"URL contains suspicious pattern: {pattern}")
13326
13327 # Check port if specified
13328 if ':' in parsed.netloc:
13329 try:
13330 port = int(parsed.netloc.split(':')[1])
13331 if port < 1 or port > 65535:
13332 raise URLValidationError(f"Invalid port number: {port}")
13333 except (IndexError, ValueError):
13334 raise URLValidationError(f"Invalid port specification in URL: {parsed.netloc}")
13335
13336 return normalized_url
13337
13338
13339def configure_domain_lists(
13340 additional_allowlist: Optional[Set[str]] = None,
13341 additional_blocklist: Optional[Set[str]] = None
13342) -> Tuple[Set[str], Set[str]]:
13343 """
13344 Configure domain allowlist and blocklist by adding custom entries.
13345
13346 Args:
13347 additional_allowlist: Additional domains to allow
13348 additional_blocklist: Additional domains to block
13349
13350 Returns:
13351 tuple: (configured_allowlist, configured_blocklist)
13352 """
13353 # Start with default lists
13354 allowlist = DEFAULT_DOMAIN_ALLOWLIST.copy()
13355 blocklist = DEFAULT_DOMAIN_BLOCKLIST.copy()
13356
13357 # Add additional entries
13358 if additional_allowlist:
13359 allowlist.update(additional_allowlist)
13360
13361 if additional_blocklist:
13362 blocklist.update(additional_blocklist)
13363
13364 return allowlist, blocklist
13365
13366
13367def create_url_validator(
13368 allowed_schemes: Optional[Set[str]] = None,
13369 domain_allowlist: Optional[Set[str]] = None,
13370 domain_blocklist: Optional[Set[str]] = None,
13371) -> callable:
13372 """
13373 Create a URL validator function with the specified configuration.
13374
13375 Args:
13376 allowed_schemes: Set of allowed URL schemes
13377 domain_allowlist: Set of allowed domains
13378 domain_blocklist: Set of blocked domains
13379
13380 Returns:
13381 callable: URL validator function
13382 """
13383 def validate(url: str) -> str:
13384 return validate_url(
13385 url,
13386 allowed_schemes=allowed_schemes,
13387 domain_allowlist=domain_allowlist,
13388 domain_blocklist=domain_blocklist
13389 )
13390
13391 return validate
13392</file>
13393
852813394</files>
ollamatools-project/url_validator.py +321 -0
……@@ -0,0 +1,321 @@
1"""
2URL validation module for OllamaTools.
3
4This module provides functions for validating and sanitizing URLs
5to prevent security vulnerabilities like SSRF attacks and URL injection.
6"""
7
8import re
9import ipaddress
10from urllib.parse import urlparse, parse_qs, urlencode, urlunparse, unquote
11import logging
12from typing import Dict, Any, List, Set, Optional, Tuple, Union
13
14# Set up logging
15logger = logging.getLogger(__name__)
16
17# Default allowed schemes
18DEFAULT_ALLOWED_SCHEMES = {'http', 'https'}
19
20# Default blocklist of domains
21DEFAULT_DOMAIN_BLOCKLIST = {
22 'localhost', '127.0.0.1', '0.0.0.0', '::1', # Localhost variations
23 '169.254.169.254', # AWS metadata endpoint
24 '169.254.170.2', # AWS ECS metadata endpoint
25 '169.254.170.23', # AWS Lambda metadata endpoint
26 '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', # Private IP ranges
27 'fc00::/7', # Unique local addresses (IPv6)
28 'fd00::/8', # Unique local addresses (IPv6)
29 'fe80::/10', # Link-local addresses (IPv6)
30}
31
32# Default allowlist of domains (overrides blocklist)
33DEFAULT_DOMAIN_ALLOWLIST = set()
34
35# Suspicious URL patterns
36SUSPICIOUS_URL_PATTERNS = [
37 r'(file|ftp|gopher|data|telnet|ldap|ssh)://', # Non-HTTP protocols
38 r'@', # URL with credentials
39 r'\.\.', # Directory traversal
40 r'<|>|\{|\}|\[|\]|\|', # Special characters
41]
42
43class URLValidationError(Exception):
44 """Exception raised for URL validation errors."""
45 pass
46
47
48def normalize_url(url: str) -> str:
49 """
50 Normalize a URL to prevent bypass techniques.
51
52 Args:
53 url: URL to normalize
54
55 Returns:
56 str: Normalized URL
57 """
58 # Handle empty URLs
59 if not url:
60 raise URLValidationError("URL cannot be empty")
61
62 # Ensure URL is a string
63 url = str(url).strip()
64
65 # Remove control characters and whitespace
66 url = re.sub(r'[\x00-\x20\x7F]', '', url)
67
68 try:
69 # Parse the URL
70 parsed = urlparse(url)
71
72 # Check if scheme and netloc exist
73 if not parsed.scheme or not parsed.netloc:
74 raise URLValidationError(f"Invalid URL: {url} - missing scheme or host")
75
76 # Decode any URL-encoded components that might be hiding malicious content
77 path = unquote(parsed.path)
78
79 # Normalize path to remove traversal sequences
80 path = normalize_path(path)
81
82 # Parse and rebuild query parameters to normalize
83 query_params = parse_qs(parsed.query, keep_blank_values=True)
84 normalized_query = urlencode(query_params, doseq=True)
85
86 # Rebuild the normalized URL
87 normalized_url = urlunparse((
88 parsed.scheme.lower(), # Normalize scheme to lowercase
89 parsed.netloc.lower(), # Normalize domain to lowercase
90 path,
91 parsed.params,
92 normalized_query,
93 '' # Remove fragment as it's client-side only
94 ))
95
96 return normalized_url
97
98 except Exception as e:
99 raise URLValidationError(f"URL normalization error: {str(e)}")
100
101
102def normalize_path(path: str) -> str:
103 """
104 Normalize a URL path to remove traversal sequences and other issues.
105
106 Args:
107 path: URL path to normalize
108
109 Returns:
110 str: Normalized path
111 """
112 # Remove consecutive slashes
113 path = re.sub(r'/+', '/', path)
114
115 # Remove trailing slash for consistency unless the path is just "/"
116 if path != '/' and path.endswith('/'):
117 path = path[:-1]
118
119 # Process path components to resolve traversal sequences
120 # This is complex, so we'll do it manually
121 components = path.split('/')
122 normalized_components = []
123
124 for component in components:
125 if component == '' or component == '.':
126 # Skip empty components and current directory references
127 continue
128 elif component == '..':
129 # Go up one directory level
130 if normalized_components:
131 normalized_components.pop()
132 else:
133 normalized_components.append(component)
134
135 # Rebuild the path
136 normalized_path = '/' + '/'.join(normalized_components)
137
138 # Ensure path starts with "/"
139 if not normalized_path.startswith('/'):
140 normalized_path = '/' + normalized_path
141
142 return normalized_path
143
144
145def is_ip_address_in_blocklist(ip_str: str, blocklist: Set[str]) -> bool:
146 """
147 Check if an IP address is in the blocklist.
148
149 Args:
150 ip_str: IP address string
151 blocklist: Set of blocked IP addresses or CIDR ranges
152
153 Returns:
154 bool: True if the IP is blocked, False otherwise
155 """
156 try:
157 # Parse the IP address
158 ip = ipaddress.ip_address(ip_str)
159
160 # Check if the IP is directly in the blocklist
161 if ip_str in blocklist:
162 return True
163
164 # Check if the IP is in any of the blocklisted CIDR ranges
165 for block in blocklist:
166 if '/' in block: # It's a CIDR block
167 try:
168 network = ipaddress.ip_network(block, strict=False)
169 if ip in network:
170 return True
171 except ValueError:
172 continue
173
174 return False
175 except ValueError:
176 # Not a valid IP address
177 return False
178
179
180def validate_url(
181 url: str,
182 allowed_schemes: Optional[Set[str]] = None,
183 domain_allowlist: Optional[Set[str]] = None,
184 domain_blocklist: Optional[Set[str]] = None,
185) -> str:
186 """
187 Validate a URL against security rules.
188
189 Args:
190 url: URL to validate
191 allowed_schemes: Optional set of allowed URL schemes (defaults to http, https)
192 domain_allowlist: Optional set of allowed domains
193 domain_blocklist: Optional set of blocked domains
194
195 Returns:
196 str: Validated and normalized URL
197
198 Raises:
199 URLValidationError: If the URL does not pass validation
200 """
201 # Use default values if not provided
202 if allowed_schemes is None:
203 allowed_schemes = DEFAULT_ALLOWED_SCHEMES
204
205 if domain_blocklist is None:
206 domain_blocklist = DEFAULT_DOMAIN_BLOCKLIST
207
208 if domain_allowlist is None:
209 domain_allowlist = DEFAULT_DOMAIN_ALLOWLIST
210
211 # Normalize the URL
212 try:
213 normalized_url = normalize_url(url)
214 except URLValidationError as e:
215 raise URLValidationError(f"URL normalization failed: {str(e)}")
216
217 # Parse the normalized URL
218 parsed = urlparse(normalized_url)
219
220 # Check scheme
221 if parsed.scheme not in allowed_schemes:
222 raise URLValidationError(
223 f"URL scheme '{parsed.scheme}' not allowed. Allowed schemes: {allowed_schemes}"
224 )
225
226 # Extract hostname
227 hostname = parsed.netloc.split(':')[0].lower()
228
229 # Check allowlist first (override blocklist)
230 if domain_allowlist and hostname in domain_allowlist:
231 return normalized_url
232
233 # Check hostname against blocklist
234 if hostname in domain_blocklist:
235 raise URLValidationError(f"Domain '{hostname}' is blocked")
236
237 # Check for IP address
238 try:
239 ip = ipaddress.ip_address(hostname)
240
241 # Check if IP is in blocklist
242 if is_ip_address_in_blocklist(str(ip), domain_blocklist):
243 raise URLValidationError(f"IP address '{ip}' is blocked")
244
245 # Check for localhost/internal IP
246 if ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_link_local:
247 raise URLValidationError(f"IP address '{ip}' is internal/reserved")
248 except ValueError:
249 # Not an IP address, continue with domain checks
250 pass
251
252 # Check for suspicious patterns
253 for pattern in SUSPICIOUS_URL_PATTERNS:
254 if re.search(pattern, normalized_url):
255 raise URLValidationError(f"URL contains suspicious pattern: {pattern}")
256
257 # Check port if specified
258 if ':' in parsed.netloc:
259 try:
260 port = int(parsed.netloc.split(':')[1])
261 if port < 1 or port > 65535:
262 raise URLValidationError(f"Invalid port number: {port}")
263 except (IndexError, ValueError):
264 raise URLValidationError(f"Invalid port specification in URL: {parsed.netloc}")
265
266 return normalized_url
267
268
269def configure_domain_lists(
270 additional_allowlist: Optional[Set[str]] = None,
271 additional_blocklist: Optional[Set[str]] = None
272) -> Tuple[Set[str], Set[str]]:
273 """
274 Configure domain allowlist and blocklist by adding custom entries.
275
276 Args:
277 additional_allowlist: Additional domains to allow
278 additional_blocklist: Additional domains to block
279
280 Returns:
281 tuple: (configured_allowlist, configured_blocklist)
282 """
283 # Start with default lists
284 allowlist = DEFAULT_DOMAIN_ALLOWLIST.copy()
285 blocklist = DEFAULT_DOMAIN_BLOCKLIST.copy()
286
287 # Add additional entries
288 if additional_allowlist:
289 allowlist.update(additional_allowlist)
290
291 if additional_blocklist:
292 blocklist.update(additional_blocklist)
293
294 return allowlist, blocklist
295
296
297def create_url_validator(
298 allowed_schemes: Optional[Set[str]] = None,
299 domain_allowlist: Optional[Set[str]] = None,
300 domain_blocklist: Optional[Set[str]] = None,
301) -> callable:
302 """
303 Create a URL validator function with the specified configuration.
304
305 Args:
306 allowed_schemes: Set of allowed URL schemes
307 domain_allowlist: Set of allowed domains
308 domain_blocklist: Set of blocked domains
309
310 Returns:
311 callable: URL validator function
312 """
313 def validate(url: str) -> str:
314 return validate_url(
315 url,
316 allowed_schemes=allowed_schemes,
317 domain_allowlist=domain_allowlist,
318 domain_blocklist=domain_blocklist
319 )
320
321 return validate