| … | … | @@ -64,9 +64,11 @@ examples/ |
| 64 | 64 | permission_example.py |
| 65 | 65 | secure_doc_tools.py |
| 66 | 66 | secure_web_tools.py |
| 67 | security_example.py |
| 67 | 68 | test_commands.py |
| 68 | 69 | test_ollama_integration.py |
| 69 | 70 | test_performance.py |
| 71 | transparent_ollama_agent.py |
| 70 | 72 | web_requests.py |
| 71 | 73 | web_tools_examples.py |
| 72 | 74 | ollamatools/ |
| … | … | @@ -80,10 +82,15 @@ ollamatools/ |
| 80 | 82 | logger.py |
| 81 | 83 | __init__.py |
| 82 | 84 | agent.py |
| 85 | command_validator.py |
| 83 | 86 | config.py |
| 87 | errors.py |
| 84 | 88 | logger.py |
| 89 | opensearch_web_tools.py |
| 90 | path_validator.py |
| 85 | 91 | security.py |
| 86 | 92 | security1.py |
| 93 | url_validator.py |
| 87 | 94 | ollamatools.egg-info/ |
| 88 | 95 | dependency_links.txt |
| 89 | 96 | PKG-INFO |
| … | … | @@ -107,6 +114,7 @@ tests/ |
| 107 | 114 | test_system_tools.py |
| 108 | 115 | test_web_tools.py |
| 109 | 116 | CHANGELOG.md |
| 117 | command_validator.py |
| 110 | 118 | COMPLETION_REPORT.md |
| 111 | 119 | CONTRIBUTING.md |
| 112 | 120 | example_doc.html |
| … | … | @@ -114,13 +122,16 @@ example_doc.md |
| 114 | 122 | LICENSE |
| 115 | 123 | make_executable.sh |
| 116 | 124 | mkdir_command.sh |
| 125 | path_validator.py |
| 117 | 126 | pytest.ini |
| 118 | 127 | README.md |
| 119 | 128 | requirements.txt |
| 120 | 129 | secure_doc.md |
| 130 | SECURITY.md |
| 121 | 131 | setup.py |
| 122 | 132 | simple_test_web_tools.py |
| 123 | 133 | tree-struct |
| 134 | url_validator.py |
| 124 | 135 | </directory_structure> |
| 125 | 136 | |
| 126 | 137 | <files> |
| … | … | @@ -2825,6 +2836,563 @@ if __name__ == "__main__": |
| 2825 | 2836 | main() |
| 2826 | 2837 | </file> |
| 2827 | 2838 | |
| 2839 | <file path="examples/security_example.py"> |
| 2840 | #!/usr/bin/env python3 |
| 2841 | """ |
| 2842 | Example demonstrating the enhanced security features in OllamaTools. |
| 2843 | |
| 2844 | This script shows how to use the command validation, path validation, |
| 2845 | URL validation, and error handling features safely. |
| 2846 | """ |
| 2847 | |
| 2848 | import os |
| 2849 | import sys |
| 2850 | import logging |
| 2851 | from typing import Set, Dict, Any |
| 2852 | |
| 2853 | # Add the parent directory to the path so we can import the package |
| 2854 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) |
| 2855 | |
| 2856 | from ollamatools.security import PermissionManager |
| 2857 | from ollamatools.command_validator import ( |
| 2858 | validate_and_sanitize_command, |
| 2859 | CommandValidationError, |
| 2860 | DEFAULT_ALLOWLIST |
| 2861 | ) |
| 2862 | from 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 | ) |
| 2869 | from ollamatools.url_validator import ( |
| 2870 | validate_url, |
| 2871 | URLValidationError, |
| 2872 | configure_domain_lists |
| 2873 | ) |
| 2874 | from ollamatools.errors import ( |
| 2875 | OllamaToolsError, |
| 2876 | handle_exception, |
| 2877 | format_error_response |
| 2878 | ) |
| 2879 | from ollamatools.tools.system_tools import ( |
| 2880 | execute_command, |
| 2881 | read_file, |
| 2882 | write_file, |
| 2883 | find_files |
| 2884 | ) |
| 2885 | from ollamatools.tools.web_tools import fetch_url |
| 2886 | from ollamatools.utils.logger import setup_logger |
| 2887 | |
| 2888 | # Set up logging |
| 2889 | logger = setup_logger("security_example", level="INFO") |
| 2890 | |
| 2891 | def 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 | |
| 2954 | def 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 | |
| 3064 | def 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 | |
| 3166 | def 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 | |
| 3233 | def 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 | |
| 3343 | def 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 | |
| 3392 | if __name__ == "__main__": |
| 3393 | main() |
| 3394 | </file> |
| 3395 | |
| 2828 | 3396 | <file path="examples/test_commands.py"> |
| 2829 | 3397 | #!/usr/bin/env python3 |
| 2830 | 3398 | """ |
| … | … | @@ -3195,124 +3763,824 @@ if __name__ == "__main__": |
| 3195 | 3763 | unittest.main() |
| 3196 | 3764 | </file> |
| 3197 | 3765 | |
| 3198 | | <file path="examples/web_requests.py"> |
| 3766 | <file path="examples/transparent_ollama_agent.py"> |
| 3767 | #!/usr/bin/env python3 |
| 3199 | 3768 | """ |
| 3200 | | Example demonstrating the use of web tools in OllamaTools. |
| 3769 | Transparent Ollama Agent with OpenSearchNG Integration. |
| 3201 | 3770 | |
| 3202 | | This script shows how to make web requests using the fetch_url function. |
| 3771 | This script demonstrates an improved OllamaAgent that: |
| 3772 | 1. Shows transparent execution of commands and functions |
| 3773 | 2. Integrates with a locally running OpenSearchNG instance |
| 3774 | 3. Provides detailed feedback on what's happening |
| 3775 | 4. Handles errors gracefully with suggestions for fixes |
| 3776 | |
| 3777 | Usage: |
| 3778 | python transparent_ollama_agent.py |
| 3203 | 3779 | """ |
| 3204 | 3780 | |
| 3205 | | from ollamatools.tools.web_tools import fetch_url |
| 3206 | | from ollamatools.security import PermissionManager |
| 3781 | import os |
| 3782 | import sys |
| 3207 | 3783 | import json |
| 3784 | import logging |
| 3785 | import time |
| 3786 | from typing import Dict, Any, List, Optional, Set, Union, Tuple |
| 3208 | 3787 | |
| 3209 | | def 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 |
| 3789 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) |
| 3790 | |
| 3791 | from ollamatools import OllamaAgent, Config |
| 3792 | from ollamatools.command_validator import DEFAULT_ALLOWLIST |
| 3793 | from ollamatools.utils.logger import setup_logger |
| 3794 | from ollamatools.errors import ( |
| 3795 | OllamaToolsError, |
| 3796 | CommandError, |
| 3797 | FileError, |
| 3798 | WebError, |
| 3799 | handle_exception |
| 3800 | ) |
| 3801 | |
| 3802 | # Import the OpenSearchNG integration |
| 3803 | from 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 |
| 3811 | logger = setup_logger("transparent_ollama_agent", level="INFO") |
| 3812 | |
| 3813 | # Extend the default command allowlist with useful commands |
| 3814 | EXTENDED_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 |
| 3820 | OPENSEARCH_CONFIG = DEFAULT_OPENSEARCH_CONFIG.copy() |
| 3821 | OPENSEARCH_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 | |
| 3827 | class TransparentOllamaAgent: |
| 3828 | """ |
| 3829 | An enhanced OllamaAgent that provides transparent execution and better feedback. |
| 3830 | """ |
| 3228 | 3831 | |
| 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 |
| 3237 | 3852 | ) |
| 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']}") |
| 3243 | 3876 | |
| 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)}" |
| 3257 | 3958 | |
| 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 | |
| 4350 | def main(): |
| 4351 | """Run the transparent Ollama agent.""" |
| 3259 | 4352 | 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 |
| 3265 | 4358 | ) |
| 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.") |
| 3269 | 4455 | 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 |
| 3271 | 4461 | |
| 3272 | 4462 | if __name__ == "__main__": |
| 3273 | | main() |
| 4463 | sys.exit(main()) |
| 3274 | 4464 | </file> |
| 3275 | 4465 | |
| 3276 | | <file path="examples/web_tools_examples.py"> |
| 4466 | <file path="examples/web_requests.py"> |
| 3277 | 4467 | """ |
| 3278 | | Examples demonstrating the use of advanced web tools in OllamaTools. |
| 4468 | Example demonstrating the use of web tools in OllamaTools. |
| 3279 | 4469 | |
| 3280 | | This script shows how to use the get_weather and search_web functions. |
| 4470 | This script shows how to make web requests using the fetch_url function. |
| 3281 | 4471 | """ |
| 3282 | 4472 | |
| 3283 | | from ollamatools.tools.web_tools import fetch_url, get_weather, search_web |
| 4473 | from ollamatools.tools.web_tools import fetch_url |
| 3284 | 4474 | from ollamatools.security import PermissionManager |
| 3285 | 4475 | import json |
| 3286 | 4476 | |
| 3287 | 4477 | def main(): |
| 3288 | 4478 | """Run the example.""" |
| 3289 | | print("OllamaTools Advanced Web Tools Example") |
| 3290 | | print("=====================================") |
| 4479 | print("OllamaTools Web Request Example") |
| 4480 | print("===============================") |
| 3291 | 4481 | |
| 3292 | 4482 | # Create a permission manager |
| 3293 | 4483 | permission_manager = PermissionManager(prompt_enabled=True) |
| 3294 | 4484 | |
| 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}") |
| 3298 | 4496 | |
| 4497 | print("\n2. POST request with data") |
| 3299 | 4498 | 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 | |
| 4540 | if __name__ == "__main__": |
| 4541 | main() |
| 4542 | </file> |
| 4543 | |
| 4544 | <file path="examples/web_tools_examples.py"> |
| 4545 | """ |
| 4546 | Examples demonstrating the use of advanced web tools in OllamaTools. |
| 4547 | |
| 4548 | This script shows how to use the get_weather and search_web functions. |
| 4549 | """ |
| 4550 | |
| 4551 | from ollamatools.tools.web_tools import fetch_url, get_weather, search_web |
| 4552 | from ollamatools.security import PermissionManager |
| 4553 | import json |
| 4554 | |
| 4555 | def 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 | |
| 3316 | 4584 | # Display current weather |
| 3317 | 4585 | print(f"Current weather in {weather_info['location']['name']}, {weather_info['location']['country']}:") |
| 3318 | 4586 | print(f" Temperature: {weather_info['current']['temperature']}°C") |
| … | … | @@ -3716,7 +4984,24 @@ import glob |
| 3716 | 4984 | import subprocess |
| 3717 | 4985 | import logging |
| 3718 | 4986 | import shlex |
| 3719 | | from typing import List, Optional, Dict, Any, Union |
| 4987 | from typing import List, Optional, Dict, Any, Union, Set |
| 4988 | |
| 4989 | # Import the command validation module |
| 4990 | from 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 |
| 4998 | from 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 | ) |
| 3720 | 5005 | |
| 3721 | 5006 | # Set up logging |
| 3722 | 5007 | logger = logging.getLogger(__name__) |
| … | … | @@ -3725,7 +5010,8 @@ def execute_command( |
| 3725 | 5010 | command: str, |
| 3726 | 5011 | args: Optional[List[str]] = None, |
| 3727 | 5012 | timeout: int = 60, |
| 3728 | | permission_manager = None |
| 5013 | permission_manager = None, |
| 5014 | custom_allowlist: Optional[set] = None |
| 3729 | 5015 | ) -> Dict[str, Any]: |
| 3730 | 5016 | """ |
| 3731 | 5017 | Execute a shell command with arguments. |
| … | … | @@ -3735,6 +5021,7 @@ def execute_command( |
| 3735 | 5021 | args: Command arguments (used only if command is a simple command) |
| 3736 | 5022 | timeout: Command timeout in seconds |
| 3737 | 5023 | permission_manager: Permission manager instance |
| 5024 | custom_allowlist: Optional custom allowlist for commands |
| 3738 | 5025 | |
| 3739 | 5026 | Returns: |
| 3740 | 5027 | dict: Result with stdout, stderr, and returncode |
| … | … | @@ -3742,10 +5029,14 @@ def execute_command( |
| 3742 | 5029 | # Handle case where command contains both command and arguments as a string |
| 3743 | 5030 | if isinstance(command, str) and ' ' in command and args is None: |
| 3744 | 5031 | # 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)}"} |
| 3749 | 5040 | |
| 3750 | 5041 | # Default args to empty list if None |
| 3751 | 5042 | if args is None: |
| … | … | @@ -3754,13 +5045,9 @@ def execute_command( |
| 3754 | 5045 | # Convert args to strings if they aren't already |
| 3755 | 5046 | args = [str(arg) for arg in args] |
| 3756 | 5047 | |
| 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: |
| 3764 | 5051 | logger.warning(f"Sudo command detected: {command} {' '.join(args)}") |
| 3765 | 5052 | |
| 3766 | 5053 | # Check permission if permission manager is provided |
| … | … | @@ -3776,18 +5063,27 @@ def execute_command( |
| 3776 | 5063 | logger.warning(f"Permission denied for command: {command} {' '.join(args)}") |
| 3777 | 5064 | return {"error": "Permission denied"} |
| 3778 | 5065 | |
| 3779 | | # Construct the full command |
| 3780 | | full_command = [command] + args |
| 3781 | | logger.info(f"Executing command: {' '.join(full_command)}") |
| 3782 | | |
| 3783 | 5066 | 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 | |
| 3784 | 5079 | # Execute the command |
| 3785 | 5080 | result = subprocess.run( |
| 3786 | 5081 | full_command, |
| 3787 | 5082 | capture_output=True, |
| 3788 | 5083 | check=False, |
| 3789 | 5084 | text=True, |
| 3790 | | timeout=timeout |
| 5085 | timeout=timeout, |
| 5086 | env=secure_env |
| 3791 | 5087 | ) |
| 3792 | 5088 | |
| 3793 | 5089 | # Return the result |
| … | … | @@ -3797,23 +5093,31 @@ def execute_command( |
| 3797 | 5093 | "returncode": result.returncode, |
| 3798 | 5094 | "command": ' '.join(full_command) # Include the executed command for reference |
| 3799 | 5095 | } |
| 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 | } |
| 3800 | 5102 | 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)}") |
| 3802 | 5104 | return { |
| 3803 | 5105 | "error": f"Command timed out after {timeout} seconds", |
| 3804 | | "command": ' '.join(full_command) |
| 5106 | "command": f"{command} {' '.join(args) if args else ''}" |
| 3805 | 5107 | } |
| 3806 | 5108 | except Exception as e: |
| 3807 | 5109 | logger.error(f"Error executing command: {str(e)}") |
| 3808 | 5110 | return { |
| 3809 | 5111 | "error": str(e), |
| 3810 | | "command": ' '.join(full_command) |
| 5112 | "command": f"{command} {' '.join(args) if args else ''}" |
| 3811 | 5113 | } |
| 3812 | 5114 | |
| 3813 | 5115 | def read_file( |
| 3814 | 5116 | path: str, |
| 3815 | 5117 | 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 |
| 3817 | 5121 | ) -> str: |
| 3818 | 5122 | """ |
| 3819 | 5123 | Read file contents. |
| … | … | @@ -3822,13 +5126,12 @@ def read_file( |
| 3822 | 5126 | path: File path |
| 3823 | 5127 | max_size: Maximum file size to read in bytes |
| 3824 | 5128 | permission_manager: Permission manager instance |
| 5129 | sandbox_path: Base path that defines the sandbox |
| 5130 | additional_allowed_paths: Additional paths to allow |
| 3825 | 5131 | |
| 3826 | 5132 | Returns: |
| 3827 | 5133 | str: File contents |
| 3828 | 5134 | """ |
| 3829 | | # Sanitize the path |
| 3830 | | path = os.path.abspath(path) |
| 3831 | | |
| 3832 | 5135 | # Check permission if permission manager is provided |
| 3833 | 5136 | if permission_manager: |
| 3834 | 5137 | function_args = {"path": path, "max_size": max_size} |
| … | … | @@ -3842,23 +5145,28 @@ def read_file( |
| 3842 | 5145 | return "Permission denied" |
| 3843 | 5146 | |
| 3844 | 5147 | 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 | ) |
| 3849 | 5154 | |
| 3850 | 5155 | # Check the file size if max_size is specified |
| 3851 | 5156 | if max_size is not None: |
| 3852 | | file_size = os.path.getsize(path) |
| 5157 | file_size = os.path.getsize(validated_path) |
| 3853 | 5158 | 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}") |
| 3855 | 5160 | return f"File size ({file_size} bytes) exceeds maximum size ({max_size} bytes)" |
| 3856 | 5161 | |
| 3857 | 5162 | # Read the file |
| 3858 | | with open(path, "r") as f: |
| 5163 | with open(validated_path, "r") as f: |
| 3859 | 5164 | content = f.read() |
| 3860 | 5165 | |
| 3861 | 5166 | return content |
| 5167 | except PathValidationError as e: |
| 5168 | logger.error(f"Path validation error: {str(e)}") |
| 5169 | return f"Path validation error: {str(e)}" |
| 3862 | 5170 | except Exception as e: |
| 3863 | 5171 | logger.error(f"Error reading file: {str(e)}") |
| 3864 | 5172 | return f"Error reading file: {str(e)}" |
| … | … | @@ -3867,7 +5175,10 @@ def write_file( |
| 3867 | 5175 | path: str, |
| 3868 | 5176 | content: str, |
| 3869 | 5177 | 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 |
| 3871 | 5182 | ) -> bool: |
| 3872 | 5183 | """ |
| 3873 | 5184 | Write content to a file. |
| … | … | @@ -3877,12 +5188,14 @@ def write_file( |
| 3877 | 5188 | content: Content to write |
| 3878 | 5189 | mode: Write mode ('w' for write, 'a' for append) |
| 3879 | 5190 | 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 |
| 3880 | 5194 | |
| 3881 | 5195 | Returns: |
| 3882 | 5196 | bool: True if successful |
| 3883 | 5197 | """ |
| 3884 | | # Sanitize the path and mode |
| 3885 | | path = os.path.abspath(path) |
| 5198 | # Validate the mode |
| 3886 | 5199 | if mode not in ['w', 'a']: |
| 3887 | 5200 | logger.error(f"Invalid mode: {mode}") |
| 3888 | 5201 | return False |
| … | … | @@ -3900,14 +5213,25 @@ def write_file( |
| 3900 | 5213 | return False |
| 3901 | 5214 | |
| 3902 | 5215 | 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 | |
| 3903 | 5224 | # 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) |
| 3905 | 5226 | |
| 3906 | 5227 | # Write to the file |
| 3907 | | with open(path, mode) as f: |
| 5228 | with open(validated_path, mode) as f: |
| 3908 | 5229 | f.write(content) |
| 3909 | 5230 | |
| 3910 | 5231 | return True |
| 5232 | except PathValidationError as e: |
| 5233 | logger.error(f"Path validation error: {str(e)}") |
| 5234 | return False |
| 3911 | 5235 | except Exception as e: |
| 3912 | 5236 | logger.error(f"Error writing to file: {str(e)}") |
| 3913 | 5237 | return False |
| … | … | @@ -3916,7 +5240,9 @@ def find_files( |
| 3916 | 5240 | pattern: str, |
| 3917 | 5241 | directory: str = '.', |
| 3918 | 5242 | 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 |
| 3920 | 5246 | ) -> List[str]: |
| 3921 | 5247 | """ |
| 3922 | 5248 | Find files matching a pattern. |
| … | … | @@ -3926,13 +5252,12 @@ def find_files( |
| 3926 | 5252 | directory: Search directory |
| 3927 | 5253 | recursive: Whether to search recursively |
| 3928 | 5254 | permission_manager: Permission manager instance |
| 5255 | sandbox_path: Base path that defines the sandbox |
| 5256 | additional_allowed_paths: Additional paths to allow |
| 3929 | 5257 | |
| 3930 | 5258 | Returns: |
| 3931 | 5259 | List[str]: Matching file paths |
| 3932 | 5260 | """ |
| 3933 | | # Sanitize the directory |
| 3934 | | directory = os.path.abspath(directory) |
| 3935 | | |
| 3936 | 5261 | # Check permission if permission manager is provided |
| 3937 | 5262 | if permission_manager: |
| 3938 | 5263 | function_args = {"pattern": pattern, "directory": directory, "recursive": recursive} |
| … | … | @@ -3946,16 +5271,41 @@ def find_files( |
| 3946 | 5271 | return [] |
| 3947 | 5272 | |
| 3948 | 5273 | 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 | |
| 3949 | 5282 | # 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) |
| 3951 | 5284 | |
| 3952 | 5285 | # Find files |
| 3953 | 5286 | files = glob.glob(search_pattern, recursive=recursive) |
| 3954 | 5287 | |
| 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)}") |
| 3957 | 5304 | |
| 3958 | | return files |
| 5305 | return validated_files |
| 5306 | except PathValidationError as e: |
| 5307 | logger.error(f"Path validation error: {str(e)}") |
| 5308 | return [] |
| 3959 | 5309 | except Exception as e: |
| 3960 | 5310 | logger.error(f"Error finding files: {str(e)}") |
| 3961 | 5311 | return [] |
| … | … | @@ -3970,12 +5320,20 @@ including weather information fetching and web search functionality. |
| 3970 | 5320 | """ |
| 3971 | 5321 | |
| 3972 | 5322 | import requests |
| 3973 | | from typing import Dict, Any, Optional, Union, List |
| 5323 | from typing import Dict, Any, Optional, Union, List, Set |
| 3974 | 5324 | import logging |
| 3975 | 5325 | import json |
| 3976 | 5326 | from urllib.parse import urlparse, quote |
| 3977 | 5327 | from bs4 import BeautifulSoup |
| 3978 | 5328 | |
| 5329 | # Import the URL validation module |
| 5330 | from ollamatools.url_validator import ( |
| 5331 | validate_url, |
| 5332 | URLValidationError, |
| 5333 | configure_domain_lists, |
| 5334 | create_url_validator |
| 5335 | ) |
| 5336 | |
| 3979 | 5337 | # Get logger |
| 3980 | 5338 | logger = logging.getLogger(__name__) |
| 3981 | 5339 | |
| … | … | @@ -3986,7 +5344,10 @@ def fetch_url( |
| 3986 | 5344 | headers: Optional[Dict[str, str]] = None, |
| 3987 | 5345 | data: Optional[Dict[str, Any]] = None, |
| 3988 | 5346 | 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 |
| 3990 | 5351 | ) -> Dict[str, Any]: |
| 3991 | 5352 | """ |
| 3992 | 5353 | Fetch a URL with specified parameters. |
| … | … | @@ -3999,6 +5360,9 @@ def fetch_url( |
| 3999 | 5360 | data: Request data |
| 4000 | 5361 | timeout: Request timeout in seconds |
| 4001 | 5362 | 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 |
| 4002 | 5366 | |
| 4003 | 5367 | Returns: |
| 4004 | 5368 | dict: Response with status, headers, and content |
| … | … | @@ -4008,21 +5372,6 @@ def fetch_url( |
| 4008 | 5372 | requests.RequestException: If the request fails |
| 4009 | 5373 | PermissionError: If permission is denied |
| 4010 | 5374 | """ |
| 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 | | |
| 4026 | 5375 | # Request permission if permission manager is provided |
| 4027 | 5376 | if permission_manager: |
| 4028 | 5377 | args = { |
| … | … | @@ -4037,40 +5386,73 @@ def fetch_url( |
| 4037 | 5386 | logger.warning(f"Permission denied for web request to {url}") |
| 4038 | 5387 | raise PermissionError(f"Permission denied for web request to {url}") |
| 4039 | 5388 | |
| 4040 | | logger.info(f"Making {method} request to {url}") |
| 4041 | | |
| 5389 | # Validate and normalize the URL |
| 4042 | 5390 | 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 |
| 4050 | 5396 | ) |
| 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: |
| 4058 | 5442 | 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 |
| 4071 | 5453 | |
| 4072 | 5454 | 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)}") |
| 4074 | 5456 | raise |
| 4075 | 5457 | |
| 4076 | 5458 | |
| … | … | @@ -4079,7 +5461,9 @@ def get_weather( |
| 4079 | 5461 | days: int = 3, |
| 4080 | 5462 | units: str = 'metric', |
| 4081 | 5463 | 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 |
| 4083 | 5467 | ) -> Dict[str, Any]: |
| 4084 | 5468 | """ |
| 4085 | 5469 | Get weather information for a location. |
| … | … | @@ -4090,6 +5474,8 @@ def get_weather( |
| 4090 | 5474 | units: Temperature units ('metric' or 'imperial') |
| 4091 | 5475 | api_key: OpenWeatherMap API key (if None, uses config) |
| 4092 | 5476 | permission_manager: Optional permission manager to request permission |
| 5477 | domain_allowlist: Optional set of allowed domains |
| 5478 | domain_blocklist: Optional set of blocked domains |
| 4093 | 5479 | |
| 4094 | 5480 | Returns: |
| 4095 | 5481 | dict: Weather information including current conditions and forecast |
| … | … | @@ -4132,6 +5518,11 @@ def get_weather( |
| 4132 | 5518 | logger.info(f"Fetching weather for {location}") |
| 4133 | 5519 | |
| 4134 | 5520 | 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 | |
| 4135 | 5526 | # Base URL for OpenWeatherMap API |
| 4136 | 5527 | base_url = "https://api.openweathermap.org/data/2.5" |
| 4137 | 5528 | |
| … | … | @@ -4143,9 +5534,15 @@ def get_weather( |
| 4143 | 5534 | "units": units |
| 4144 | 5535 | } |
| 4145 | 5536 | |
| 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"] |
| 4149 | 5546 | |
| 4150 | 5547 | # Get forecast |
| 4151 | 5548 | forecast_url = f"{base_url}/forecast" |
| … | … | @@ -4156,9 +5553,15 @@ def get_weather( |
| 4156 | 5553 | "cnt": days * 8 # 8 forecasts per day (every 3 hours) |
| 4157 | 5554 | } |
| 4158 | 5555 | |
| 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"] |
| 4162 | 5565 | |
| 4163 | 5566 | # Format the response |
| 4164 | 5567 | weather_info = { |
| … | … | @@ -4197,7 +5600,7 @@ def get_weather( |
| 4197 | 5600 | |
| 4198 | 5601 | return weather_info |
| 4199 | 5602 | |
| 4200 | | except requests.RequestException as e: |
| 5603 | except Exception as e: |
| 4201 | 5604 | logger.error(f"Weather API request failed: {str(e)}") |
| 4202 | 5605 | raise |
| 4203 | 5606 | |
| … | … | @@ -4206,7 +5609,9 @@ def search_web( |
| 4206 | 5609 | query: str, |
| 4207 | 5610 | num_results: int = 5, |
| 4208 | 5611 | 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 |
| 4210 | 5615 | ) -> List[Dict[str, str]]: |
| 4211 | 5616 | """ |
| 4212 | 5617 | Search the web for a query. |
| … | … | @@ -4216,6 +5621,8 @@ def search_web( |
| 4216 | 5621 | num_results: Number of results to return (1-10) |
| 4217 | 5622 | api_key: Search API key (if None, uses config) |
| 4218 | 5623 | permission_manager: Optional permission manager to request permission |
| 5624 | domain_allowlist: Optional set of allowed domains |
| 5625 | domain_blocklist: Optional set of blocked domains |
| 4219 | 5626 | |
| 4220 | 5627 | Returns: |
| 4221 | 5628 | List[dict]: Search results with title, URL, and snippet |
| … | … | @@ -4248,11 +5655,22 @@ def search_web( |
| 4248 | 5655 | # In a real implementation, this would get the API key from the config |
| 4249 | 5656 | # For now, we'll use a fallback to a simple search method that doesn't require API key |
| 4250 | 5657 | 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 | ) |
| 4252 | 5665 | |
| 4253 | 5666 | logger.info(f"Searching the web for: {query}") |
| 4254 | 5667 | |
| 4255 | 5668 | 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 | |
| 4256 | 5674 | # Example using the Serpapi (Google Search API) |
| 4257 | 5675 | # In a real implementation, you would use a proper search API |
| 4258 | 5676 | url = "https://serpapi.com/search" |
| … | … | @@ -4262,10 +5680,16 @@ def search_web( |
| 4262 | 5680 | "num": num_results |
| 4263 | 5681 | } |
| 4264 | 5682 | |
| 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 | ) |
| 4267 | 5691 | |
| 4268 | | data = response.json() |
| 5692 | data = response["content"] |
| 4269 | 5693 | results = [] |
| 4270 | 5694 | |
| 4271 | 5695 | # Process search results |
| … | … | @@ -4279,15 +5703,27 @@ def search_web( |
| 4279 | 5703 | |
| 4280 | 5704 | return results |
| 4281 | 5705 | |
| 4282 | | except requests.RequestException as e: |
| 5706 | except Exception as e: |
| 4283 | 5707 | logger.error(f"Search API request failed: {str(e)}") |
| 4284 | 5708 | |
| 4285 | 5709 | # Fall back to the simple search method if API request fails |
| 4286 | 5710 | 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 | ) |
| 4288 | 5718 | |
| 4289 | 5719 | |
| 4290 | | def _fallback_search(query: str, num_results: int = 5) -> List[Dict[str, str]]: |
| 5720 | def _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]]: |
| 4291 | 5727 | """ |
| 4292 | 5728 | Fallback search method using DuckDuckGo HTML scraping when API is not available. |
| 4293 | 5729 | |
| … | … | @@ -4297,6 +5733,9 @@ def _fallback_search(query: str, num_results: int = 5) -> List[Dict[str, str]]: |
| 4297 | 5733 | Args: |
| 4298 | 5734 | query: Search query |
| 4299 | 5735 | 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 |
| 4300 | 5739 | |
| 4301 | 5740 | Returns: |
| 4302 | 5741 | 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]]: |
| 4306 | 5745 | url = f"https://html.duckduckgo.com/html/?q={encoded_query}" |
| 4307 | 5746 | |
| 4308 | 5747 | 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 | |
| 4309 | 5753 | headers = { |
| 4310 | 5754 | "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" |
| 4311 | 5755 | } |
| 4312 | | response = requests.get(url, headers=headers, timeout=15) |
| 4313 | | response.raise_for_status() |
| 4314 | 5756 | |
| 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') |
| 4316 | 5767 | |
| 4317 | 5768 | # Find search results |
| 4318 | 5769 | result_elements = soup.select('.result') |
| … | … | @@ -4328,15 +5779,20 @@ def _fallback_search(query: str, num_results: int = 5) -> List[Dict[str, str]]: |
| 4328 | 5779 | link = title_element.find('a').get('href', '') |
| 4329 | 5780 | snippet = snippet_element.get_text(strip=True) if snippet_element else "" |
| 4330 | 5781 | |
| 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}") |
| 4336 | 5792 | |
| 4337 | 5793 | return results |
| 4338 | 5794 | |
| 4339 | | except (requests.RequestException, Exception) as e: |
| 5795 | except Exception as e: |
| 4340 | 5796 | logger.error(f"Fallback search failed: {str(e)}") |
| 4341 | 5797 | # Return empty results if even the fallback fails |
| 4342 | 5798 | return [] |
| … | … | @@ -4486,7 +5942,7 @@ enabling automatic invocation of system commands, web requests, and |
| 4486 | 5942 | documentation utilities based on conversational context. |
| 4487 | 5943 | """ |
| 4488 | 5944 | |
| 4489 | | __version__ = "0.1.0" |
| 5945 | __version__ = "0.2.0" |
| 4490 | 5946 | |
| 4491 | 5947 | from .config import Config |
| 4492 | 5948 | from .security import PermissionManager |
| … | … | @@ -4499,15 +5955,102 @@ from .utils.logger import ( |
| 4499 | 5955 | get_ollamatools_logger |
| 4500 | 5956 | ) |
| 4501 | 5957 | |
| 5958 | # Import new security modules |
| 5959 | from .command_validator import ( |
| 5960 | validate_and_sanitize_command, |
| 5961 | CommandValidationError, |
| 5962 | get_secure_env, |
| 5963 | is_command_in_allowlist, |
| 5964 | DEFAULT_ALLOWLIST |
| 5965 | ) |
| 5966 | |
| 5967 | from .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 | |
| 5976 | from .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 |
| 5988 | from .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 | |
| 4502 | 6003 | __all__ = [ |
| 6004 | # Core components |
| 4503 | 6005 | 'Config', |
| 4504 | 6006 | 'PermissionManager', |
| 4505 | 6007 | 'OllamaAgent', |
| 6008 | |
| 6009 | # Logger utilities |
| 4506 | 6010 | 'setup_logger', |
| 4507 | 6011 | 'get_logger', |
| 4508 | 6012 | 'enable_debug_logging', |
| 4509 | 6013 | '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' |
| 4511 | 6054 | ] |
| 4512 | 6055 | </file> |
| 4513 | 6056 | |
| … | … | @@ -4965,265 +6508,1807 @@ class OllamaAgent: |
| 4965 | 6508 | elif "Dict" in annotation_str or "dict" in annotation_str: |
| 4966 | 6509 | return "object" |
| 4967 | 6510 | |
| 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 | """ |
| 6517 | Command validation module for OllamaTools. |
| 6518 | |
| 6519 | This module provides functions for validating and sanitizing shell commands |
| 6520 | to prevent command injection and other security vulnerabilities. |
| 6521 | """ |
| 6522 | |
| 6523 | import os |
| 6524 | import re |
| 6525 | import shlex |
| 6526 | import logging |
| 6527 | import pathlib |
| 6528 | from typing import List, Optional, Dict, Set, Tuple, Union |
| 6529 | |
| 6530 | # Set up logging |
| 6531 | logger = logging.getLogger(__name__) |
| 6532 | |
| 6533 | # Default allowlist of commands |
| 6534 | DEFAULT_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 |
| 6558 | HIGH_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) |
| 6566 | PROTECTED_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 |
| 6573 | DANGEROUS_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 | |
| 6585 | class CommandValidationError(Exception): |
| 6586 | """Exception raised for command validation errors.""" |
| 6587 | pass |
| 6588 | |
| 6589 | |
| 6590 | def 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 | |
| 6609 | def 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 | |
| 6651 | def 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 | |
| 6708 | def 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 | |
| 6734 | def 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 | |
| 6783 | def 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 | |
| 6813 | def 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 | """ |
| 6846 | Configuration management for OllamaTools. |
| 6847 | |
| 6848 | This module contains the Config class, which handles configuration settings for |
| 6849 | the OllamaTools library. |
| 6850 | """ |
| 6851 | |
| 6852 | import json |
| 6853 | import os |
| 6854 | from typing import Optional, Dict, Any |
| 6855 | |
| 6856 | from pydantic import BaseModel, validator, Field |
| 6857 | |
| 6858 | |
| 6859 | class 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 | """ |
| 6956 | Error definitions for OllamaTools. |
| 6957 | |
| 6958 | This module defines custom exceptions and error utilities for standardized |
| 6959 | error handling across the OllamaTools library. |
| 6960 | """ |
| 6961 | |
| 6962 | from typing import Dict, Any, Optional, List, Tuple, Union |
| 6963 | import logging |
| 6964 | import sys |
| 6965 | import traceback |
| 6966 | |
| 6967 | # Set up logging |
| 6968 | logger = logging.getLogger(__name__) |
| 6969 | |
| 6970 | # Define error codes |
| 6971 | ERROR_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 | |
| 7022 | class 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 | |
| 7051 | class 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 | |
| 7066 | class 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 | |
| 7086 | class 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 | |
| 7109 | class 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 | |
| 7141 | class 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 | |
| 7164 | class 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 | |
| 7190 | class 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 | |
| 7216 | class 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 | |
| 7239 | def 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 | |
| 7280 | def 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 | |
| 7306 | def 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 | """ |
| 7343 | Logging utilities for OllamaTools. |
| 7344 | |
| 7345 | This module contains logging utilities used by the OllamaTools library. |
| 7346 | """ |
| 7347 | |
| 7348 | import logging |
| 7349 | import os |
| 7350 | import sys |
| 7351 | from typing import Optional, Union, Dict, Any, List |
| 7352 | |
| 7353 | |
| 7354 | def 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 | |
| 7434 | def 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 | |
| 7451 | def 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 | |
| 7468 | def 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 | """ |
| 7490 | OpenSearchNG integration for OllamaTools. |
| 7491 | |
| 7492 | This module extends the web_tools capabilities by adding integration with |
| 7493 | a locally running OpenSearchNG instance for improved search functionality. |
| 7494 | """ |
| 7495 | |
| 7496 | import requests |
| 7497 | import json |
| 7498 | import logging |
| 7499 | from typing import Dict, Any, Optional, List, Union, Set |
| 7500 | from urllib.parse import quote_plus |
| 7501 | |
| 7502 | # Import URL validation utilities |
| 7503 | from ollamatools.url_validator import ( |
| 7504 | validate_url, |
| 7505 | URLValidationError, |
| 7506 | configure_domain_lists, |
| 7507 | ) |
| 7508 | |
| 7509 | # Import error handling utilities |
| 7510 | from ollamatools.errors import ( |
| 7511 | WebError, |
| 7512 | ERROR_CODES, |
| 7513 | handle_exception |
| 7514 | ) |
| 7515 | |
| 7516 | # Set up logging |
| 7517 | logger = logging.getLogger(__name__) |
| 7518 | |
| 7519 | # Default OpenSearchNG configuration |
| 7520 | DEFAULT_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 | |
| 7533 | def 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 | |
| 7669 | def 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 | |
| 7729 | def 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 | |
| 7796 | def 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 | ) |
| 4970 | 7890 | </file> |
| 4971 | 7891 | |
| 4972 | | <file path="ollamatools/config.py"> |
| 7892 | <file path="ollamatools/path_validator.py"> |
| 4973 | 7893 | """ |
| 4974 | | Configuration management for OllamaTools. |
| 7894 | Path validation module for OllamaTools. |
| 4975 | 7895 | |
| 4976 | | This module contains the Config class, which handles configuration settings for |
| 4977 | | the OllamaTools library. |
| 7896 | This module provides functions for validating and sanitizing file paths |
| 7897 | to prevent path traversal attacks and other file path-related security issues. |
| 4978 | 7898 | """ |
| 4979 | 7899 | |
| 4980 | | import json |
| 4981 | 7900 | import os |
| 4982 | | from typing import Optional, Dict, Any |
| 4983 | | |
| 4984 | | from pydantic import BaseModel, validator, Field |
| 7901 | import re |
| 7902 | import pathlib |
| 7903 | from typing import Optional, List, Set, Dict, Any, Union, Tuple |
| 7904 | import logging |
| 4985 | 7905 | |
| 7906 | # Set up logging |
| 7907 | logger = logging.getLogger(__name__) |
| 4986 | 7908 | |
| 4987 | | class Config(BaseModel): |
| 7909 | # Default protected paths - should not be modified |
| 7910 | DEFAULT_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 |
| 7929 | DEFAULT_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 |
| 7936 | DEFAULT_SANDBOX_PATH = os.path.expanduser('~') |
| 7937 | |
| 7938 | class PathValidationError(Exception): |
| 7939 | """Exception raised for path validation errors.""" |
| 7940 | pass |
| 7941 | |
| 7942 | |
| 7943 | def normalize_path(path: str) -> str: |
| 4988 | 7944 | """ |
| 4989 | | Configuration class for OllamaTools. |
| 7945 | Normalize a file path to resolve symlinks, relative paths, etc. |
| 4990 | 7946 | |
| 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 |
| 4994 | 7952 | """ |
| 7953 | # Convert to string if it's not already |
| 7954 | if not isinstance(path, str): |
| 7955 | path = str(path) |
| 4995 | 7956 | |
| 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', '') |
| 5002 | 7959 | |
| 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() |
| 5009 | 7962 | |
| 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) |
| 5016 | 7965 | |
| 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 | |
| 7974 | def resolve_symlinks(path: str) -> str: |
| 7975 | """ |
| 7976 | Resolve symlinks in a path. |
| 5042 | 7977 | |
| 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 |
| 5046 | 7980 | |
| 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 | |
| 7992 | def is_path_traversal_attempt(path: str) -> bool: |
| 7993 | """ |
| 7994 | Check if a path contains traversal patterns that might be used in attacks. |
| 5061 | 7995 | |
| 5062 | | def to_dict(self) -> Dict[str, Any]: |
| 5063 | | """ |
| 5064 | | Convert configuration to a dictionary. |
| 7996 | Args: |
| 7997 | path: File path to check |
| 5065 | 7998 | |
| 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 | ] |
| 5070 | 8021 | |
| 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 | | """ |
| 5084 | | Logging utilities for OllamaTools. |
| 8022 | for pattern in suspicious_patterns: |
| 8023 | if re.search(pattern, path): |
| 8024 | return True |
| 8025 | |
| 8026 | return False |
| 5085 | 8027 | |
| 5086 | | This module contains logging utilities used by the OllamaTools library. |
| 5087 | | """ |
| 5088 | 8028 | |
| 5089 | | import logging |
| 5090 | | import os |
| 5091 | | import sys |
| 5092 | | from typing import Optional, Union, Dict, Any, List |
| 8029 | def 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) |
| 5093 | 8063 | |
| 5094 | 8064 | |
| 5095 | | def 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: |
| 8065 | def is_path_protected( |
| 8066 | path: str, |
| 8067 | additional_protected_paths: Optional[Set[str]] = None, |
| 8068 | allowed_paths: Optional[Set[str]] = None |
| 8069 | ) -> bool: |
| 5104 | 8070 | """ |
| 5105 | | Get a configured logger instance. |
| 8071 | Check if a path is in a protected area that should not be modified. |
| 5106 | 8072 | |
| 5107 | 8073 | 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 |
| 5115 | 8077 | |
| 5116 | 8078 | Returns: |
| 5117 | | logging.Logger: Configured logger instance |
| 8079 | bool: True if the path is protected, False otherwise |
| 5118 | 8080 | """ |
| 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) |
| 5122 | 8083 | |
| 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) |
| 5127 | 8088 | |
| 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) |
| 5131 | 8093 | |
| 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 |
| 5135 | 8097 | |
| 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 |
| 5137 | 8102 | |
| 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 | |
| 8106 | def 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). |
| 5143 | 8114 | |
| 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 |
| 5150 | 8120 | |
| 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") |
| 5155 | 8130 | |
| 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) |
| 5160 | 8133 | |
| 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") |
| 5171 | 8137 | |
| 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 |
| 5173 | 8155 | |
| 5174 | 8156 | |
| 5175 | | def enable_debug_logging(logger_names: Optional[List[str]] = None) -> None: |
| 8157 | def 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 |
| 5176 | 8179 | """ |
| 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") |
| 5178 | 8183 | |
| 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 |
| 5190 | 8213 | |
| 5191 | 8214 | |
| 5192 | | def disable_logging(logger_names: Optional[List[str]] = None) -> None: |
| 8215 | def 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]: |
| 5193 | 8222 | """ |
| 5194 | | Disable logging for specified loggers or all loggers. |
| 8223 | Validate a directory and pattern for file searching operations (e.g., find_files). |
| 5195 | 8224 | |
| 5196 | 8225 | 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 |
| 5199 | 8237 | """ |
| 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 |
| 5207 | 8278 | |
| 5208 | 8279 | |
| 5209 | | def get_ollamatools_logger( |
| 5210 | | component: str, |
| 5211 | | level: Union[int, str] = logging.INFO, |
| 5212 | | log_file: Optional[str] = None |
| 5213 | | ) -> logging.Logger: |
| 8280 | def 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]]]: |
| 5214 | 8285 | """ |
| 5215 | | Get a logger for a specific OllamaTools component. |
| 8286 | Configure the path sandbox with custom settings. |
| 5216 | 8287 | |
| 5217 | 8288 | 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 |
| 5221 | 8292 | |
| 5222 | 8293 | Returns: |
| 5223 | | logging.Logger: Configured logger instance |
| 8294 | dict: Configuration dictionary with sandbox settings |
| 5224 | 8295 | """ |
| 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 |
| 5227 | 8312 | </file> |
| 5228 | 8313 | |
| 5229 | 8314 | <file path="ollamatools/security.py"> |
| … | … | @@ -5234,7 +8319,13 @@ This module provides security controls for executing functions, |
| 5234 | 8319 | with a focus on a prompt-based permission system. |
| 5235 | 8320 | """ |
| 5236 | 8321 | from typing import Dict, Any, Optional, Tuple, Set |
| 8322 | import logging |
| 8323 | import re |
| 8324 | |
| 8325 | from ollamatools.errors import PermissionError, ERROR_CODES |
| 5237 | 8326 | |
| 8327 | # Set up logging |
| 8328 | logger = logging.getLogger(__name__) |
| 5238 | 8329 | |
| 5239 | 8330 | class PermissionManager: |
| 5240 | 8331 | """ |
| … | … | @@ -5266,6 +8357,9 @@ class PermissionManager: |
| 5266 | 8357 | self.prompt_enabled = prompt_enabled |
| 5267 | 8358 | self.sudo_always_prompt = sudo_always_prompt |
| 5268 | 8359 | 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) |
| 5269 | 8363 | |
| 5270 | 8364 | def request_permission( |
| 5271 | 8365 | self, |
| … | … | @@ -5286,6 +8380,9 @@ class PermissionManager: |
| 5286 | 8380 | |
| 5287 | 8381 | Returns: |
| 5288 | 8382 | bool: True if permission granted, False otherwise |
| 8383 | |
| 8384 | Raises: |
| 8385 | PermissionError: If there's an issue with the permission request |
| 5289 | 8386 | """ |
| 5290 | 8387 | # Format the arguments as a readable string |
| 5291 | 8388 | args_str = ', '.join(f"{k}={v!r}" for k, v in args.items()) |
| … | … | @@ -5299,13 +8396,16 @@ class PermissionManager: |
| 5299 | 8396 | # Always prompt for sudo if sudo_always_prompt is True |
| 5300 | 8397 | if is_sudo and self.sudo_always_prompt: |
| 5301 | 8398 | should_prompt = True |
| 8399 | logger.info("Forcing permission prompt for sudo command") |
| 5302 | 8400 | |
| 5303 | 8401 | # Check if this operation is already allowed for the session |
| 5304 | 8402 | if permission_key in self.session_permissions: |
| 8403 | logger.debug("Permission already granted for session: %s", permission_key) |
| 5305 | 8404 | return True |
| 5306 | 8405 | |
| 5307 | 8406 | # If prompting is disabled and not overridden by sudo, grant permission |
| 5308 | 8407 | if not should_prompt: |
| 8408 | logger.debug("Permission prompts disabled, automatically granting permission for: %s", permission_key) |
| 5309 | 8409 | return True |
| 5310 | 8410 | |
| 5311 | 8411 | # Display the permission prompt |
| … | … | @@ -5326,35 +8426,62 @@ class PermissionManager: |
| 5326 | 8426 | print(" [D] Deny: Reject the operation") |
| 5327 | 8427 | |
| 5328 | 8428 | 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() |
| 5337 | 8431 | |
| 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.") |
| 5350 | 8472 | return False |
| 5351 | 8473 | |
| 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 | ) |
| 5358 | 8485 | |
| 5359 | 8486 | def modify_command( |
| 5360 | 8487 | self, |
| … | … | @@ -5370,6 +8497,9 @@ class PermissionManager: |
| 5370 | 8497 | |
| 5371 | 8498 | Returns: |
| 5372 | 8499 | Optional[Dict[str, Any]]: Modified arguments or None if cancelled |
| 8500 | |
| 8501 | Raises: |
| 8502 | PermissionError: If there's an issue with the modification process |
| 5373 | 8503 | """ |
| 5374 | 8504 | print("\n" + "="*60) |
| 5375 | 8505 | print("MODIFY OPERATION") |
| … | … | @@ -5380,85 +8510,471 @@ class PermissionManager: |
| 5380 | 8510 | # Create a copy of the arguments that will be modified |
| 5381 | 8511 | modified_args = args.copy() |
| 5382 | 8512 | |
| 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 |
| 5412 | 8520 | 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 |
| 5415 | 8602 | |
| 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 | """ |
| 8644 | Tests for the permission management system. |
| 8645 | """ |
| 8646 | import pytest |
| 8647 | import sys |
| 8648 | import os |
| 8649 | |
| 8650 | # Add the parent directory to the path so we can import the package |
| 8651 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) |
| 8652 | |
| 8653 | from ollamatools.security import PermissionManager |
| 8654 | </file> |
| 8655 | |
| 8656 | <file path="ollamatools/url_validator.py"> |
| 8657 | """ |
| 8658 | URL validation module for OllamaTools. |
| 8659 | |
| 8660 | This module provides functions for validating and sanitizing URLs |
| 8661 | to prevent security vulnerabilities like SSRF attacks and URL injection. |
| 8662 | """ |
| 8663 | |
| 8664 | import re |
| 8665 | import ipaddress |
| 8666 | from urllib.parse import urlparse, parse_qs, urlencode, urlunparse, unquote |
| 8667 | import logging |
| 8668 | from typing import Dict, Any, List, Set, Optional, Tuple, Union |
| 8669 | |
| 8670 | # Set up logging |
| 8671 | logger = logging.getLogger(__name__) |
| 8672 | |
| 8673 | # Default allowed schemes |
| 8674 | DEFAULT_ALLOWED_SCHEMES = {'http', 'https'} |
| 8675 | |
| 8676 | # Default blocklist of domains |
| 8677 | DEFAULT_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) |
| 8689 | DEFAULT_DOMAIN_ALLOWLIST = set() |
| 8690 | |
| 8691 | # Suspicious URL patterns |
| 8692 | SUSPICIOUS_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 | |
| 8699 | class URLValidationError(Exception): |
| 8700 | """Exception raised for URL validation errors.""" |
| 8701 | pass |
| 8702 | |
| 8703 | |
| 8704 | def 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 | |
| 8758 | def 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 | |
| 8801 | def 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 | |
| 8836 | def 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 | |
| 8925 | def 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 |
| 5419 | 8935 | |
| 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() |
| 5426 | 8942 | |
| 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) |
| 5430 | 8946 | |
| 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 | | """ |
| 5452 | | Tests for the permission management system. |
| 5453 | | """ |
| 5454 | | import pytest |
| 5455 | | import sys |
| 5456 | | import os |
| 8947 | if additional_blocklist: |
| 8948 | blocklist.update(additional_blocklist) |
| 8949 | |
| 8950 | return allowlist, blocklist |
| 5457 | 8951 | |
| 5458 | | # Add the parent directory to the path so we can import the package |
| 5459 | | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) |
| 5460 | 8952 | |
| 5461 | | from ollamatools.security import PermissionManager |
| 8953 | def 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 |
| 5462 | 8978 | </file> |
| 5463 | 8979 | |
| 5464 | 8980 | <file path="ollamatools.egg-info/dependency_links.txt"> |
| … | … | @@ -7812,115 +11328,488 @@ def test_post_request_with_data(mock_request): |
| 7812 | 11328 | mock_response.url = 'https://example.com/api' |
| 7813 | 11329 | mock_request.return_value = mock_response |
| 7814 | 11330 | |
| 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 |
| 11356 | def 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 |
| 11364 | def 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') |
| 11370 | def 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 |
| 11377 | def 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') |
| 11393 | def 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 | |
| 11413 | All notable changes to the OllamaTools project will be documented in this file. |
| 11414 | |
| 11415 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), |
| 11416 | and 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 | """ |
| 11488 | Command validation module for OllamaTools. |
| 11489 | |
| 11490 | This module provides functions for validating and sanitizing shell commands |
| 11491 | to prevent command injection and other security vulnerabilities. |
| 11492 | """ |
| 11493 | |
| 11494 | import os |
| 11495 | import re |
| 11496 | import shlex |
| 11497 | import logging |
| 11498 | import pathlib |
| 11499 | from typing import List, Optional, Dict, Set, Tuple, Union |
| 11500 | |
| 11501 | # Set up logging |
| 11502 | logger = logging.getLogger(__name__) |
| 11503 | |
| 11504 | # Default allowlist of commands |
| 11505 | DEFAULT_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 |
| 11529 | HIGH_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) |
| 11537 | PROTECTED_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 |
| 11544 | DANGEROUS_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 | |
| 11556 | class CommandValidationError(Exception): |
| 11557 | """Exception raised for command validation errors.""" |
| 11558 | pass |
| 11559 | |
| 11560 | |
| 11561 | def 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 | |
| 11580 | def 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 | |
| 11622 | def 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 | |
| 11679 | def 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 | |
| 11705 | def 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)}") |
| 7817 | 11738 | |
| 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 | ) |
| 7824 | 11744 | |
| 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) |
| 7828 | 11747 | |
| 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 |
| 7840 | | def 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) |
| 7843 | 11750 | |
| 7844 | | with pytest.raises(ValueError): |
| 7845 | | fetch_url('not-a-url') |
| 11751 | return validated_command, validated_args |
| 7846 | 11752 | |
| 7847 | | # Test request with invalid method |
| 7848 | | def test_invalid_method(): |
| 7849 | | with pytest.raises(ValueError): |
| 7850 | | fetch_url('https://example.com', method='INVALID') |
| 7851 | 11753 | |
| 7852 | | # Test request timeout |
| 7853 | | @patch('requests.request') |
| 7854 | | def test_request_timeout(mock_request): |
| 7855 | | mock_request.side_effect = requests.exceptions.Timeout("Request timed out") |
| 11754 | def is_sudo_command(command: str, args: Optional[List[str]] = None) -> bool: |
| 11755 | """ |
| 11756 | Check if a command uses sudo. |
| 7856 | 11757 | |
| 7857 | | with pytest.raises(requests.exceptions.Timeout): |
| 7858 | | fetch_url('https://example.com', timeout=5) |
| 7859 | | |
| 7860 | | # Test permission denied |
| 7861 | | def 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 = [] |
| 7865 | 11767 | |
| 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 |
| 7871 | 11771 | |
| 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') |
| 7877 | | def 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 |
| 7885 | 11775 | |
| 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 |
| 7888 | 11780 | |
| 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 | | |
| 7897 | | All notable changes to the OllamaTools project will be documented in this file. |
| 11781 | return False |
| 7898 | 11782 | |
| 7899 | | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), |
| 7900 | | and 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 |
| 7917 | 11783 | |
| 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 |
| 11784 | def 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 |
| 7924 | 11813 | </file> |
| 7925 | 11814 | |
| 7926 | 11815 | <file path="COMPLETION_REPORT.md"> |
| … | … | @@ -8232,30 +12121,452 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 8232 | 12121 | copies of the Software, and to permit persons to whom the Software is |
| 8233 | 12122 | furnished to do so, subject to the following conditions: |
| 8234 | 12123 | |
| 8235 | | The above copyright notice and this permission notice shall be included in all |
| 8236 | | copies or substantial portions of the Software. |
| 12124 | The above copyright notice and this permission notice shall be included in all |
| 12125 | copies or substantial portions of the Software. |
| 12126 | |
| 12127 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 12128 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 12129 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 12130 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 12131 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 12132 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 12133 | SOFTWARE. |
| 12134 | </file> |
| 12135 | |
| 12136 | <file path="make_executable.sh"> |
| 12137 | #!/bin/bash |
| 12138 | # Make scripts executable |
| 12139 | chmod +x mkdir_command.sh |
| 12140 | chmod +x scripts/install_and_verify.sh |
| 12141 | chmod +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 |
| 12147 | mkdir -p scripts |
| 12148 | </file> |
| 12149 | |
| 12150 | <file path="path_validator.py"> |
| 12151 | """ |
| 12152 | Path validation module for OllamaTools. |
| 12153 | |
| 12154 | This module provides functions for validating and sanitizing file paths |
| 12155 | to prevent path traversal attacks and other file path-related security issues. |
| 12156 | """ |
| 12157 | |
| 12158 | import os |
| 12159 | import re |
| 12160 | import pathlib |
| 12161 | from typing import Optional, List, Set, Dict, Any, Union, Tuple |
| 12162 | import logging |
| 12163 | |
| 12164 | # Set up logging |
| 12165 | logger = logging.getLogger(__name__) |
| 12166 | |
| 12167 | # Default protected paths - should not be modified |
| 12168 | DEFAULT_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 |
| 12187 | DEFAULT_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 |
| 12194 | DEFAULT_SANDBOX_PATH = os.path.expanduser('~') |
| 12195 | |
| 12196 | class PathValidationError(Exception): |
| 12197 | """Exception raised for path validation errors.""" |
| 12198 | pass |
| 12199 | |
| 12200 | |
| 12201 | def 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 | |
| 12232 | def 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 | |
| 12250 | def 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 | |
| 12287 | def 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 | |
| 12323 | def 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 | |
| 12364 | def 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 | |
| 12415 | def 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 | |
| 8237 | 12472 | |
| 8238 | | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 8239 | | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 8240 | | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 8241 | | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 8242 | | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 8243 | | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 8244 | | SOFTWARE. |
| 8245 | | </file> |
| 12473 | def 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 |
| 8246 | 12536 | |
| 8247 | | <file path="make_executable.sh"> |
| 8248 | | #!/bin/bash |
| 8249 | | # Make scripts executable |
| 8250 | | chmod +x mkdir_command.sh |
| 8251 | | chmod +x scripts/install_and_verify.sh |
| 8252 | | chmod +x examples/final_demo.py |
| 8253 | | </file> |
| 8254 | 12537 | |
| 8255 | | <file path="mkdir_command.sh"> |
| 8256 | | #!/bin/bash |
| 8257 | | # Create scripts directory if it doesn't exist |
| 8258 | | mkdir -p scripts |
| 12538 | def 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 |
| 8259 | 12570 | </file> |
| 8260 | 12571 | |
| 8261 | 12572 | <file path="pytest.ini"> |
| … | … | @@ -8364,6 +12675,237 @@ doc = generate_markdown(content, title, |
| 8364 | 12675 | ``` |
| 8365 | 12676 | </file> |
| 8366 | 12677 | |
| 12678 | <file path="SECURITY.md"> |
| 12679 | # OllamaTools Security Documentation |
| 12680 | |
| 12681 | This document provides an overview of the security features and best practices for the OllamaTools library. |
| 12682 | |
| 12683 | ## Security Architecture |
| 12684 | |
| 12685 | OllamaTools implements multiple layers of security protections: |
| 12686 | |
| 12687 | 1. **Permission System**: A comprehensive permission system that prompts for user approval before executing potentially sensitive operations |
| 12688 | 2. **Command Validation**: Robust validation and sanitization of system commands to prevent command injection attacks |
| 12689 | 3. **Path Validation**: Secure file path handling to prevent path traversal attacks and unauthorized file access |
| 12690 | 4. **URL Validation**: URL validation and sanitization to prevent SSRF and other web-related vulnerabilities |
| 12691 | 5. **Standardized Error Handling**: Consistent error handling with detailed diagnostics for security issues |
| 12692 | |
| 12693 | ## Permission System |
| 12694 | |
| 12695 | The 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 | |
| 12704 | When 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 | |
| 12711 | Example: |
| 12712 | ``` |
| 12713 | ================ |
| 12714 | PERMISSION REQUEST |
| 12715 | ================ |
| 12716 | Function: execute_command |
| 12717 | Arguments: command='ls', args=['-la'] |
| 12718 | |
| 12719 | Options: |
| 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 | |
| 12725 | Your choice [A/S/M/D]: |
| 12726 | ``` |
| 12727 | |
| 12728 | ## Command Validation |
| 12729 | |
| 12730 | The 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 | |
| 12740 | The 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 | |
| 12747 | Custom allowlists can be defined if needed. |
| 12748 | |
| 12749 | ## Path Validation |
| 12750 | |
| 12751 | File 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 | |
| 12761 | By default, file operations are restricted to the user's home directory. This can be customized: |
| 12762 | |
| 12763 | ```python |
| 12764 | from ollamatools.path_validator import configure_path_sandbox |
| 12765 | |
| 12766 | # Configure custom sandbox |
| 12767 | sandbox_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 | |
| 12776 | URL 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 |
| 12787 | from ollamatools.url_validator import configure_domain_lists |
| 12788 | |
| 12789 | # Configure custom domain lists |
| 12790 | allowlist, blocklist = configure_domain_lists( |
| 12791 | additional_allowlist={"trusted-domain.com"}, |
| 12792 | additional_blocklist={"dangerous-domain.com"} |
| 12793 | ) |
| 12794 | ``` |
| 12795 | |
| 12796 | ## Error Handling |
| 12797 | |
| 12798 | The 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 | |
| 12821 | 1. **Minimize Command Usage**: Only use execute_command when absolutely necessary |
| 12822 | 2. **Use Specific Commands**: Be as specific as possible when executing commands |
| 12823 | 3. **Validate User Input**: Never pass unvalidated user input to commands |
| 12824 | 4. **Handle Errors**: Always check command results for errors |
| 12825 | 5. **Set Appropriate Timeouts**: Use timeouts appropriate to the expected runtime |
| 12826 | |
| 12827 | ### File Operations |
| 12828 | |
| 12829 | 1. **Read-Only When Possible**: Use read operations instead of write when possible |
| 12830 | 2. **Be Specific**: Use specific file paths rather than patterns when possible |
| 12831 | 3. **Validate File Contents**: Validate file contents before writing |
| 12832 | 4. **Use Path Validation**: Always use the path validation functions |
| 12833 | 5. **Check For Errors**: Handle file operation errors gracefully |
| 12834 | |
| 12835 | ### Web Requests |
| 12836 | |
| 12837 | 1. **Use Allowlists**: Define domain allowlists for web requests |
| 12838 | 2. **Set Timeouts**: Always set appropriate timeouts for web requests |
| 12839 | 3. **Validate Responses**: Check response status and content |
| 12840 | 4. **Limit Request Rate**: Avoid making too many requests in a short time |
| 12841 | 5. **Handle Errors**: Properly handle web request errors |
| 12842 | |
| 12843 | ## Configuration |
| 12844 | |
| 12845 | Security features can be configured at different levels: |
| 12846 | |
| 12847 | ### Global Configuration |
| 12848 | |
| 12849 | ```python |
| 12850 | from ollamatools import Config |
| 12851 | |
| 12852 | config = 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 |
| 12863 | from ollamatools.tools.system_tools import execute_command |
| 12864 | |
| 12865 | # Custom allowlist for this operation |
| 12866 | custom_allowlist = {"ls", "cat", "grep"} |
| 12867 | |
| 12868 | result = execute_command( |
| 12869 | command="ls", |
| 12870 | args=["-la"], |
| 12871 | timeout=10, |
| 12872 | custom_allowlist=custom_allowlist |
| 12873 | ) |
| 12874 | ``` |
| 12875 | |
| 12876 | ## Logging and Monitoring |
| 12877 | |
| 12878 | OllamaTools 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 | |
| 12886 | Enable debug logging for more detailed security information: |
| 12887 | |
| 12888 | ```python |
| 12889 | from ollamatools import enable_debug_logging |
| 12890 | |
| 12891 | enable_debug_logging("ollamatools.security") |
| 12892 | ``` |
| 12893 | |
| 12894 | ## Security Updates |
| 12895 | |
| 12896 | The security enhancements in this version include: |
| 12897 | |
| 12898 | 1. Enhanced command validation and sanitization |
| 12899 | 2. Improved URL validation to prevent SSRF attacks |
| 12900 | 3. New path validation module for file operations |
| 12901 | 4. Standardized error handling |
| 12902 | 5. Better sudo command detection |
| 12903 | 6. Path sandboxing for file operations |
| 12904 | 7. Improved permission request workflow |
| 12905 | |
| 12906 | Future updates will continue to improve security features based on evolving best practices. |
| 12907 | </file> |
| 12908 | |
| 8367 | 12909 | <file path="setup.py"> |
| 8368 | 12910 | """ |
| 8369 | 12911 | Setup script for the OllamaTools package. |
| … | … | @@ -8525,4 +13067,328 @@ if __name__ == "__main__": |
| 8525 | 13067 | tree -L 3 # Limit depth to 3 levels |
| 8526 | 13068 | </file> |
| 8527 | 13069 | |
| 13070 | <file path="url_validator.py"> |
| 13071 | """ |
| 13072 | URL validation module for OllamaTools. |
| 13073 | |
| 13074 | This module provides functions for validating and sanitizing URLs |
| 13075 | to prevent security vulnerabilities like SSRF attacks and URL injection. |
| 13076 | """ |
| 13077 | |
| 13078 | import re |
| 13079 | import ipaddress |
| 13080 | from urllib.parse import urlparse, parse_qs, urlencode, urlunparse, unquote |
| 13081 | import logging |
| 13082 | from typing import Dict, Any, List, Set, Optional, Tuple, Union |
| 13083 | |
| 13084 | # Set up logging |
| 13085 | logger = logging.getLogger(__name__) |
| 13086 | |
| 13087 | # Default allowed schemes |
| 13088 | DEFAULT_ALLOWED_SCHEMES = {'http', 'https'} |
| 13089 | |
| 13090 | # Default blocklist of domains |
| 13091 | DEFAULT_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) |
| 13103 | DEFAULT_DOMAIN_ALLOWLIST = set() |
| 13104 | |
| 13105 | # Suspicious URL patterns |
| 13106 | SUSPICIOUS_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 | |
| 13113 | class URLValidationError(Exception): |
| 13114 | """Exception raised for URL validation errors.""" |
| 13115 | pass |
| 13116 | |
| 13117 | |
| 13118 | def 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 | |
| 13172 | def 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 | |
| 13215 | def 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 | |
| 13250 | def 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 | |
| 13339 | def 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 | |
| 13367 | def 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 | |
| 8528 | 13394 | </files> |